From 956c464e978c3cfae1e038625444b14dae4c9a4b Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 03:58:27 -0500 Subject: [PATCH 01/37] feat: add cell host adapter and aggregate cell class Second portable-command host: CausalWorkspace talks to a per-shard CellStreamStore (in-process stand-in for private SQLite, not sqlx and not a celld Cargo feature). AggregateCell mounts the same PortableCommand declarations as SOA Routes and dispatches them without GraphQL or projectors. Implements [[tasks/portable-command-hosts-4]] --- src/lib.rs | 2 + src/microsvc/cell_host/cell.rs | 168 +++++++++ src/microsvc/cell_host/mod.rs | 19 + src/microsvc/cell_host/store.rs | 330 ++++++++++++++++++ src/microsvc/cell_host/tests.rs | 297 ++++++++++++++++ src/microsvc/dependencies.rs | 6 +- src/microsvc/mod.rs | 1 + src/microsvc/service/handlers.rs | 1 - src/microsvc/service/routes.rs | 98 +++++- src/microsvc/service/tests.rs | 10 + tests/e2e-ui/crates/todo-domain/Cargo.toml | 1 + .../e2e-ui/crates/todo-domain/src/commands.rs | 43 +++ 12 files changed, 971 insertions(+), 5 deletions(-) create mode 100644 src/microsvc/cell_host/cell.rs create mode 100644 src/microsvc/cell_host/mod.rs create mode 100644 src/microsvc/cell_host/store.rs create mode 100644 src/microsvc/cell_host/tests.rs diff --git a/src/lib.rs b/src/lib.rs index 894a4adbd..5eef9b718 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,8 @@ pub mod lock; #[cfg(feature = "metrics")] pub mod metrics; pub mod microsvc; +/// Celld Durable Object host adapter (not a sqlx dialect; no `celld` feature). +pub use microsvc::cell_host; pub mod mutation; pub mod outbox; pub mod outbox_worker; diff --git a/src/microsvc/cell_host/cell.rs b/src/microsvc/cell_host/cell.rs new file mode 100644 index 000000000..4f61002ab --- /dev/null +++ b/src/microsvc/cell_host/cell.rs @@ -0,0 +1,168 @@ +//! Aggregate cell class: one Durable Object analogue per aggregate type, +//! one instance per shard (`{aggregate_type}:{shard}`). + +use std::collections::HashMap; + +use serde_json::Value; + +use super::store::CellStreamStore; +use crate::aggregate::{Aggregate, AggregateRepository}; +use crate::microsvc::error::HandlerError; +use crate::microsvc::service::{PortableCommand, Routes}; +use crate::microsvc::session::Session; +use crate::repository::{RepositoryError, StreamIdentity}; + +/// Cell class for aggregate `A`. Equivalent to +/// `#[distributed::cell(aggregate = A)]`: mount the same domain +/// [`PortableCommand`] values used by SOA `Routes::mount`. +/// +/// Projectors, GraphQL, and ingest are not methods on this type +/// (`PCH-REQ-005`). +/// +/// ```compile_fail +/// fn projectors_are_not_cell_methods(cell: distributed::cell_host::AggregateCell) +/// where +/// A: distributed::Aggregate + Send + Sync + 'static, +/// { +/// let _ = cell.causal_projector; +/// } +/// ``` +/// +/// ```compile_fail +/// fn graphql_is_not_a_cell_method(cell: distributed::cell_host::AggregateCell) +/// where +/// A: distributed::Aggregate + Send + Sync + 'static, +/// { +/// let _ = cell.bind_graphql; +/// } +/// ``` +pub struct AggregateCell +where + A: Aggregate + Send + Sync + 'static, +{ + shard: StreamIdentity, + routes: Routes>, +} + +impl AggregateCell +where + A: Aggregate + Send + Sync + 'static, +{ + /// Open a cell instance addressed as `{aggregate_type}:{shard_id}`. + pub fn new(shard_id: impl Into) -> Result { + let shard = StreamIdentity::new(A::aggregate_type(), shard_id.into())?; + let store = CellStreamStore::for_identity(shard.clone()); + Ok(Self { + shard, + routes: Routes::from_dependencies(AggregateRepository::new(store)), + }) + } + + /// Durable Object name: `format!("{}:{}", type, shard)`. + pub fn instance_name(&self) -> String { + self.shard.to_string() + } + + /// Shard id used for cell addressing and SOA `load_by`. + pub fn shard_id(&self) -> &str { + self.shard.aggregate_id() + } + + /// Install a domain command declaration. Same value SOA mounts. + pub fn mount( + mut self, + command: impl PortableCommand>, + ) -> Self { + self.routes = self.routes.mount(command); + self + } + + /// Command ids mounted on this cell class instance. + pub fn command_names(&self) -> Vec { + self.routes + .command_specs() + .unwrap_or_default() + .into_iter() + .map(|spec| spec.id) + .collect() + } + + /// True when this cell has only command mounts (no projectors/GraphQL services). + pub fn is_command_only(&self) -> bool { + self.routes.is_command_only() + } + + /// Dispatch a mounted command through the cell-local workspace adapter. + pub async fn dispatch( + &self, + command: &str, + input: Value, + session: Session, + ) -> Result { + self.routes + .dispatch_cell_command(command, input, session, &self.shard) + .await + } +} + +/// Worker-side namespace: `getByName(format!("{}:{}", type, shard))`. +pub struct CellNamespace +where + A: Aggregate + Send + Sync + 'static, +{ + cells: HashMap>, +} + +impl Default for CellNamespace +where + A: Aggregate + Send + Sync + 'static, +{ + fn default() -> Self { + Self::new() + } +} + +impl CellNamespace +where + A: Aggregate + Send + Sync + 'static, +{ + pub fn new() -> Self { + Self { + cells: HashMap::new(), + } + } + + /// Address a cell by Durable Object name. + pub fn get_by_name(&self, name: &str) -> Option<&AggregateCell> { + self.cells.get(name) + } + + /// Mutable address by Durable Object name. + pub fn get_by_name_mut(&mut self, name: &str) -> Option<&mut AggregateCell> { + self.cells.get_mut(name) + } + + /// Insert a fully mounted cell instance. + pub fn insert(&mut self, cell: AggregateCell) { + self.cells.insert(cell.instance_name(), cell); + } + + /// Create or return the cell for `shard_id`. + pub fn get_or_create( + &mut self, + shard_id: &str, + mount: impl FnOnce(AggregateCell) -> AggregateCell, + ) -> Result<&mut AggregateCell, RepositoryError> { + let name = instance_name::(shard_id); + if !self.cells.contains_key(&name) { + let cell = mount(AggregateCell::new(shard_id)?); + self.cells.insert(name.clone(), cell); + } + Ok(self.cells.get_mut(&name).expect("just inserted")) + } +} + +/// Cell instance name: `{aggregate_type}:{shard_id}`. +pub fn instance_name(shard_id: &str) -> String { + format!("{}:{shard_id}", A::aggregate_type()) +} diff --git a/src/microsvc/cell_host/mod.rs b/src/microsvc/cell_host/mod.rs new file mode 100644 index 000000000..01232e198 --- /dev/null +++ b/src/microsvc/cell_host/mod.rs @@ -0,0 +1,19 @@ +//! Second command host: a celld Durable Object class analogue. +//! +//! Domain crates keep `CausalCommandContext` / `ctx.repo()`. This module is +//! the host adapter: one named cell per shard, private stream store, same +//! [`PortableCommand`] mounts as SOA `Routes`. It is **not** a sqlx dialect +//! and must not be gated behind `feature = "celld"` (`PCH-DEC-005`). +//! +//! Live celld fleet / CI is not required (`PCH-AC-006.1`). A workers-rs +//! `Send` tax stays in this adapter; cell types do not leak into domain +//! command declarations. + +mod cell; +mod store; + +pub use cell::{instance_name, AggregateCell, CellNamespace}; +pub use store::CellStreamStore; + +#[cfg(test)] +mod tests; diff --git a/src/microsvc/cell_host/store.rs b/src/microsvc/cell_host/store.rs new file mode 100644 index 000000000..b4c6c8ed5 --- /dev/null +++ b/src/microsvc/cell_host/store.rs @@ -0,0 +1,330 @@ +//! Per-shard stream store: in-process stand-in for one cell's private SQLite. +//! +//! Production celld wraps rusqlite (or workers-rs storage) the same way: sync +//! calls inside async fns. This is **not** `feature = "sqlite"` (sqlx pool) and +//! **not** a `celld` dialect. + +use std::future::Future; + +use crate::command_ledger::{ + AttemptFence, CausalCommitBatch, CausalGetStream, CausalRepositoryIdentity, + CausalStorageIdentity, CausalTransactionalCommit, CommandLedgerError, CommandLedgerKey, + CommandLedgerStore, CommandLookup, CommandLookupScope, CommandReservation, ReservationOutcome, +}; +use crate::entity::Entity; +use crate::microsvc::HasOutboxStore; +use crate::projection_protocol::{ + ProjectionChangeCursor, ProjectionChangeRead, ProjectionCheckpoint, ProjectionCommitBatch, + ProjectionCommitResult, ProjectionFailure, ProjectionFailureBatch, ProjectionFailureLocation, + ProjectionGeneration, ProjectionInputCursor, ProjectionInputDisposition, + ProjectionLiveRecordBatch, ProjectionLiveRecordBatchRequest, ProjectionModelOwnership, + ProjectionObligationEvidenceBatch, ProjectionObligationEvidenceBatchRequest, + ProjectionObservation, ProjectionObservationKind, ProjectionPartition, + ProjectionPartitionRuntimeState, ProjectionProtocolError, ProjectionProtocolStore, + ProjectionQuerySnapshot, ProjectionQuerySnapshotBatch, ProjectionQuerySnapshotBatchRequest, + ProjectionQuerySnapshotRequest, ProjectionRecordMetadata, ProjectionRecordScope, + ProjectorTopologyId, TrustedProjectionInput, +}; +use crate::repository::{ + CommitBatch, RepositoryError, SnapshotWrite, StreamIdentity, TransactionalCommit, +}; +use crate::{InMemoryOutboxStore, InMemoryRepository}; + +/// Private SQLite stand-in for one cell instance (`{aggregate_type}:{shard}`). +/// +/// Loads and commits are rejected for any stream that is not this cell's shard. +#[derive(Clone)] +pub struct CellStreamStore { + identity: StreamIdentity, + inner: InMemoryRepository, +} + +impl CellStreamStore { + /// Bind a store to one exact stream identity. + pub fn for_identity(identity: StreamIdentity) -> Self { + Self { + identity, + inner: InMemoryRepository::new(), + } + } + + /// Named cell constructor used by [`super::AggregateCell`]. + pub fn new( + aggregate_type: impl Into, + shard_id: impl Into, + ) -> Result { + Ok(Self::for_identity(StreamIdentity::new( + aggregate_type, + shard_id, + )?)) + } + + /// Cell instance name (`type:id`). + pub fn instance_name(&self) -> String { + self.identity.to_string() + } + + /// Stream this cell owns. + pub fn identity(&self) -> &StreamIdentity { + &self.identity + } + + fn ensure_identity(&self, identity: &StreamIdentity) -> Result<(), RepositoryError> { + if identity != &self.identity { + return Err(RepositoryError::Model(format!( + "cell `{}` cannot access stream `{identity}`", + self.identity + ))); + } + Ok(()) + } + + fn ensure_batch(&self, batch: &CommitBatch<'_>) -> Result<(), RepositoryError> { + for stream in &batch.streams { + self.ensure_identity(&stream.identity)?; + } + for snapshot in &batch.snapshots { + match snapshot { + SnapshotWrite::Save { identity, .. } => self.ensure_identity(identity)?, + } + } + Ok(()) + } +} + +impl CausalGetStream for CellStreamStore { + fn get_causal_stream<'a>( + &'a self, + identity: &'a StreamIdentity, + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + self.ensure_identity(identity)?; + CausalGetStream::get_causal_stream(&self.inner, identity).await + } + } +} + +impl CausalRepositoryIdentity for CellStreamStore { + fn causal_storage_identity(&self) -> CausalStorageIdentity { + CausalRepositoryIdentity::causal_storage_identity(&self.inner) + } +} + +impl CommandLedgerStore for CellStreamStore { + fn reserve_command( + &self, + reservation: CommandReservation, + ) -> impl Future> + Send + '_ { + CommandLedgerStore::reserve_command(&self.inner, reservation) + } + + fn lookup_command<'a>( + &'a self, + key: &'a CommandLedgerKey, + scope: CommandLookupScope<'a>, + ) -> impl Future> + Send + 'a { + CommandLedgerStore::lookup_command(&self.inner, key, scope) + } + + fn mark_retryable_unknown( + &self, + attempt: AttemptFence, + ) -> impl Future> + Send + '_ { + CommandLedgerStore::mark_retryable_unknown(&self.inner, attempt) + } + + fn compact_expired_commands( + &self, + limit: usize, + ) -> impl Future> + Send + '_ { + CommandLedgerStore::compact_expired_commands(&self.inner, limit) + } +} + +impl TransactionalCommit for CellStreamStore { + fn commit_batch<'a>( + &'a self, + batch: CommitBatch<'a>, + ) -> impl Future> + Send + 'a { + async move { + self.ensure_batch(&batch)?; + TransactionalCommit::commit_batch(&self.inner, batch).await + } + } +} + +impl CausalTransactionalCommit for CellStreamStore { + fn commit_causal_batch<'a>( + &'a self, + batch: CausalCommitBatch<'a>, + ) -> impl Future> + Send + 'a { + async move { + self.ensure_batch(&batch.domain) + .map_err(CommandLedgerError::Storage)?; + CausalTransactionalCommit::commit_causal_batch(&self.inner, batch).await + } + } +} + +impl HasOutboxStore for CellStreamStore { + type OutboxStore = InMemoryOutboxStore; + + fn outbox_store(&self) -> Self::OutboxStore { + self.inner.outbox_store() + } +} + +impl ProjectionProtocolStore for CellStreamStore { + fn register_projection_models<'a>( + &'a self, + topology: &'a ProjectorTopologyId, + ownership: &'a [ProjectionModelOwnership], + ) -> impl Future> + Send + 'a { + self.inner.register_projection_models(topology, ownership) + } + + fn commit_projection( + &self, + batch: ProjectionCommitBatch, + ) -> impl Future> + Send + '_ + { + self.inner.commit_projection(batch) + } + + fn record_projection_failure( + &self, + batch: ProjectionFailureBatch, + ) -> impl Future> + Send + '_ { + self.inner.record_projection_failure(batch) + } + + fn projection_checkpoint<'a>( + &'a self, + cursor_scope: &'a ProjectionInputCursor, + generation: ProjectionGeneration, + ) -> impl Future, ProjectionProtocolError>> + Send + 'a + { + self.inner.projection_checkpoint(cursor_scope, generation) + } + + fn projection_record<'a>( + &'a self, + scope: &'a ProjectionRecordScope, + ) -> impl Future, ProjectionProtocolError>> + + Send + + 'a { + self.inner.projection_record(scope) + } + + fn projection_input_disposition<'a>( + &'a self, + input: &'a TrustedProjectionInput, + ) -> impl Future> + Send + 'a + { + self.inner.projection_input_disposition(input) + } + + fn projection_query_snapshot<'a>( + &'a self, + request: &'a ProjectionQuerySnapshotRequest, + ) -> impl Future> + Send + 'a + { + self.inner.projection_query_snapshot(request) + } + + fn projection_query_snapshot_batch<'a>( + &'a self, + request: &'a ProjectionQuerySnapshotBatchRequest, + ) -> impl Future> + Send + 'a + { + self.inner.projection_query_snapshot_batch(request) + } + + fn projection_obligation_evidence_batch<'a>( + &'a self, + request: &'a ProjectionObligationEvidenceBatchRequest, + ) -> impl Future> + + Send + + 'a { + self.inner.projection_obligation_evidence_batch(request) + } + + fn projection_live_record_batch<'a>( + &'a self, + request: &'a ProjectionLiveRecordBatchRequest, + ) -> impl Future> + Send + 'a + { + self.inner.projection_live_record_batch(request) + } + + fn projection_partition_runtime_state<'a>( + &'a self, + topology: &'a ProjectorTopologyId, + partition: &'a ProjectionPartition, + ) -> impl Future, ProjectionProtocolError>> + + Send + + 'a { + self.inner + .projection_partition_runtime_state(topology, partition) + } + + fn projection_observation<'a>( + &'a self, + causation_id: &'a str, + scope: &'a ProjectionRecordScope, + kind: ProjectionObservationKind, + ) -> impl Future, ProjectionProtocolError>> + Send + 'a + { + self.inner.projection_observation(causation_id, scope, kind) + } + + fn projection_changes<'a>( + &'a self, + topology: &'a ProjectorTopologyId, + partition: &'a ProjectionPartition, + after: Option<&'a ProjectionChangeCursor>, + limit: usize, + ) -> impl Future> + Send + 'a + { + self.inner + .projection_changes(topology, partition, after, limit) + } + + fn repair_projection<'a>( + &'a self, + topology: &'a ProjectorTopologyId, + partition: &'a ProjectionPartition, + failure_id: &'a str, + ) -> impl Future> + Send + 'a + { + self.inner + .repair_projection(topology, partition, failure_id) + } + + fn compact_projection_changes<'a>( + &'a self, + through: &'a ProjectionChangeCursor, + ) -> impl Future> + Send + 'a { + self.inner.compact_projection_changes(through) + } + + fn projection_failure<'a>( + &'a self, + topology: &'a ProjectorTopologyId, + partition: &'a ProjectionPartition, + failure_id: &'a str, + ) -> impl Future, ProjectionProtocolError>> + Send + 'a + { + self.inner + .projection_failure(topology, partition, failure_id) + } + + fn projection_failure_location<'a>( + &'a self, + failure_id: &'a str, + ) -> impl Future, ProjectionProtocolError>> + + Send + + 'a { + self.inner.projection_failure_location(failure_id) + } +} diff --git a/src/microsvc/cell_host/tests.rs b/src/microsvc/cell_host/tests.rs new file mode 100644 index 000000000..3aed5b4ee --- /dev/null +++ b/src/microsvc/cell_host/tests.rs @@ -0,0 +1,297 @@ +use super::{instance_name, AggregateCell, CellNamespace, CellStreamStore}; +use crate::aggregate::{Aggregate, AggregateRepository}; +use crate::entity::Entity; +use crate::graphql::{typed_command, PreparedCommand, Succeeded}; +use crate::microsvc::service::{CausalCommandContext, PortableCommand, Routes}; +use crate::microsvc::session::{Session, USER_ID_KEY}; +use crate::microsvc::HandlerError; +use crate::repository::{RepositoryError, TransactionalCommit}; +use crate::sourced; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use super::super::causal::{CausalWorkspace, CausalWorkspaceError}; + +#[derive(Clone, Default)] +struct CellItem { + entity: Entity, + title: String, + done: bool, +} + +#[sourced(entity, aggregate_type = "CellItem")] +impl CellItem { + #[event("cell_item.created", version = 1)] + fn create(&mut self, id: String, title: String) { + self.entity.set_id(id); + self.title = title; + self.done = false; + } + + #[event("cell_item.completed", version = 1)] + fn complete(&mut self) { + self.done = true; + } +} + +#[derive(Debug, Deserialize, crate::GraphqlInput)] +struct CreateInput { + id: String, + title: String, +} + +#[derive(Debug, Serialize, crate::GraphqlOutput)] +struct CreatePayload { + id: String, +} + +#[derive(Debug, Deserialize, crate::GraphqlInput)] +struct CompleteInput { + id: String, +} + +#[derive(Debug, Serialize, crate::GraphqlOutput)] +struct CompletePayload { + id: String, + done: bool, +} + +struct Create; + +impl Create { + const COMMAND: &'static str = "cell_item.create"; +} + +impl PortableCommand for Create +where + D: crate::microsvc::CausalRouteDependencies + Send + Sync + 'static, +{ + fn install(self, routes: Routes) -> Routes { + routes + .typed_command(typed_command::>( + Self::COMMAND, + )) + .guarded( + |ctx: &CausalCommandContext<'_, CellItem>| ctx.session().user_id().is_some(), + handle_create, + ) + } +} + +struct Complete; + +impl Complete { + const COMMAND: &'static str = "cell_item.complete"; + + fn shard(input: &CompleteInput) -> String { + input.id.clone() + } +} + +impl PortableCommand for Complete +where + D: crate::microsvc::CausalRouteDependencies + Send + Sync + 'static, +{ + fn install(self, routes: Routes) -> Routes { + routes + .typed_command(typed_command::>( + Self::COMMAND, + )) + .load_by(|input: &CompleteInput| Complete::shard(input)) + .invoke(|item, _input, _owner| item.complete()) + .succeeded(|item| CompletePayload { + id: item.entity().id().to_string(), + done: item.done, + }) + } +} + +async fn handle_create( + ctx: &CausalCommandContext<'_, CellItem>, + input: CreateInput, +) -> Result>, HandlerError> { + let repo = ctx.repo(); + if repo.get(&input.id).await?.is_some() { + return Err(HandlerError::Rejected(format!( + "cell item {} already exists", + input.id + ))); + } + let mut item = repo.create(); + item.create(input.id.clone(), input.title) + .map_err(|error| HandlerError::Rejected(error.to_string()))?; + repo.commit(item)?.succeeded(CreatePayload { id: input.id }) +} + +fn owner_session() -> Session { + let mut session = Session::new(); + session.set(USER_ID_KEY, "user-1"); + session +} + +fn fn_send_sync(_: &T) {} + +#[tokio::test] +async fn workspace_adapter_loads_and_commits_one_stream_without_sqlx() { + let store = CellStreamStore::new("CellItem", "item-1").expect("identity"); + let repository = AggregateRepository::<_, CellItem>::new(store.clone()); + let workspace = CausalWorkspace::new(&repository); + + let mut item = workspace.create(); + item.create("item-1".into(), "write".into()).unwrap(); + workspace.stage(item).unwrap(); + + let mut parts = workspace.into_parts().unwrap(); + parts.prepare_domain_publications("causation-1").unwrap(); + let batch = parts.prepare_commit_batch().unwrap(); + TransactionalCommit::commit_batch(&store, batch) + .await + .unwrap(); + + let repository = AggregateRepository::<_, CellItem>::new(store.clone()); + let workspace = CausalWorkspace::new(&repository); + let loaded = workspace.load("item-1").await.unwrap().unwrap(); + assert_eq!(loaded.entity().id(), "item-1"); + assert_eq!(loaded.title, "write"); + + match workspace.load("item-2").await { + Err(CausalWorkspaceError::Repository(RepositoryError::Model(message))) => { + assert!( + message.contains("cannot access stream"), + "unexpected message: {message}" + ); + } + other => panic!( + "expected shard fence, got {}", + match other { + Ok(_) => "Ok(checkout)".to_string(), + Err(error) => error.to_string(), + } + ), + } +} + +#[tokio::test] +async fn cell_rejects_commit_of_a_foreign_stream() { + let store = CellStreamStore::new("CellItem", "item-1").expect("identity"); + let repository = AggregateRepository::<_, CellItem>::new(store.clone()); + let workspace = CausalWorkspace::new(&repository); + let mut item = workspace.create(); + item.create("item-2".into(), "other".into()).unwrap(); + workspace.stage(item).unwrap(); + let mut parts = workspace.into_parts().unwrap(); + parts.prepare_domain_publications("causation-1").unwrap(); + let batch = parts.prepare_commit_batch().unwrap(); + let error = TransactionalCommit::commit_batch(&store, batch) + .await + .unwrap_err(); + assert!( + matches!(error, RepositoryError::Model(message) if message.contains("cannot access stream")) + ); +} + +#[tokio::test] +async fn cell_dispatches_complete_with_the_same_portable_command_as_soa() { + let cell = AggregateCell::::new("item-1") + .unwrap() + .mount(Create) + .mount(Complete); + assert_eq!(cell.instance_name(), "CellItem:item-1"); + assert_eq!(instance_name::("item-1"), "CellItem:item-1"); + let names = cell.command_names(); + assert!(names.iter().any(|name| name == "cell_item.create")); + assert!(names.iter().any(|name| name == "cell_item.complete")); + assert!(cell.is_command_only()); + fn_send_sync(&cell); + + let created = cell + .dispatch( + "cell_item.create", + json!({ "id": "item-1", "title": "ship" }), + owner_session(), + ) + .await + .expect("create"); + assert_eq!(created["id"], "item-1"); + + let completed = cell + .dispatch( + "cell_item.complete", + json!({ "id": "item-1" }), + owner_session(), + ) + .await + .expect("complete"); + assert_eq!(completed["id"], "item-1"); + assert_eq!(completed["done"], true); +} + +#[tokio::test] +async fn cell_complete_rejects_a_different_shard_id() { + let cell = AggregateCell::::new("item-1") + .unwrap() + .mount(Create) + .mount(Complete); + cell.dispatch( + "cell_item.create", + json!({ "id": "item-1", "title": "ship" }), + owner_session(), + ) + .await + .unwrap(); + + let error = cell + .dispatch( + "cell_item.complete", + json!({ "id": "item-2" }), + owner_session(), + ) + .await + .unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("cannot access stream") || message.contains("not found"), + "unexpected error: {message}" + ); +} + +#[tokio::test] +async fn namespace_get_by_name_addresses_type_and_shard() { + let mut namespace = CellNamespace::::new(); + namespace + .get_or_create("item-7", |cell| cell.mount(Create).mount(Complete)) + .unwrap(); + let cell = namespace + .get_by_name("CellItem:item-7") + .expect("named cell"); + assert_eq!(cell.shard_id(), "item-7"); + assert!(namespace.get_by_name("CellItem:missing").is_none()); +} + +#[test] +fn cargo_features_keep_sqlite_and_do_not_add_celld() { + let manifest = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")); + assert!( + manifest + .lines() + .any(|line| line.trim_start().starts_with("sqlite =")), + "sqlite feature must remain next to postgres" + ); + assert!( + manifest + .lines() + .any(|line| line.trim_start().starts_with("postgres =")), + "postgres feature must remain next to sqlite" + ); + let features = manifest + .split("[features]") + .nth(1) + .and_then(|rest| rest.split("\n[").next()) + .expect("features table"); + assert!( + !features + .lines() + .any(|line| line.trim_start().starts_with("celld")), + "PCH-DEC-005: do not add a celld Cargo feature beside sqlite/postgres" + ); +} diff --git a/src/microsvc/dependencies.rs b/src/microsvc/dependencies.rs index ddc2a399c..d618e2d2a 100644 --- a/src/microsvc/dependencies.rs +++ b/src/microsvc/dependencies.rs @@ -6,7 +6,9 @@ use crate::command_ledger::{ }; use crate::outbox::OutboxPublisherConfig; use crate::projection_protocol::ProjectionProtocolStore; -use crate::repository::{ReadModelWritePlanStore, RelationalReadModelQueryStore, Repository}; +use crate::repository::{ + ReadModelWritePlanStore, RelationalReadModelQueryStore, Repository, TransactionalCommit, +}; /// Dependency capability for services that expose an aggregate repository. pub trait HasRepo { @@ -27,6 +29,7 @@ pub trait CausalRepositoryBackend: + CausalTransactionalCommit + CausalRepositoryIdentity + ProjectionProtocolStore + + TransactionalCommit + Send + Sync + 'static @@ -39,6 +42,7 @@ impl CausalRepositoryBackend for T where + CausalTransactionalCommit + CausalRepositoryIdentity + ProjectionProtocolStore + + TransactionalCommit + Send + Sync + 'static diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 05bebdfa7..483192dd7 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -56,6 +56,7 @@ //! ``` mod causal; +pub mod cell_host; mod context; mod descriptor; mod dependencies; diff --git a/src/microsvc/service/handlers.rs b/src/microsvc/service/handlers.rs index cc3d892d6..b64fa38e6 100644 --- a/src/microsvc/service/handlers.rs +++ b/src/microsvc/service/handlers.rs @@ -542,7 +542,6 @@ impl<'a, A> CausalCommandContext<'a, A> where A: Aggregate + Send + Sync + 'static, { - #[cfg(feature = "graphql")] pub(super) fn new( message: &'a Message, session: &'a Session, diff --git a/src/microsvc/service/routes.rs b/src/microsvc/service/routes.rs index 198b3dfda..fc575605e 100644 --- a/src/microsvc/service/routes.rs +++ b/src/microsvc/service/routes.rs @@ -40,7 +40,6 @@ use crate::graphql::command_input::canonicalize_command_input; #[cfg(feature = "graphql")] use crate::graphql::identity::VerifiedPrincipal; use crate::graphql::{command_transition, GraphqlInputType, SurfaceProjector, TypedCommand}; -#[cfg(feature = "graphql")] use crate::microsvc::causal::CausalWorkspace; use crate::microsvc::context::Context; use crate::microsvc::dependencies::{ @@ -75,6 +74,7 @@ use crate::outbox_worker::{ use crate::projection_protocol::ProjectionProtocolStore; #[cfg(feature = "graphql")] use crate::projection_protocol::{CompiledProjectionTopology, ProjectorTopologyId}; +use crate::repository::{StreamIdentity, TransactionalCommit}; use serde_json::Value; /// How a handler expects the transport to deliver matching messages. @@ -233,6 +233,15 @@ pub(super) trait ErasedCausalHandler: Send + Sync { session: &'a Session, protocol: Option, ) -> CausalStatusFuture<'a>; + + /// Run the same typed `handle` inside one cell, without GraphQL receipts. + fn invoke_cell<'a>( + &'a self, + dependencies: &'a D, + input: Value, + session: Session, + shard: &'a StreamIdentity, + ) -> Pin> + Send + 'a>>; } struct RegisteredCausalHandler @@ -241,9 +250,7 @@ where K: CommandOutcome, { contract: TypedCommandContract, - #[cfg_attr(not(feature = "graphql"), allow(dead_code))] guard: Option>>, - #[cfg_attr(not(feature = "graphql"), allow(dead_code))] handle: Arc>, /// Retryable, fail-closed bootstrap for the bound projector's complete /// model/table ownership inventory. `get_or_try_init` leaves the cell empty @@ -991,6 +998,37 @@ impl Routes { command.install(self) } + /// Dispatch a typed causal command inside one cell (no GraphQL envelope). + pub(in crate::microsvc) async fn dispatch_cell_command( + &self, + command: &str, + input: Value, + session: Session, + shard: &StreamIdentity, + ) -> Result { + let handler = self + .handlers + .get(&MessageKind::Command) + .and_then(|handlers| handlers.get(command)); + match handler { + Some(RegisteredHandler::Causal(handler)) => { + handler + .invoke_cell(&self.dependencies, input, session, shard) + .await + } + Some(_) | None => Err(HandlerError::UnknownCommand(command.to_string())), + } + } + + pub(in crate::microsvc) fn is_command_only(&self) -> bool { + self.projectors.is_empty() + && self.modeled_local_services.is_empty() + && self + .handler_specs + .iter() + .all(|spec| spec.kind == MessageKind::Command) + } + /// Register a typed command declaration and its executable handler as one /// inventory entry. pub fn typed_command(self, command: TypedCommand) -> TypedRouteBuilder @@ -1448,6 +1486,7 @@ impl CommandMountRegistrar for Routes { impl ErasedCausalHandler for RegisteredCausalHandler where D: CausalRouteDependencies + Send + Sync + 'static, + D::Backend: TransactionalCommit, A: Aggregate + Send + Sync + 'static, I: serde::de::DeserializeOwned + Send + 'static, K: CommandOutcome, @@ -1942,6 +1981,59 @@ where .await }) } + + fn invoke_cell<'a>( + &'a self, + dependencies: &'a D, + input: Value, + session: Session, + shard: &'a StreamIdentity, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let payload = serde_json::to_vec(&input) + .map_err(|error| HandlerError::DecodeFailed(error.to_string()))?; + let typed: I = serde_json::from_value(input) + .map_err(|error| HandlerError::DecodeFailed(error.to_string()))?; + let message = Message::new(self.contract.name.clone(), MessageKind::Command, payload); + let aggregate_repository = dependencies.__causal_aggregate_repository(); + let workspace = CausalWorkspace::new(aggregate_repository); + let context = CausalCommandContext::new(&message, &session, &workspace); + if self.guard.as_ref().is_some_and(|guard| !guard(&context)) { + return Err(HandlerError::GuardRejected(self.contract.name.clone())); + } + let mut prepared = (self.handle)(&context, typed).await?; + let mut parts = workspace + .into_parts() + .map_err(super::handlers::workspace_handler_error)?; + let causation = uuid::Uuid::now_v7().hyphenated().to_string(); + parts + .prepare_domain_publications(&causation) + .map_err(super::handlers::workspace_handler_error)?; + parts + .validate_prepared(&self.contract, &mut prepared) + .map_err(|error| HandlerError::Rejected(error.to_string()))?; + { + let batch = parts + .prepare_commit_batch() + .map_err(super::handlers::workspace_handler_error)?; + for stream in &batch.streams { + if stream.identity != *shard { + return Err(HandlerError::Rejected(format!( + "cell `{shard}` cannot commit stream `{}`", + stream.identity + ))); + } + } + TransactionalCommit::commit_batch(aggregate_repository.repo(), batch) + .await + .map_err(HandlerError::from)?; + } + parts + .mark_committed_state() + .map_err(super::handlers::workspace_handler_error)?; + Ok(prepared.serialized_payload().clone()) + }) + } } impl ErasedRoutes for Routes diff --git a/src/microsvc/service/tests.rs b/src/microsvc/service/tests.rs index a26b9ea31..5c1330d2f 100644 --- a/src/microsvc/service/tests.rs +++ b/src/microsvc/service/tests.rs @@ -1008,6 +1008,16 @@ impl CommandLedgerStore for AmbiguousCommitRepository { } } +#[cfg(feature = "graphql")] +impl crate::repository::TransactionalCommit for AmbiguousCommitRepository { + fn commit_batch<'a>( + &'a self, + batch: crate::repository::CommitBatch<'a>, + ) -> impl Future> + Send + 'a { + crate::repository::TransactionalCommit::commit_batch(&self.inner, batch) + } +} + #[cfg(feature = "graphql")] impl CausalTransactionalCommit for AmbiguousCommitRepository { async fn commit_causal_batch<'a>( diff --git a/tests/e2e-ui/crates/todo-domain/Cargo.toml b/tests/e2e-ui/crates/todo-domain/Cargo.toml index fb7c5ce61..b4c393895 100644 --- a/tests/e2e-ui/crates/todo-domain/Cargo.toml +++ b/tests/e2e-ui/crates/todo-domain/Cargo.toml @@ -12,3 +12,4 @@ thiserror = { workspace = true } [dev-dependencies] serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/tests/e2e-ui/crates/todo-domain/src/commands.rs b/tests/e2e-ui/crates/todo-domain/src/commands.rs index 923b89e6f..3480ee3ae 100644 --- a/tests/e2e-ui/crates/todo-domain/src/commands.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands.rs @@ -599,4 +599,47 @@ mod tests { .expect("todo.complete"); assert_eq!(complete_spec.field_name, "todos_complete"); } + + #[tokio::test] + async fn cell_host_dispatches_complete_with_the_same_handle_as_soa() { + use distributed::cell_host::AggregateCell; + use distributed::microsvc::{Session, USER_ID_KEY}; + + let cell = AggregateCell::::new("todo-1") + .expect("cell identity") + .mount(create()) + .mount(complete()); + assert_eq!(cell.instance_name(), "todo:todo-1"); + assert!(cell.is_command_only()); + assert!(cell + .command_names() + .iter() + .any(|name| name == "todo.complete")); + + let mut session = Session::new(); + session.set(USER_ID_KEY, "owner-1"); + session.set("x-roles", "user"); + + cell.dispatch( + "todo.create", + serde_json::json!({ + "todo_id": "todo-1", + "title": "cell complete", + }), + session.clone(), + ) + .await + .expect("todo.create on cell"); + + let completed = cell + .dispatch( + "todo.complete", + serde_json::json!({ "todo_id": "todo-1" }), + session, + ) + .await + .expect("todo.complete on cell"); + assert_eq!(completed["todo_id"], "todo-1"); + assert_eq!(completed["status"], "completed"); + } } From 7a993398125491388cf9d5ce94b0306d0df8ab3e Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 04:08:26 -0500 Subject: [PATCH 02/37] feat: parent-shard game cells for Blob and bomberman tick CellStreamStore::for_parent_shard holds sibling streams (map, player, bomb) in one cell SQLite and one CommitBatch. Bomberman tick shards by game id (`game:{game_id}`), not player/bomb. Blob cells stay `blob:{game_id}`. There is no two-cell transaction API. Implements [[tasks/portable-command-hosts-5]] --- src/microsvc/cell_host/cell.rs | 8 ++ src/microsvc/cell_host/mod.rs | 2 +- src/microsvc/cell_host/store.rs | 86 +++++++++++++++---- src/microsvc/cell_host/tests.rs | 72 +++++++++++++++- tests/bomberman/handlers/mod.rs | 2 +- tests/bomberman/handlers/tick.rs | 16 ++++ tests/bomberman/main.rs | 10 +++ .../e2e-ui/crates/blob-domain/src/commands.rs | 21 ++++- 8 files changed, 197 insertions(+), 20 deletions(-) diff --git a/src/microsvc/cell_host/cell.rs b/src/microsvc/cell_host/cell.rs index 4f61002ab..346bb79fa 100644 --- a/src/microsvc/cell_host/cell.rs +++ b/src/microsvc/cell_host/cell.rs @@ -166,3 +166,11 @@ where pub fn instance_name(shard_id: &str) -> String { format!("{}:{shard_id}", A::aggregate_type()) } + +/// Parent-shard cell name (`game:{game_id}` for bomberman tick). +/// +/// Child streams (player, bomb, explosion, map, saga) live inside this cell. +/// There is no two-cell transaction API (`PCH-REQ-006`). +pub fn parent_cell_name(parent_type: &str, parent_id: &str) -> String { + format!("{parent_type}:{parent_id}") +} diff --git a/src/microsvc/cell_host/mod.rs b/src/microsvc/cell_host/mod.rs index 01232e198..19ce81bb3 100644 --- a/src/microsvc/cell_host/mod.rs +++ b/src/microsvc/cell_host/mod.rs @@ -12,7 +12,7 @@ mod cell; mod store; -pub use cell::{instance_name, AggregateCell, CellNamespace}; +pub use cell::{instance_name, parent_cell_name, AggregateCell, CellNamespace}; pub use store::CellStreamStore; #[cfg(test)] diff --git a/src/microsvc/cell_host/store.rs b/src/microsvc/cell_host/store.rs index b4c6c8ed5..875d63af9 100644 --- a/src/microsvc/cell_host/store.rs +++ b/src/microsvc/cell_host/store.rs @@ -26,16 +26,37 @@ use crate::projection_protocol::{ ProjectorTopologyId, TrustedProjectionInput, }; use crate::repository::{ - CommitBatch, RepositoryError, SnapshotWrite, StreamIdentity, TransactionalCommit, + CommitBatch, GetStream, RepositoryError, SnapshotWrite, StreamIdentity, TransactionalCommit, }; use crate::{InMemoryOutboxStore, InMemoryRepository}; +#[derive(Clone)] +enum CellOwnership { + /// One stream identity (Todo, BlobGame). Foreign streams are rejected. + Exclusive(StreamIdentity), + /// Parent game cell: map/player/bomb/explosion/saga streams share this + /// cell's private SQLite. There is no API to commit across two cells. + Parent { name: StreamIdentity }, +} + /// Private SQLite stand-in for one cell instance (`{aggregate_type}:{shard}`). /// -/// Loads and commits are rejected for any stream that is not this cell's shard. +/// Exclusive cells reject any stream that is not this cell's shard. Parent +/// cells (`for_parent_shard`) hold sibling streams of one game and commit +/// them in one [`CommitBatch`]. +/// +/// ```compile_fail +/// fn two_cell_transaction_does_not_exist( +/// left: &distributed::cell_host::CellStreamStore, +/// right: &distributed::cell_host::CellStreamStore, +/// batch: distributed::CommitBatch<'_>, +/// ) { +/// let _ = left.commit_across(right, batch); +/// } +/// ``` #[derive(Clone)] pub struct CellStreamStore { - identity: StreamIdentity, + ownership: CellOwnership, inner: InMemoryRepository, } @@ -43,12 +64,28 @@ impl CellStreamStore { /// Bind a store to one exact stream identity. pub fn for_identity(identity: StreamIdentity) -> Self { Self { - identity, + ownership: CellOwnership::Exclusive(identity), inner: InMemoryRepository::new(), } } - /// Named cell constructor used by [`super::AggregateCell`]. + /// Parent-shard cell: `"{parent_type}:{parent_id}"` (bomberman `game:{id}`). + /// + /// Child streams of any aggregate type live in this cell's SQLite. A + /// transaction across two parent cells does not exist. + pub fn for_parent_shard( + parent_type: impl Into, + parent_id: impl Into, + ) -> Result { + Ok(Self { + ownership: CellOwnership::Parent { + name: StreamIdentity::new(parent_type, parent_id)?, + }, + inner: InMemoryRepository::new(), + }) + } + + /// Named exclusive-cell constructor used by [`super::AggregateCell`]. pub fn new( aggregate_type: impl Into, shard_id: impl Into, @@ -61,22 +98,29 @@ impl CellStreamStore { /// Cell instance name (`type:id`). pub fn instance_name(&self) -> String { - self.identity.to_string() + match &self.ownership { + CellOwnership::Exclusive(identity) | CellOwnership::Parent { name: identity } => { + identity.to_string() + } + } } - /// Stream this cell owns. - pub fn identity(&self) -> &StreamIdentity { - &self.identity + /// Stream this exclusive cell owns. Parent cells have no single stream. + pub fn identity(&self) -> Option<&StreamIdentity> { + match &self.ownership { + CellOwnership::Exclusive(identity) => Some(identity), + CellOwnership::Parent { .. } => None, + } } fn ensure_identity(&self, identity: &StreamIdentity) -> Result<(), RepositoryError> { - if identity != &self.identity { - return Err(RepositoryError::Model(format!( - "cell `{}` cannot access stream `{identity}`", - self.identity - ))); + match &self.ownership { + CellOwnership::Parent { .. } => Ok(()), + CellOwnership::Exclusive(owned) if identity == owned => Ok(()), + CellOwnership::Exclusive(owned) => Err(RepositoryError::Model(format!( + "cell `{owned}` cannot access stream `{identity}`" + ))), } - Ok(()) } fn ensure_batch(&self, batch: &CommitBatch<'_>) -> Result<(), RepositoryError> { @@ -104,6 +148,18 @@ impl CausalGetStream for CellStreamStore { } } +impl GetStream for CellStreamStore { + fn get_stream<'a>( + &'a self, + identity: &'a StreamIdentity, + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + self.ensure_identity(identity)?; + GetStream::get_stream(&self.inner, identity).await + } + } +} + impl CausalRepositoryIdentity for CellStreamStore { fn causal_storage_identity(&self) -> CausalStorageIdentity { CausalRepositoryIdentity::causal_storage_identity(&self.inner) diff --git a/src/microsvc/cell_host/tests.rs b/src/microsvc/cell_host/tests.rs index 3aed5b4ee..380a98790 100644 --- a/src/microsvc/cell_host/tests.rs +++ b/src/microsvc/cell_host/tests.rs @@ -1,11 +1,13 @@ -use super::{instance_name, AggregateCell, CellNamespace, CellStreamStore}; +use super::{instance_name, parent_cell_name, AggregateCell, CellNamespace, CellStreamStore}; use crate::aggregate::{Aggregate, AggregateRepository}; use crate::entity::Entity; use crate::graphql::{typed_command, PreparedCommand, Succeeded}; use crate::microsvc::service::{CausalCommandContext, PortableCommand, Routes}; use crate::microsvc::session::{Session, USER_ID_KEY}; use crate::microsvc::HandlerError; -use crate::repository::{RepositoryError, TransactionalCommit}; +use crate::repository::{ + CommitBatch, GetStream, RepositoryError, StreamIdentity, StreamWrite, TransactionalCommit, +}; use crate::sourced; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -268,6 +270,72 @@ async fn namespace_get_by_name_addresses_type_and_shard() { assert!(namespace.get_by_name("CellItem:missing").is_none()); } +#[tokio::test] +async fn parent_cell_commits_sibling_streams_in_one_batch() { + let store = CellStreamStore::for_parent_shard("game", "game-1").expect("parent shard"); + assert_eq!(store.instance_name(), "game:game-1"); + assert_eq!(parent_cell_name("game", "game-1"), "game:game-1"); + assert_ne!(parent_cell_name("game", "game-1"), "player:player-1"); + + let mut map = Entity::with_id("game-1"); + map.digest_empty("initialized").unwrap(); + let mut player = Entity::with_id("player:1"); + player.digest_empty("joined").unwrap(); + let mut bomb = Entity::with_id("bomb:1"); + bomb.digest_empty("placed").unwrap(); + + let map_id = StreamIdentity::new("GameMap", "game-1").unwrap(); + let player_id = StreamIdentity::new("Player", "player:1").unwrap(); + let bomb_id = StreamIdentity::new("Bomb", "bomb:1").unwrap(); + let batch = CommitBatch::new(vec![ + StreamWrite::new(map_id.clone(), &mut map), + StreamWrite::new(player_id.clone(), &mut player), + StreamWrite::new(bomb_id.clone(), &mut bomb), + ]); + TransactionalCommit::commit_batch(&store, batch) + .await + .expect("sibling streams commit on one parent cell"); + + assert!(GetStream::get_stream(&store, &map_id) + .await + .unwrap() + .is_some()); + assert!(GetStream::get_stream(&store, &player_id) + .await + .unwrap() + .is_some()); + assert!(GetStream::get_stream(&store, &bomb_id) + .await + .unwrap() + .is_some()); +} + +#[tokio::test] +async fn parent_cells_are_isolated_and_have_no_cross_cell_commit() { + let game_1 = CellStreamStore::for_parent_shard("game", "g1").unwrap(); + let game_2 = CellStreamStore::for_parent_shard("game", "g2").unwrap(); + + let mut player = Entity::with_id("player:1"); + player.digest_empty("joined").unwrap(); + let player_id = StreamIdentity::new("Player", "player:1").unwrap(); + let batch = CommitBatch::new(vec![StreamWrite::new(player_id.clone(), &mut player)]); + TransactionalCommit::commit_batch(&game_1, batch) + .await + .unwrap(); + + assert!(GetStream::get_stream(&game_1, &player_id) + .await + .unwrap() + .is_some()); + assert!( + GetStream::get_stream(&game_2, &player_id) + .await + .unwrap() + .is_none(), + "a second game cell cannot see sibling streams of the first" + ); +} + #[test] fn cargo_features_keep_sqlite_and_do_not_add_celld() { let manifest = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")); diff --git a/tests/bomberman/handlers/mod.rs b/tests/bomberman/handlers/mod.rs index dfd1c0f02..f58d17c93 100644 --- a/tests/bomberman/handlers/mod.rs +++ b/tests/bomberman/handlers/mod.rs @@ -12,4 +12,4 @@ pub use join_game::join_game; pub use move_player::move_player; pub use place_bomb::place_bomb; pub(crate) use shared::get_aggregate; -pub use tick::tick; +pub use tick::{tick, tick_cell_name, tick_shard}; diff --git a/tests/bomberman/handlers/tick.rs b/tests/bomberman/handlers/tick.rs index 332f28c9d..bdbacea76 100644 --- a/tests/bomberman/handlers/tick.rs +++ b/tests/bomberman/handlers/tick.rs @@ -16,6 +16,20 @@ use crate::domain::tick_saga::{Detonation, TickSaga}; use crate::domain::types::{Direction, Tile}; use crate::error::GameError; +/// Parent shard for a cell host (`PCH-REQ-006` / `PCH-AC-005.1`). +/// +/// Tick (and every other game command) addresses **one game cell**, not a +/// player or bomb cell. Child streams — map, players, bombs, explosions, +/// saga — live inside that cell's SQLite and commit in one [`CommitBatch`]. +pub fn tick_shard(game_id: &str) -> String { + game_id.to_string() +} + +/// Cell instance name: `game:{game_id}`. +pub fn tick_cell_name(game_id: &str) -> String { + format!("game:{game_id}") +} + #[derive(Default)] struct DamageReport { blocks_destroyed: Vec<(i32, i32)>, @@ -212,6 +226,8 @@ where } // Stage every touched aggregate stream under its own type's stream identity. + // These siblings belong to one parent shard (`tick_shard` / `tick_cell_name`); + // a cell host writes them to one store. They are not per-player/bomb cells. let mut streams: Vec> = Vec::new(); let map_identity = StreamIdentity::new(GameMap::aggregate_type(), map.entity.id()) .map_err(GameError::Repository)?; diff --git a/tests/bomberman/main.rs b/tests/bomberman/main.rs index 326ce6d75..f5fd00a49 100644 --- a/tests/bomberman/main.rs +++ b/tests/bomberman/main.rs @@ -19,6 +19,7 @@ use domain::types::Direction; use sim::Game; use distributed::InMemoryRepository; +use handlers::{tick_cell_name, tick_shard}; const SMALL_MAP: &str = "\ ####### @@ -34,6 +35,15 @@ const SMALL_MAP: &str = "\ // Pattern: Single aggregate + read model commit, terrain validation // ============================================================================ +#[test] +fn tick_shards_by_game_id_not_player_or_bomb() { + assert_eq!(tick_shard("game-1"), "game-1"); + assert_eq!(tick_cell_name("game-1"), "game:game-1"); + assert_ne!(tick_shard("game-1"), "player:1"); + assert_ne!(tick_cell_name("game-1"), "player:player-1"); + assert_ne!(tick_cell_name("game-1"), "bomb:bomb-1"); +} + #[tokio::test] async fn game_setup_and_movement() { let repo = InMemoryRepository::new(); diff --git a/tests/e2e-ui/crates/blob-domain/src/commands.rs b/tests/e2e-ui/crates/blob-domain/src/commands.rs index 621844ce7..8be1a19cd 100644 --- a/tests/e2e-ui/crates/blob-domain/src/commands.rs +++ b/tests/e2e-ui/crates/blob-domain/src/commands.rs @@ -245,7 +245,7 @@ where #[cfg(test)] mod tests { use super::*; - use distributed::{AggregateBuilder, InMemoryRepository}; + use distributed::{Aggregate, AggregateBuilder, InMemoryRepository}; use std::path::Path; #[test] @@ -265,6 +265,25 @@ mod tests { assert_eq!(StartLevel::shard(&level), "g1"); } + #[test] + fn blob_cell_is_parent_game_shard() { + use distributed::cell_host::instance_name; + let mv = BlobMoveInput { + game_id: "g1".into(), + direction: "up".into(), + }; + let shard = Move::shard(&mv); + assert_eq!( + instance_name::(&shard), + format!("{}:{}", BlobGame::aggregate_type(), shard) + ); + assert_eq!( + instance_name::(&shard), + "blob:g1", + "cell host addresses BlobGame as (aggregate_type, game_id)" + ); + } + #[test] fn atomic_blob_games_commands_mount_without_sqlx_or_celld() { let repository = InMemoryRepository::new(); From f8c4ed4b513f758c9069bdfd839874f28d4007b3 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 04:29:19 -0500 Subject: [PATCH 03/37] test: add celld compose host and TodoCell worker One SQLite Durable Object class per todo id, official celld image via Docker Compose. Fixture tests always run; live HTTP create/complete is gated on CELLD_URL. No MinIO, no celld Cargo feature, no secrets. Implements [[tasks/portable-command-hosts-6]] --- tests/celld/README.md | 43 ++++++++++ tests/celld/docker-compose.yml | 46 ++++++++++ tests/celld/entrypoint.sh | 16 ++++ tests/celld/main.rs | 136 ++++++++++++++++++++++++++++++ tests/celld/worker/index.js | 102 ++++++++++++++++++++++ tests/celld/worker/wrangler.jsonc | 9 ++ 6 files changed, 352 insertions(+) create mode 100644 tests/celld/README.md create mode 100644 tests/celld/docker-compose.yml create mode 100644 tests/celld/entrypoint.sh create mode 100644 tests/celld/main.rs create mode 100644 tests/celld/worker/index.js create mode 100644 tests/celld/worker/wrangler.jsonc diff --git a/tests/celld/README.md b/tests/celld/README.md new file mode 100644 index 000000000..bf32fa9fc --- /dev/null +++ b/tests/celld/README.md @@ -0,0 +1,43 @@ +# celld live Todo cell + +First live celld host for portable command hosts: one `TodoCell` Durable +Object per todo id, SQLite private to the cell, Docker Compose for the +daemon. + +This is **not** workers-rs packaging of `distributed::cell_host::AggregateCell`. +The Worker is a thin JS class with the same shard rule (`idFromName(todo_id)`). +The Rust library host stays the unit-tested adapter; this directory proves +the celld process. + +## Prerequisites + +- Docker +- `celld` CLI + `esbuild` on `PATH` (`curl -fsSL https://celld.dev/install.sh | sh`) +- A **qualified** bucket: S3, R2, Tigris, GCS, or Azure. Not MinIO community. + +```sh +export CELLD_BUCKET=s3://your-bucket +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +# R2: +export CELLD_ENDPOINT=https://ACCOUNT.r2.cloudflarestorage.com +export CELLD_REGION=auto + +celld diagnose --bucket "$CELLD_BUCKET" --endpoint "$CELLD_ENDPOINT" --region "$CELLD_REGION" +``` + +`celld diagnose` must report `ok bucket conditional write`. + +## Run + +```sh +docker compose -f tests/celld/docker-compose.yml up -d --wait +celld deploy tests/celld/worker --bucket "$CELLD_BUCKET" \ + --endpoint "$CELLD_ENDPOINT" --region "$CELLD_REGION" +CELLD_URL=http://127.0.0.1:18080 cargo test --test celld +``` + +Without `CELLD_URL`, `cargo test --test celld` only checks the worker +fixture and skips the live HTTP round-trip. + +Tear down: `docker compose -f tests/celld/docker-compose.yml down`. diff --git a/tests/celld/docker-compose.yml b/tests/celld/docker-compose.yml new file mode 100644 index 000000000..591b774ba --- /dev/null +++ b/tests/celld/docker-compose.yml @@ -0,0 +1,46 @@ +# One celld node for tests/celld. +# +# Requires a *qualified* object store (S3, R2, Tigris, GCS, Azure). MinIO +# community, DO Spaces, B2, and Hetzner do not implement the conditional +# writes celld uses for fencing — do not point CELLD_BUCKET at them. +# +# export CELLD_BUCKET=s3://your-bucket +# export AWS_ACCESS_KEY_ID=... +# export AWS_SECRET_ACCESS_KEY=... +# # R2 / Tigris: +# export CELLD_ENDPOINT=https://ACCOUNT.r2.cloudflarestorage.com +# export CELLD_REGION=auto +# +# docker compose -f tests/celld/docker-compose.yml up -d --wait +# celld deploy tests/celld/worker --bucket "$CELLD_BUCKET" \ +# ${CELLD_ENDPOINT:+--endpoint "$CELLD_ENDPOINT"} \ +# ${CELLD_REGION:+--region "$CELLD_REGION"} +# CELLD_URL=http://127.0.0.1:18080 cargo test --test celld +# +# Port 8081 is peer/internal. Do not publish it. + +services: + celld: + image: ghcr.io/denoland/celld + restart: always + hostname: celld + ports: + - "18080:8080" + expose: + - "8081" + environment: + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-} + CELLD_WATCH: /var/lib/celld/state + CELLD_BUCKET: ${CELLD_BUCKET:?set CELLD_BUCKET to a qualified s3:// gs:// or az:// URL} + CELLD_ENDPOINT: ${CELLD_ENDPOINT:-} + CELLD_REGION: ${CELLD_REGION:-} + CELLD_ADVERTISE: celld:8081 + volumes: + - celld-state:/var/lib/celld + - ./entrypoint.sh:/entrypoint.sh:ro + entrypoint: ["/bin/sh", "/entrypoint.sh"] + +volumes: + celld-state: diff --git a/tests/celld/entrypoint.sh b/tests/celld/entrypoint.sh new file mode 100644 index 000000000..329a4bfc9 --- /dev/null +++ b/tests/celld/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Assemble celld flags from env. Optional endpoint/region for R2/Tigris. +set -eu +bucket="${CELLD_BUCKET:?CELLD_BUCKET is required}" +advertise="${CELLD_ADVERTISE:-celld:8081}" +set -- celld --bucket "$bucket" \ + --listen 0.0.0.0:8080 \ + --internal-listen 0.0.0.0:8081 \ + --advertise "$advertise" +if [ -n "${CELLD_ENDPOINT:-}" ]; then + set -- "$@" --endpoint "$CELLD_ENDPOINT" +fi +if [ -n "${CELLD_REGION:-}" ]; then + set -- "$@" --region "$CELLD_REGION" +fi +exec "$@" diff --git a/tests/celld/main.rs b/tests/celld/main.rs new file mode 100644 index 000000000..742ccc6de --- /dev/null +++ b/tests/celld/main.rs @@ -0,0 +1,136 @@ +//! Live celld host: one Todo Durable Object per id. +//! +//! Fixture checks always run. The HTTP round-trip runs only when `CELLD_URL` +//! is set (operator started compose + `celld deploy`). See `tests/celld/README.md`. + +use std::path::Path; +use std::time::Duration; + +use serde_json::Value; + +#[path = "../support/env.rs"] +mod env_support; + +fn worker_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/celld/worker")) +} + +#[test] +fn worker_declares_sqlite_todo_cell() { + let wrangler = std::fs::read_to_string(worker_dir().join("wrangler.jsonc")) + .expect("wrangler.jsonc"); + let spec: Value = serde_json::from_str(&wrangler).expect("wrangler json"); + assert_eq!(spec["main"], "index.js"); + let bindings = spec["durable_objects"]["bindings"].as_array().unwrap(); + assert_eq!(bindings[0]["name"], "TODO"); + assert_eq!(bindings[0]["class_name"], "TodoCell"); + let classes = spec["migrations"][0]["new_sqlite_classes"] + .as_array() + .unwrap(); + assert_eq!(classes[0], "TodoCell"); + + let source = std::fs::read_to_string(worker_dir().join("index.js")).expect("index.js"); + assert!(source.contains("export class TodoCell")); + assert!(source.contains("idFromName")); + assert!(source.contains("CREATE TABLE IF NOT EXISTS todo")); +} + +#[test] +fn compose_file_does_not_use_minio() { + let compose = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/celld/docker-compose.yml" + )) + .expect("compose"); + assert!(compose.contains("ghcr.io/denoland/celld")); + assert!( + !compose + .lines() + .any(|line| line.trim_start().starts_with("image:") && line.contains("minio")), + "do not run MinIO as the celld bucket" + ); + assert!(compose.contains("18080:8080")); +} + +#[tokio::test] +async fn live_todo_cell_create_complete_and_isolate() { + let Some(base) = env_support::broker_env("CELLD_URL", "celld live Todo cell") else { + return; + }; + let base = base.trim_end_matches('/').to_string(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("client"); + + wait_healthy(&client, &base).await; + + let a = unique_todo(); + let b = unique_todo(); + + let created = client + .put(format!("{base}/todo/{a}")) + .json(&serde_json::json!({ "title": "ship celld" })) + .send() + .await + .expect("create"); + assert_eq!(created.status(), 201, "{}", created.text().await.unwrap()); + let created: Value = created.json().await.unwrap(); + assert_eq!(created["id"], a); + assert_eq!(created["status"], "open"); + + let completed = client + .post(format!("{base}/todo/{a}/complete")) + .send() + .await + .expect("complete"); + assert_eq!( + completed.status(), + 200, + "{}", + completed.text().await.unwrap() + ); + let completed: Value = completed.json().await.unwrap(); + assert_eq!(completed["status"], "completed"); + + let got: Value = client + .get(format!("{base}/todo/{a}")) + .send() + .await + .expect("get") + .json() + .await + .unwrap(); + assert_eq!(got["title"], "ship celld"); + assert_eq!(got["status"], "completed"); + + let other = client + .get(format!("{base}/todo/{b}")) + .send() + .await + .expect("missing cell"); + assert_eq!(other.status(), 404, "second name must be a different cell"); +} + +fn unique_todo() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + format!("todo-{nanos}") +} + +async fn wait_healthy(client: &reqwest::Client, base: &str) { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + loop { + if let Ok(response) = client.get(format!("{base}/health")).send().await { + if response.status().is_success() { + return; + } + } + if std::time::Instant::now() > deadline { + panic!("celld at {base} did not become healthy in 30s"); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } +} diff --git a/tests/celld/worker/index.js b/tests/celld/worker/index.js new file mode 100644 index 000000000..92df5b4a5 --- /dev/null +++ b/tests/celld/worker/index.js @@ -0,0 +1,102 @@ +// One Todo aggregate per Durable Object instance. +// Worker: GET/PUT /todo/:id, POST /todo/:id/complete +// Cell address: env.TODO.idFromName(id) → shard = todo id (PCH-REQ-003) + +export class TodoCell { + constructor(state, _env) { + this.state = state; + this.sql = state.storage.sql; + this.sql.exec(` + CREATE TABLE IF NOT EXISTS todo ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL + ) + `); + } + + async fetch(request) { + const url = new URL(request.url); + const parts = url.pathname.split("/").filter(Boolean); + // ["todo", id] or ["todo", id, "complete"] + const id = parts[1]; + if (!id) { + return json({ error: "missing todo id" }, 400); + } + + if (request.method === "GET" && parts.length === 2) { + const row = firstRow(this.sql.exec("SELECT id, title, status FROM todo WHERE id = ?", id)); + if (!row) { + return json({ error: "not found", id }, 404); + } + return json(row, 200); + } + + if (request.method === "PUT" && parts.length === 2) { + const body = await request.json().catch(() => ({})); + const title = typeof body.title === "string" ? body.title.trim() : ""; + if (!title) { + return json({ error: "title required" }, 400); + } + const existing = firstRow(this.sql.exec("SELECT id FROM todo WHERE id = ?", id)); + if (existing) { + return json({ error: "already exists", id }, 409); + } + this.sql.exec( + "INSERT INTO todo (id, title, status) VALUES (?, ?, ?)", + id, + title, + "open", + ); + return json({ id, title, status: "open" }, 201); + } + + if (request.method === "POST" && parts[2] === "complete") { + const row = firstRow( + this.sql.exec("SELECT id, title, status FROM todo WHERE id = ?", id), + ); + if (!row) { + return json({ error: "not found", id }, 404); + } + if (row.status !== "open") { + return json({ error: "not open", id, status: row.status }, 422); + } + this.sql.exec("UPDATE todo SET status = ? WHERE id = ?", "completed", id); + return json({ id, title: row.title, status: "completed" }, 200); + } + + return json({ error: "not found" }, 404); + } +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/" || url.pathname === "/health") { + return new Response("distributed todo cell\n", { status: 200 }); + } + const parts = url.pathname.split("/").filter(Boolean); + if (parts[0] !== "todo" || !parts[1]) { + return new Response("todo cell. PUT/GET /todo/:id POST /todo/:id/complete\n", { + status: 404, + }); + } + const id = parts[1]; + const stub = env.TODO.get(env.TODO.idFromName(id)); + return stub.fetch(request); + }, +}; + +function firstRow(cursor) { + for (const row of cursor) { + return row; + } + return null; +} + +function json(body, status) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/tests/celld/worker/wrangler.jsonc b/tests/celld/worker/wrangler.jsonc new file mode 100644 index 000000000..da99c89dd --- /dev/null +++ b/tests/celld/worker/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "distributed-todo-cell", + "main": "index.js", + "compatibility_date": "2026-01-01", + "durable_objects": { + "bindings": [{ "name": "TODO", "class_name": "TodoCell" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TodoCell"] }] +} From cb940353eee5e12ed417a56db6f1a4e7d2515327 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 04:58:08 -0500 Subject: [PATCH 04/37] test: run local celld compose against Azurite Azurite is the documented local bucket (az://celld). Docker Desktop injects extra_hosts, so celld cannot share Azurite's network namespace; socat forwards 127.0.0.1:10000 to the azurite service. Implements [[tasks/portable-command-hosts-6]] --- tests/celld/Dockerfile | 7 +++ tests/celld/README.md | 56 +++++++++++++-------- tests/celld/docker-compose.yml | 92 +++++++++++++++++++++++----------- tests/celld/entrypoint.sh | 48 +++++++++++++----- tests/celld/init-container.sh | 12 +++++ tests/celld/main.rs | 28 +++++++++-- 6 files changed, 175 insertions(+), 68 deletions(-) create mode 100644 tests/celld/Dockerfile create mode 100644 tests/celld/init-container.sh diff --git a/tests/celld/Dockerfile b/tests/celld/Dockerfile new file mode 100644 index 000000000..9e6762d30 --- /dev/null +++ b/tests/celld/Dockerfile @@ -0,0 +1,7 @@ +# Local-only: official celld plus socat so Azurite is reachable at +# 127.0.0.1:10000 (object_store emulator client). Not a production image. +FROM ghcr.io/denoland/celld:latest + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends socat \ + && rm -rf /var/lib/apt/lists/* diff --git a/tests/celld/README.md b/tests/celld/README.md index bf32fa9fc..21ab594c2 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -1,43 +1,55 @@ # celld live Todo cell First live celld host for portable command hosts: one `TodoCell` Durable -Object per todo id, SQLite private to the cell, Docker Compose for the -daemon. +Object per todo id, private SQLite, Docker Compose for the daemon **and** +Azurite (no AWS or Cloudflare account). This is **not** workers-rs packaging of `distributed::cell_host::AggregateCell`. The Worker is a thin JS class with the same shard rule (`idFromName(todo_id)`). -The Rust library host stays the unit-tested adapter; this directory proves -the celld process. + +Azurite is celld's documented local development store. It is **not** a +production fleet bucket. ## Prerequisites - Docker - `celld` CLI + `esbuild` on `PATH` (`curl -fsSL https://celld.dev/install.sh | sh`) -- A **qualified** bucket: S3, R2, Tigris, GCS, or Azure. Not MinIO community. - -```sh -export CELLD_BUCKET=s3://your-bucket -export AWS_ACCESS_KEY_ID=... -export AWS_SECRET_ACCESS_KEY=... -# R2: -export CELLD_ENDPOINT=https://ACCOUNT.r2.cloudflarestorage.com -export CELLD_REGION=auto - -celld diagnose --bucket "$CELLD_BUCKET" --endpoint "$CELLD_ENDPOINT" --region "$CELLD_REGION" -``` - -`celld diagnose` must report `ok bucket conditional write`. ## Run ```sh -docker compose -f tests/celld/docker-compose.yml up -d --wait -celld deploy tests/celld/worker --bucket "$CELLD_BUCKET" \ - --endpoint "$CELLD_ENDPOINT" --region "$CELLD_REGION" +docker compose -f tests/celld/docker-compose.yml up -d --build azurite +# wait until azurite-init exits 0 + +export AZURE_STORAGE_USE_EMULATOR=true +export AZURE_STORAGE_ACCOUNT_NAME=devstoreaccount1 +export AZURE_STORAGE_ACCOUNT_KEY='Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==' + +celld diagnose --bucket az://celld --listen 127.0.0.1:18090 --internal-listen 127.0.0.1:18091 +celld deploy tests/celld/worker --bucket az://celld +docker compose -f tests/celld/docker-compose.yml up -d celld CELLD_URL=http://127.0.0.1:18080 cargo test --test celld ``` +Nodes load a deployment at startup, so deploy before the celld container starts (or restart it after deploy). `celld diagnose` should report `ok bucket conditional write`. A host-side peer probe to `:8081` is expected to fail: that listener is not published. + +If host port 18080 is already taken, set `CELLD_HTTP_PORT` (for example `18880`) +before `docker compose up` and use that port in `CELLD_URL`. If host port 8080 is taken, pass `--listen` / `--internal-listen` to `celld diagnose` as above. + Without `CELLD_URL`, `cargo test --test celld` only checks the worker fixture and skips the live HTTP round-trip. -Tear down: `docker compose -f tests/celld/docker-compose.yml down`. +Tear down: `docker compose -f tests/celld/docker-compose.yml down -v`. + +## Ports + +| Host | Inside compose | What | +|---|---|---| +| 18080 (or `CELLD_HTTP_PORT`) | celld `:8080` | Worker HTTP | +| 10000 | Azurite blob | Host `celld deploy` / `diagnose` | +| — | celld `:8081` | Peer/internal — not published | + +celld's Azure emulator client always uses `127.0.0.1:10000`. The celld +container forwards that address to the `azurite` service. Sharing Azurite's +network namespace is not used: Docker Desktop injects `extra_hosts`, which +conflicts with `network_mode: service:…`. diff --git a/tests/celld/docker-compose.yml b/tests/celld/docker-compose.yml index 591b774ba..74f84af2a 100644 --- a/tests/celld/docker-compose.yml +++ b/tests/celld/docker-compose.yml @@ -1,46 +1,82 @@ -# One celld node for tests/celld. +# Local celld + Azurite. No AWS or Cloudflare account. # -# Requires a *qualified* object store (S3, R2, Tigris, GCS, Azure). MinIO -# community, DO Spaces, B2, and Hetzner do not implement the conditional -# writes celld uses for fencing — do not point CELLD_BUCKET at them. +# celld's Azure emulator client always talks to 127.0.0.1:10000. Docker +# Desktop / Dory inject extra_hosts (host.docker.internal), which cannot +# combine with network_mode: service:azurite. The celld image therefore +# forwards 127.0.0.1:10000 -> azurite:10000 via socat (see Dockerfile). # -# export CELLD_BUCKET=s3://your-bucket -# export AWS_ACCESS_KEY_ID=... -# export AWS_SECRET_ACCESS_KEY=... -# # R2 / Tigris: -# export CELLD_ENDPOINT=https://ACCOUNT.r2.cloudflarestorage.com -# export CELLD_REGION=auto +# Host ports: +# 18080 → Worker HTTP (override with CELLD_HTTP_PORT if busy) +# 10000 → Azurite (for `celld deploy` / `celld diagnose` on the host) +# Do not publish 8081 (peer/internal). # -# docker compose -f tests/celld/docker-compose.yml up -d --wait -# celld deploy tests/celld/worker --bucket "$CELLD_BUCKET" \ -# ${CELLD_ENDPOINT:+--endpoint "$CELLD_ENDPOINT"} \ -# ${CELLD_REGION:+--region "$CELLD_REGION"} -# CELLD_URL=http://127.0.0.1:18080 cargo test --test celld +# Azurite is a development store — not a production celld fleet. # -# Port 8081 is peer/internal. Do not publish it. +# docker compose -f tests/celld/docker-compose.yml up -d --build azurite +# export AZURE_STORAGE_USE_EMULATOR=true +# export AZURE_STORAGE_ACCOUNT_NAME=devstoreaccount1 +# export AZURE_STORAGE_ACCOUNT_KEY='Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==' +# celld deploy tests/celld/worker --bucket az://celld +# docker compose -f tests/celld/docker-compose.yml up -d celld +# CELLD_URL=http://127.0.0.1:${CELLD_HTTP_PORT:-18080} cargo test --test celld services: + azurite: + image: mcr.microsoft.com/azure-storage/azurite + command: + - azurite-blob + - --blobHost + - 0.0.0.0 + - --blobPort + - "10000" + - --skipApiVersionCheck + - --loose + ports: + - "10000:10000" + volumes: + - azurite-data:/data + + azurite-init: + image: mcr.microsoft.com/azure-cli + depends_on: + - azurite + environment: + AZURE_STORAGE_CONNECTION_STRING: >- + DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1; + volumes: + - ./init-container.sh:/init-container.sh:ro + entrypoint: ["/bin/sh", "/init-container.sh"] + celld: - image: ghcr.io/denoland/celld + build: + context: . + dockerfile: Dockerfile restart: always - hostname: celld + init: true ports: - - "18080:8080" - expose: - - "8081" + - "${CELLD_HTTP_PORT:-18080}:8080" + depends_on: + azurite-init: + condition: service_completed_successfully environment: - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} - AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-} + AZURE_STORAGE_USE_EMULATOR: "true" + AZURE_STORAGE_ACCOUNT_NAME: devstoreaccount1 + AZURE_STORAGE_ACCOUNT_KEY: Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + CELLD_BUCKET: az://celld CELLD_WATCH: /var/lib/celld/state - CELLD_BUCKET: ${CELLD_BUCKET:?set CELLD_BUCKET to a qualified s3:// gs:// or az:// URL} - CELLD_ENDPOINT: ${CELLD_ENDPOINT:-} - CELLD_REGION: ${CELLD_REGION:-} - CELLD_ADVERTISE: celld:8081 + CELLD_ADVERTISE: 127.0.0.1:8081 volumes: - celld-state:/var/lib/celld - ./entrypoint.sh:/entrypoint.sh:ro + - ./worker:/worker:ro entrypoint: ["/bin/sh", "/entrypoint.sh"] + healthcheck: + test: ["CMD-SHELL", "bash -c 'echo >/dev/tcp/127.0.0.1/8080'"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 5s volumes: + azurite-data: celld-state: diff --git a/tests/celld/entrypoint.sh b/tests/celld/entrypoint.sh index 329a4bfc9..7da7d931d 100644 --- a/tests/celld/entrypoint.sh +++ b/tests/celld/entrypoint.sh @@ -1,16 +1,38 @@ #!/bin/sh -# Assemble celld flags from env. Optional endpoint/region for R2/Tigris. +# Local Azurite path. object_store's emulator client uses 127.0.0.1:10000; +# socat forwards that to the azurite compose service. set -eu -bucket="${CELLD_BUCKET:?CELLD_BUCKET is required}" -advertise="${CELLD_ADVERTISE:-celld:8081}" -set -- celld --bucket "$bucket" \ +bucket="${CELLD_BUCKET:-az://celld}" +advertise="${CELLD_ADVERTISE:-127.0.0.1:8081}" +watch="${CELLD_WATCH:-/var/lib/celld/state}" +mkdir -p "$watch" + +i=0 +while [ "$i" -lt 30 ]; do + if socat /dev/null TCP:azurite:10000,connect-timeout=1 >/dev/null 2>&1; then + break + fi + i=$((i + 1)) + sleep 1 +done + +socat TCP-LISTEN:10000,bind=127.0.0.1,fork,reuseaddr TCP:azurite:10000 & +socat_pid=$! + +celld --bucket "$bucket" \ --listen 0.0.0.0:8080 \ - --internal-listen 0.0.0.0:8081 \ - --advertise "$advertise" -if [ -n "${CELLD_ENDPOINT:-}" ]; then - set -- "$@" --endpoint "$CELLD_ENDPOINT" -fi -if [ -n "${CELLD_REGION:-}" ]; then - set -- "$@" --region "$CELLD_REGION" -fi -exec "$@" + --internal-listen 127.0.0.1:8081 \ + --advertise "$advertise" & +celld_pid=$! + +term() { + kill "$celld_pid" "$socat_pid" 2>/dev/null || true + wait "$celld_pid" 2>/dev/null || true + wait "$socat_pid" 2>/dev/null || true +} +trap term TERM INT + +wait "$celld_pid" +status=$? +kill "$socat_pid" 2>/dev/null || true +exit "$status" diff --git a/tests/celld/init-container.sh b/tests/celld/init-container.sh new file mode 100644 index 000000000..9b419d675 --- /dev/null +++ b/tests/celld/init-container.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +i=0 +while [ "$i" -lt 30 ]; do + if az storage container create -n celld --connection-string "$AZURE_STORAGE_CONNECTION_STRING"; then + exit 0 + fi + i=$((i + 1)) + sleep 2 +done +echo "azurite did not accept container create" >&2 +exit 1 diff --git a/tests/celld/main.rs b/tests/celld/main.rs index 742ccc6de..98ffb5270 100644 --- a/tests/celld/main.rs +++ b/tests/celld/main.rs @@ -42,14 +42,30 @@ fn compose_file_does_not_use_minio() { "/tests/celld/docker-compose.yml" )) .expect("compose"); - assert!(compose.contains("ghcr.io/denoland/celld")); + let dockerfile = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/celld/Dockerfile" + )) + .expect("dockerfile"); + assert!(dockerfile.contains("ghcr.io/denoland/celld")); + assert!(dockerfile.contains("socat")); + assert!(compose.contains("mcr.microsoft.com/azure-storage/azurite")); + assert!(compose.contains("az://celld")); + assert!(compose.contains("AZURE_STORAGE_USE_EMULATOR")); + assert!( + !compose + .lines() + .any(|line| line.trim_start().starts_with("network_mode")), + "Docker Desktop extra_hosts cannot combine with network_mode" + ); assert!( !compose .lines() .any(|line| line.trim_start().starts_with("image:") && line.contains("minio")), "do not run MinIO as the celld bucket" ); - assert!(compose.contains("18080:8080")); + assert!(compose.contains("CELLD_HTTP_PORT:-18080")); + assert!(compose.contains(":8080")); } #[tokio::test] @@ -123,9 +139,11 @@ fn unique_todo() -> String { async fn wait_healthy(client: &reqwest::Client, base: &str) { let deadline = std::time::Instant::now() + Duration::from_secs(30); loop { - if let Ok(response) = client.get(format!("{base}/health")).send().await { - if response.status().is_success() { - return; + for path in ["/health", "/__celld/health", "/"] { + if let Ok(response) = client.get(format!("{base}{path}")).send().await { + if response.status().is_success() { + return; + } } } if std::time::Instant::now() > deadline { From 6cfe9facaed111f0e24446305468252d9a7b000f Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 05:36:19 -0500 Subject: [PATCH 05/37] feat: run Todo AggregateCell as workers-rs wasm on celld Replace the JS TodoCell with a workers-rs Durable Object that mounts todo-domain create/complete through AggregateCell. wasm32 uses a JS Date wall clock because SystemTime::now panics on unknown-unknown. Implements [[tasks/portable-command-hosts-7]] --- .gitignore | 2 + Cargo.toml | 6 +- src/command_dispatch/remote.rs | 20 ++- src/entity/entity.rs | 4 +- src/entity/event_record.rs | 6 +- src/graphql/engine/request.rs | 2 +- src/graphql/identity/oidc.rs | 2 +- src/graphql/projection_delta/runtime.rs | 8 +- src/in_memory_repo/repository.rs | 13 +- src/lib.rs | 2 + src/microsvc/cell_host/cell.rs | 8 + src/microsvc/cell_host/tests.rs | 2 + src/microsvc/service/routes.rs | 2 +- src/outbox/commit.rs | 4 +- src/outbox/message.rs | 10 +- src/outbox/table.rs | 2 +- src/outbox_worker/outbox_dispatch.rs | 2 +- src/outbox_worker/store/in_memory.rs | 11 +- src/repository/inbox.rs | 2 +- src/snapshot/store.rs | 2 +- src/time.rs | 14 ++ tests/celld/README.md | 9 +- tests/celld/main.rs | 16 +- tests/celld/worker/Cargo.toml | 24 +++ tests/celld/worker/index.js | 102 ------------ tests/celld/worker/src/lib.rs | 175 +++++++++++++++++++++ tests/celld/worker/wrangler.jsonc | 2 +- tests/e2e-ui/crates/todo-domain/Cargo.toml | 13 +- 28 files changed, 300 insertions(+), 165 deletions(-) create mode 100644 src/time.rs create mode 100644 tests/celld/worker/Cargo.toml delete mode 100644 tests/celld/worker/index.js create mode 100644 tests/celld/worker/src/lib.rs diff --git a/.gitignore b/.gitignore index 619edf85f..a224987c0 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ tests/workshop-service/*.db # fixture crate build artifacts tests/fixtures/**/target/ +tests/celld/worker/target/ +tests/celld/worker/build/ diff --git a/Cargo.toml b/Cargo.toml index b46a2e6e2..409c02fe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["distributed_macros", "distributed_cli"] -exclude = ["tests/e2e-ui"] +exclude = ["tests/e2e-ui", "tests/celld/worker"] resolver = "2" [workspace.package] @@ -79,6 +79,10 @@ tracing = { version = "0.1", optional = true } tracing-opentelemetry = { version = "0.33", default-features = false, optional = true } uuid = { version = "1", features = ["v7"] } +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +js-sys = "0.3" +uuid = { version = "1", features = ["v7", "js"] } + [build-dependencies] serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" diff --git a/src/command_dispatch/remote.rs b/src/command_dispatch/remote.rs index 89bdb68a6..ef43f5e71 100644 --- a/src/command_dispatch/remote.rs +++ b/src/command_dispatch/remote.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, UNIX_EPOCH}; /// Stable identifier for the single production remote profile approved by /// task 20. Implementation and tests must cite this constant. @@ -131,7 +131,7 @@ impl CommandDispatcher for RemoteCommandDispatcher { } if let Some(deadline) = envelope.deadline_unix_ms { - let now = SystemTime::now() + let now = crate::time::now() .duration_since(UNIX_EPOCH) .unwrap_or(Duration::ZERO) .as_millis() as u64; @@ -141,10 +141,7 @@ impl CommandDispatcher for RemoteCommandDispatcher { } let mut headers = BTreeMap::new(); - headers.insert( - "content-type".into(), - "application/json".into(), - ); + headers.insert("content-type".into(), "application/json".into()); headers.insert( "x-distributed-dispatch-profile".into(), APPROVED_REMOTE_DISPATCH_PROFILE.into(), @@ -163,11 +160,12 @@ impl CommandDispatcher for RemoteCommandDispatcher { "remote writer returned status {status}" ))); } - let response: CommandResponse = serde_json::from_slice(&response_body).map_err(|error| { - CommandDispatchError::Transport(format!( - "remote writer returned invalid response: {error}" - )) - })?; + let response: CommandResponse = + serde_json::from_slice(&response_body).map_err(|error| { + CommandDispatchError::Transport(format!( + "remote writer returned invalid response: {error}" + )) + })?; Ok(response) } diff --git a/src/entity/entity.rs b/src/entity/entity.rs index 1c0fdff35..c435ef881 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -54,7 +54,7 @@ impl Default for Entity { replaying: false, snapshot_version: 0, committed_version: 0, - timestamp: SystemTime::now(), + timestamp: crate::time::now(), metadata: HashMap::new(), pending_domain_events: Vec::new(), domain_event_poison: None, @@ -462,7 +462,7 @@ impl Entity { fn push_new_event(&mut self, record: EventRecord) { self.events.push(record); self.version = self.prefix_version + self.events.len() as u64; - self.timestamp = SystemTime::now(); + self.timestamp = crate::time::now(); } pub fn load_from_history(&mut self, history: Vec) { diff --git a/src/entity/event_record.rs b/src/entity/event_record.rs index 604962c46..8b90fae9a 100644 --- a/src/entity/event_record.rs +++ b/src/entity/event_record.rs @@ -157,7 +157,7 @@ impl EventRecord { payload, event_version: 1, sequence, - timestamp: SystemTime::now(), + timestamp: crate::time::now(), metadata: HashMap::new(), } } @@ -176,7 +176,7 @@ impl EventRecord { payload, event_version: version, sequence, - timestamp: SystemTime::now(), + timestamp: crate::time::now(), metadata: HashMap::new(), } } @@ -195,7 +195,7 @@ impl EventRecord { payload, event_version: 1, sequence, - timestamp: SystemTime::now(), + timestamp: crate::time::now(), metadata, } } diff --git a/src/graphql/engine/request.rs b/src/graphql/engine/request.rs index 949376c08..f3eb5a98f 100644 --- a/src/graphql/engine/request.rs +++ b/src/graphql/engine/request.rs @@ -240,7 +240,7 @@ impl GraphqlEngine { .map_err(|_| ())?, ) .map_err(|_| ())?; - let issued_at_unix_ms = std::time::SystemTime::now() + let issued_at_unix_ms = crate::time::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|_| ())? .as_millis() diff --git a/src/graphql/identity/oidc.rs b/src/graphql/identity/oidc.rs index 0c9e7f5cb..1107f1941 100644 --- a/src/graphql/identity/oidc.rs +++ b/src/graphql/identity/oidc.rs @@ -796,7 +796,7 @@ fn map_jwt_error(e: jsonwebtoken::errors::Error) -> ValidationError { /// Current unix time for tests that craft exp manually. #[allow(dead_code)] pub fn now_unix() -> u64 { - SystemTime::now() + crate::time::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) diff --git a/src/graphql/projection_delta/runtime.rs b/src/graphql/projection_delta/runtime.rs index 4f4d6bbc2..3c271f7b7 100644 --- a/src/graphql/projection_delta/runtime.rs +++ b/src/graphql/projection_delta/runtime.rs @@ -159,7 +159,7 @@ impl ProtocolProjectionRequestSeed { replay_retention, occurrences, sealed_events, - SystemTime::now(), + crate::time::now(), ) } @@ -497,7 +497,7 @@ impl ProtocolProjectionRequestSeed { causation_id: &str, metadata: &CommandProjectionMetadataV1, ) -> Result<(), ProjectionRuntimeAuthorityError> { - let now_unix_ms = unix_time_ms(SystemTime::now())?; + let now_unix_ms = unix_time_ms(crate::time::now())?; metadata .validate_not_expired(now_unix_ms) .map_err(|error| match error { @@ -549,7 +549,7 @@ impl ProtocolProjectionRequestSeed { ); } self.validate_command_projection_inventory(command_name, metadata)?; - let now_unix_ms = unix_time_ms(SystemTime::now())?; + let now_unix_ms = unix_time_ms(crate::time::now())?; metadata .validate_not_expired(now_unix_ms) .map_err(|error| match error { @@ -732,7 +732,7 @@ impl ProtocolProjectionRequestSeed { // zero-occurrence receipt. It must still declare modeled selectors, // even though lifecycle-only or scope-drift work is revalidation-only. self.empty_command_disposition(command_name)?; - let now_unix_ms = unix_time_ms(SystemTime::now())?; + let now_unix_ms = unix_time_ms(crate::time::now())?; metadata .validate_not_expired(now_unix_ms) .map_err(|error| match error { diff --git a/src/in_memory_repo/repository.rs b/src/in_memory_repo/repository.rs index 94498c503..6652c1450 100644 --- a/src/in_memory_repo/repository.rs +++ b/src/in_memory_repo/repository.rs @@ -6,7 +6,6 @@ use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::{Arc, RwLock}; -use std::time::SystemTime; use super::projection_protocol::{ reject_causal_owned_plans, stage_same_transaction_projection, InMemoryProjectionProtocolState, @@ -287,7 +286,7 @@ impl InMemoryRepository { .ok_or_else(|| CommandLedgerError::AttemptFenced { command_id: completion.attempt().key().command_id().to_string(), })?; - record.validate_live_attempt(&completion.attempt_fence(), SystemTime::now())?; + record.validate_live_attempt(&completion.attempt_fence(), crate::time::now())?; } // Events: optimistic-concurrency check (reads only; appends cannot @@ -386,7 +385,7 @@ impl InMemoryRepository { command_id: completion.attempt().key().command_id().to_string(), })?; let mut staged = record.clone(); - staged.complete(completion, SystemTime::now())?; + staged.complete(completion, crate::time::now())?; Ok::<_, CommandLedgerError>(staged) }) .transpose()?; @@ -480,7 +479,7 @@ impl CommandLedgerStore for InMemoryRepository { reservation: CommandReservation, ) -> impl Future> + Send + '_ { async move { - let now = SystemTime::now(); + let now = crate::time::now(); let mut ledger = self .command_ledger .write() @@ -517,7 +516,7 @@ impl CommandLedgerStore for InMemoryRepository { scope: CommandLookupScope<'a>, ) -> impl Future> + Send + 'a { async move { - let now = SystemTime::now(); + let now = crate::time::now(); let mut ledger = self .command_ledger .write() @@ -552,7 +551,7 @@ impl CommandLedgerStore for InMemoryRepository { .ok_or_else(|| CommandLedgerError::AttemptFenced { command_id: attempt.key().command_id().to_string(), })?; - record.mark_retryable_unknown(&attempt, SystemTime::now()) + record.mark_retryable_unknown(&attempt, crate::time::now()) } } @@ -564,7 +563,7 @@ impl CommandLedgerStore for InMemoryRepository { if limit == 0 { return Ok(0); } - let now = SystemTime::now(); + let now = crate::time::now(); let mut ledger = self .command_ledger .write() diff --git a/src/lib.rs b/src/lib.rs index 5eef9b718..76d80a52a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,8 @@ pub mod __private { pub use serde; } +mod time; + pub mod aggregate; pub mod application; pub mod command_dispatch; diff --git a/src/microsvc/cell_host/cell.rs b/src/microsvc/cell_host/cell.rs index 346bb79fa..2d6ec0df7 100644 --- a/src/microsvc/cell_host/cell.rs +++ b/src/microsvc/cell_host/cell.rs @@ -103,6 +103,14 @@ where .dispatch_cell_command(command, input, session, &self.shard) .await } + + /// Load this cell's aggregate from the private stream store. + /// + /// HTTP GET on the cell host is a stream load, not a GraphQL/projector + /// method (`PCH-REQ-005`). + pub async fn load(&self) -> Result, RepositoryError> { + self.routes.repo().get(self.shard.aggregate_id()).await + } } /// Worker-side namespace: `getByName(format!("{}:{}", type, shard))`. diff --git a/src/microsvc/cell_host/tests.rs b/src/microsvc/cell_host/tests.rs index 380a98790..03729fa08 100644 --- a/src/microsvc/cell_host/tests.rs +++ b/src/microsvc/cell_host/tests.rs @@ -215,6 +215,8 @@ async fn cell_dispatches_complete_with_the_same_portable_command_as_soa() { .await .expect("create"); assert_eq!(created["id"], "item-1"); + let loaded = cell.load().await.expect("load"); + assert_eq!(loaded.expect("resident").title, "ship"); let completed = cell .dispatch( diff --git a/src/microsvc/service/routes.rs b/src/microsvc/service/routes.rs index fc575605e..3c525c1f3 100644 --- a/src/microsvc/service/routes.rs +++ b/src/microsvc/service/routes.rs @@ -1820,7 +1820,7 @@ where let mut fallback_rows = Vec::new(); let mut outbox_ids = Vec::new(); if let Some(config) = publisher { - let claim_now = config.schedule.is_none().then(SystemTime::now); + let claim_now = config.schedule.is_none().then(crate::time::now); let mut claim_error = None; for message in &mut batch.outbox_messages { message.overwrite_causation_id(attempt.causation_id().as_str()); diff --git a/src/outbox/commit.rs b/src/outbox/commit.rs index fa7d0c1a4..26e47fd85 100644 --- a/src/outbox/commit.rs +++ b/src/outbox/commit.rs @@ -1,7 +1,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::time::{Duration, SystemTime}; +use std::time::Duration; use crate::aggregate::{Aggregate, AggregateRepository}; use crate::domain_event::{DomainEvent, DomainEventCaptureError, DomainEventCommitGuardError}; @@ -253,7 +253,7 @@ where let mut fallback_rows = Vec::new(); if let Some(config) = publisher { if config.schedule.is_none() { - let now = SystemTime::now(); + let now = crate::time::now(); for message in &mut self.outbox_messages { message.claim_at(&config.worker_id, config.lease, now)?; } diff --git a/src/outbox/message.rs b/src/outbox/message.rs index f93578bae..3b145f9d6 100644 --- a/src/outbox/message.rs +++ b/src/outbox/message.rs @@ -381,7 +381,7 @@ impl OutboxMessage { } fn is_claimable(&self) -> bool { - self.is_claimable_at(SystemTime::now()) + self.is_claimable_at(crate::time::now()) } // Commands @@ -431,7 +431,7 @@ impl OutboxMessage { self.destination = destination; self.metadata = metadata; self.status = OutboxMessageStatus::Pending; - self.created_at = SystemTime::now(); + self.created_at = crate::time::now(); self.attempts = 0; self.last_error = None; self.worker_id = None; @@ -481,7 +481,7 @@ impl OutboxMessage { /// Claim with a Duration (convenience method that computes the deadline) pub fn claim_for(&mut self, worker_id: impl Into, lease: Duration) -> SourcedResult { - self.claim_at(worker_id, lease, SystemTime::now()) + self.claim_at(worker_id, lease, crate::time::now()) } /// Claim with an explicit clock value. This is useful for deterministic @@ -685,8 +685,8 @@ mod tests { .claim_at("worker-1", Duration::from_secs(1), SystemTime::UNIX_EPOCH) .unwrap(); - assert!(message.has_expired_lease_at(SystemTime::now())); - assert!(message.is_claimable_at(SystemTime::now())); + assert!(message.has_expired_lease_at(crate::time::now())); + assert!(message.is_claimable_at(crate::time::now())); message .claim_for("worker-2", Duration::from_secs(60)) diff --git a/src/outbox/table.rs b/src/outbox/table.rs index a71c972ae..2383df679 100644 --- a/src/outbox/table.rs +++ b/src/outbox/table.rs @@ -190,7 +190,7 @@ fn optional_time_epoch_secs(value: Option) -> Result Result { if message.status == OutboxMessageStatus::Failed { - Ok(RowValue::U64(system_time_epoch_secs(SystemTime::now())?)) + Ok(RowValue::U64(system_time_epoch_secs(crate::time::now())?)) } else { Ok(RowValue::Null) } diff --git a/src/outbox_worker/outbox_dispatch.rs b/src/outbox_worker/outbox_dispatch.rs index 85042c77a..34d6b78f2 100644 --- a/src/outbox_worker/outbox_dispatch.rs +++ b/src/outbox_worker/outbox_dispatch.rs @@ -309,7 +309,7 @@ pub(crate) async fn record_backlog_gauges(store: &S, service: Op if let Ok(stats) = store.backlog_stats().await { let oldest_pending_age = stats .oldest_created_at - .and_then(|created_at| SystemTime::now().duration_since(created_at).ok()); + .and_then(|created_at| crate::time::now().duration_since(created_at).ok()); crate::metrics::set_outbox_backlog(service, stats.pending, oldest_pending_age); } } diff --git a/src/outbox_worker/store/in_memory.rs b/src/outbox_worker/store/in_memory.rs index e0b0053b4..13a2c718a 100644 --- a/src/outbox_worker/store/in_memory.rs +++ b/src/outbox_worker/store/in_memory.rs @@ -1,5 +1,4 @@ use std::future::Future; -use std::time::SystemTime; use crate::in_memory_repo::InMemoryOutboxStore; use crate::outbox::{OutboxMessage, OutboxMessageStatus}; @@ -93,7 +92,7 @@ impl OutboxStore for InMemoryOutboxStore { return Ok(Vec::new()); } - let now = SystemTime::now(); + let now = crate::time::now(); let ids = claim_order_ids(storage.values()); let mut claimed = Vec::new(); for id in ids { @@ -130,7 +129,7 @@ impl OutboxStore for InMemoryOutboxStore { ) -> impl Future> + Send + 'a { async move { self.update_outbox_message(&claim.message_id, |message| { - ensure_active_claim(message, Some(claim), SystemTime::now())?; + ensure_active_claim(message, Some(claim), crate::time::now())?; message.complete()?; Ok(()) }) @@ -154,7 +153,7 @@ impl OutboxStore for InMemoryOutboxStore { .storage .write() .map_err(|_| RepositoryError::LockPoisoned("outbox write"))?; - let now = SystemTime::now(); + let now = crate::time::now(); for claim in claims { let message = storage.get_mut(&claim.message_id).ok_or_else(|| { RepositoryError::NotFound { @@ -175,7 +174,7 @@ impl OutboxStore for InMemoryOutboxStore { ) -> impl Future> + Send + 'a { async move { self.update_outbox_message(&claim.message_id, |message| { - ensure_active_claim(message, Some(claim), SystemTime::now())?; + ensure_active_claim(message, Some(claim), crate::time::now())?; message.release(error.to_string())?; Ok(()) }) @@ -189,7 +188,7 @@ impl OutboxStore for InMemoryOutboxStore { ) -> impl Future> + Send + 'a { async move { self.update_outbox_message(&claim.message_id, |message| { - ensure_active_claim(message, Some(claim), SystemTime::now())?; + ensure_active_claim(message, Some(claim), crate::time::now())?; message.fail(error.to_string())?; Ok(()) }) diff --git a/src/repository/inbox.rs b/src/repository/inbox.rs index 0b30fd17e..826e59928 100644 --- a/src/repository/inbox.rs +++ b/src/repository/inbox.rs @@ -45,7 +45,7 @@ impl InboxReceipt { Self { consumer: consumer.into(), message_id: message_id.into(), - processed_at: SystemTime::now(), + processed_at: crate::time::now(), } } diff --git a/src/snapshot/store.rs b/src/snapshot/store.rs index 10044e377..36f30949c 100644 --- a/src/snapshot/store.rs +++ b/src/snapshot/store.rs @@ -44,7 +44,7 @@ impl SnapshotRecord { payload_codec_version: BITCODE_PAYLOAD_CODEC_VERSION, payload, metadata: HashMap::new(), - recorded_at: SystemTime::now(), + recorded_at: crate::time::now(), } } diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 000000000..281291745 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,14 @@ +//! Wall clock. `wasm32-unknown-unknown` has no `SystemTime::now`. + +use std::time::SystemTime; + +pub(crate) fn now() -> SystemTime { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(js_sys::Date::now() as u64) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + SystemTime::now() + } +} diff --git a/tests/celld/README.md b/tests/celld/README.md index 21ab594c2..e2423065f 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -4,8 +4,11 @@ First live celld host for portable command hosts: one `TodoCell` Durable Object per todo id, private SQLite, Docker Compose for the daemon **and** Azurite (no AWS or Cloudflare account). -This is **not** workers-rs packaging of `distributed::cell_host::AggregateCell`. -The Worker is a thin JS class with the same shard rule (`idFromName(todo_id)`). +The Worker is a workers-rs Durable Object class around +`distributed::cell_host::AggregateCell`. Shard rule is still +`idFromName(todo_id)` (`PCH-DEC-004`). GraphQL and projectors are not +cell methods. Cell stream storage is still the in-memory +`CellStreamStore` stand-in (SQL-backed cell storage is follow-up). Azurite is celld's documented local development store. It is **not** a production fleet bucket. @@ -14,6 +17,7 @@ production fleet bucket. - Docker - `celld` CLI + `esbuild` on `PATH` (`curl -fsSL https://celld.dev/install.sh | sh`) +- `worker-build` (`cargo install worker-build`) and the `wasm32-unknown-unknown` target ## Run @@ -26,6 +30,7 @@ export AZURE_STORAGE_ACCOUNT_NAME=devstoreaccount1 export AZURE_STORAGE_ACCOUNT_KEY='Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==' celld diagnose --bucket az://celld --listen 127.0.0.1:18090 --internal-listen 127.0.0.1:18091 +(cd tests/celld/worker && worker-build --release) celld deploy tests/celld/worker --bucket az://celld docker compose -f tests/celld/docker-compose.yml up -d celld CELLD_URL=http://127.0.0.1:18080 cargo test --test celld diff --git a/tests/celld/main.rs b/tests/celld/main.rs index 98ffb5270..4006b6a08 100644 --- a/tests/celld/main.rs +++ b/tests/celld/main.rs @@ -17,10 +17,10 @@ fn worker_dir() -> &'static Path { #[test] fn worker_declares_sqlite_todo_cell() { - let wrangler = std::fs::read_to_string(worker_dir().join("wrangler.jsonc")) - .expect("wrangler.jsonc"); + let wrangler = + std::fs::read_to_string(worker_dir().join("wrangler.jsonc")).expect("wrangler.jsonc"); let spec: Value = serde_json::from_str(&wrangler).expect("wrangler json"); - assert_eq!(spec["main"], "index.js"); + assert_eq!(spec["main"], "build/worker/shim.mjs"); let bindings = spec["durable_objects"]["bindings"].as_array().unwrap(); assert_eq!(bindings[0]["name"], "TODO"); assert_eq!(bindings[0]["class_name"], "TodoCell"); @@ -29,10 +29,12 @@ fn worker_declares_sqlite_todo_cell() { .unwrap(); assert_eq!(classes[0], "TodoCell"); - let source = std::fs::read_to_string(worker_dir().join("index.js")).expect("index.js"); - assert!(source.contains("export class TodoCell")); - assert!(source.contains("idFromName")); - assert!(source.contains("CREATE TABLE IF NOT EXISTS todo")); + let source = std::fs::read_to_string(worker_dir().join("src/lib.rs")).expect("lib.rs"); + assert!(source.contains("pub struct TodoCell")); + assert!(source.contains("AggregateCell::")); + assert!(source.contains("id_from_name")); + assert!(source.contains("mount(create())")); + assert!(source.contains("mount(complete())")); } #[test] diff --git a/tests/celld/worker/Cargo.toml b/tests/celld/worker/Cargo.toml new file mode 100644 index 000000000..eaf47dc96 --- /dev/null +++ b/tests/celld/worker/Cargo.toml @@ -0,0 +1,24 @@ +[workspace] +members = ["."] +resolver = "2" + +[package] +name = "todo-cell-worker" +version = "0.1.0" +edition = "2021" +publish = false +description = "workers-rs Todo cell: AggregateCell + todo-domain handles" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +console_error_panic_hook = "0.1" +distributed = { path = "../../..", default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +todo-domain = { path = "../../e2e-ui/crates/todo-domain" } +worker = "0.8" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/tests/celld/worker/index.js b/tests/celld/worker/index.js deleted file mode 100644 index 92df5b4a5..000000000 --- a/tests/celld/worker/index.js +++ /dev/null @@ -1,102 +0,0 @@ -// One Todo aggregate per Durable Object instance. -// Worker: GET/PUT /todo/:id, POST /todo/:id/complete -// Cell address: env.TODO.idFromName(id) → shard = todo id (PCH-REQ-003) - -export class TodoCell { - constructor(state, _env) { - this.state = state; - this.sql = state.storage.sql; - this.sql.exec(` - CREATE TABLE IF NOT EXISTS todo ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - status TEXT NOT NULL - ) - `); - } - - async fetch(request) { - const url = new URL(request.url); - const parts = url.pathname.split("/").filter(Boolean); - // ["todo", id] or ["todo", id, "complete"] - const id = parts[1]; - if (!id) { - return json({ error: "missing todo id" }, 400); - } - - if (request.method === "GET" && parts.length === 2) { - const row = firstRow(this.sql.exec("SELECT id, title, status FROM todo WHERE id = ?", id)); - if (!row) { - return json({ error: "not found", id }, 404); - } - return json(row, 200); - } - - if (request.method === "PUT" && parts.length === 2) { - const body = await request.json().catch(() => ({})); - const title = typeof body.title === "string" ? body.title.trim() : ""; - if (!title) { - return json({ error: "title required" }, 400); - } - const existing = firstRow(this.sql.exec("SELECT id FROM todo WHERE id = ?", id)); - if (existing) { - return json({ error: "already exists", id }, 409); - } - this.sql.exec( - "INSERT INTO todo (id, title, status) VALUES (?, ?, ?)", - id, - title, - "open", - ); - return json({ id, title, status: "open" }, 201); - } - - if (request.method === "POST" && parts[2] === "complete") { - const row = firstRow( - this.sql.exec("SELECT id, title, status FROM todo WHERE id = ?", id), - ); - if (!row) { - return json({ error: "not found", id }, 404); - } - if (row.status !== "open") { - return json({ error: "not open", id, status: row.status }, 422); - } - this.sql.exec("UPDATE todo SET status = ? WHERE id = ?", "completed", id); - return json({ id, title: row.title, status: "completed" }, 200); - } - - return json({ error: "not found" }, 404); - } -} - -export default { - async fetch(request, env) { - const url = new URL(request.url); - if (url.pathname === "/" || url.pathname === "/health") { - return new Response("distributed todo cell\n", { status: 200 }); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts[0] !== "todo" || !parts[1]) { - return new Response("todo cell. PUT/GET /todo/:id POST /todo/:id/complete\n", { - status: 404, - }); - } - const id = parts[1]; - const stub = env.TODO.get(env.TODO.idFromName(id)); - return stub.fetch(request); - }, -}; - -function firstRow(cursor) { - for (const row of cursor) { - return row; - } - return null; -} - -function json(body, status) { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} diff --git a/tests/celld/worker/src/lib.rs b/tests/celld/worker/src/lib.rs new file mode 100644 index 000000000..9819e7593 --- /dev/null +++ b/tests/celld/worker/src/lib.rs @@ -0,0 +1,175 @@ +//! Todo Durable Object class backed by `AggregateCell`. +//! +//! HTTP is a thin adapter over domain create/complete + stream load. +//! GraphQL and projectors are not methods on this class (`PCH-REQ-005`). + +use distributed::cell_host::AggregateCell; +use distributed::microsvc::{HandlerError, Session, ROLE_KEY, USER_ID_KEY}; +use serde::Deserialize; +use serde_json::{json, Value}; +use todo_domain::{complete, create, Todo, TodoState}; +use worker::*; + +#[durable_object] +pub struct TodoCell { + cell: AggregateCell, +} + +impl DurableObject for TodoCell { + fn new(state: State, _env: Env) -> Self { + console_error_panic_hook::set_once(); + let shard = state.id().name().unwrap_or_else(|| "todo".to_string()); + let cell = AggregateCell::::new(shard) + .expect("todo cell identity") + .mount(create()) + .mount(complete()); + Self { cell } + } + + async fn fetch(&self, mut req: Request) -> Result { + let url = req.url()?; + let parts: Vec = url + .path() + .split('/') + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect(); + let id = match parts.get(1) { + Some(id) if parts.first().map(String::as_str) == Some("todo") => id.clone(), + _ => return json_status(json!({ "error": "missing todo id" }), 400), + }; + + match (req.method(), parts.get(2).map(String::as_str)) { + (Method::Get, None) => get_todo(&self.cell, &id).await, + (Method::Put, None) => create_todo(&self.cell, &id, &mut req).await, + (Method::Post, Some("complete")) => complete_todo(&self.cell, &id).await, + _ => json_status(json!({ "error": "not found" }), 404), + } + } +} + +#[event(fetch)] +async fn main(req: Request, env: Env, _ctx: Context) -> Result { + console_error_panic_hook::set_once(); + let url = req.url()?; + let path = url.path(); + if path == "/" || path == "/health" { + return Response::ok("distributed todo cell\n"); + } + let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + if parts.first() != Some(&"todo") || parts.get(1).is_none() { + return Response::error( + "todo cell. PUT/GET /todo/:id POST /todo/:id/complete\n", + 404, + ); + } + let namespace = env.durable_object("TODO")?; + let stub = namespace.id_from_name(parts[1])?.get_stub()?; + stub.fetch_with_request(req).await +} + +#[derive(Deserialize)] +struct CreateBody { + title: Option, +} + +fn local_session() -> Session { + let mut session = Session::new(); + session.set(USER_ID_KEY, "celld-local"); + session.set(ROLE_KEY, "user"); + session +} + +async fn get_todo(cell: &AggregateCell, id: &str) -> Result { + match cell.load().await { + Ok(Some(todo)) => json_status(http_todo(&TodoState::from(&todo)), 200), + Ok(None) => json_status(json!({ "error": "not found", "id": id }), 404), + Err(error) => json_status(json!({ "error": error.to_string() }), 500), + } +} + +async fn create_todo(cell: &AggregateCell, id: &str, req: &mut Request) -> Result { + let body = req + .json::() + .await + .unwrap_or(CreateBody { title: None }); + let title = body.title.unwrap_or_default(); + let title = title.trim(); + if title.is_empty() { + return json_status(json!({ "error": "title required" }), 400); + } + match cell + .dispatch( + "todo.create", + json!({ "todo_id": id, "title": title }), + local_session(), + ) + .await + { + Ok(payload) => json_status(http_from_command(id, &payload, title), 201), + Err(HandlerError::Rejected(message)) if message.contains("already exists") => { + json_status(json!({ "error": "already exists", "id": id }), 409) + } + Err(error) => map_handler_error(error), + } +} + +async fn complete_todo(cell: &AggregateCell, id: &str) -> Result { + match cell + .dispatch("todo.complete", json!({ "todo_id": id }), local_session()) + .await + { + Ok(payload) => { + let title = cell + .load() + .await + .ok() + .flatten() + .map(|todo| TodoState::from(&todo).title) + .unwrap_or_default(); + json_status(http_from_command(id, &payload, &title), 200) + } + Err(HandlerError::NotFound(_)) => { + json_status(json!({ "error": "not found", "id": id }), 404) + } + Err(HandlerError::Rejected(message)) if message.to_lowercase().contains("not found") => { + json_status(json!({ "error": "not found", "id": id }), 404) + } + Err(HandlerError::Rejected(message)) if message.contains("not open") => json_status( + json!({ "error": "not open", "id": id, "status": "completed" }), + 422, + ), + Err(error) => map_handler_error(error), + } +} + +fn http_todo(state: &TodoState) -> Value { + json!({ + "id": state.todo_id, + "title": state.title, + "status": state.status, + }) +} + +fn http_from_command(id: &str, payload: &Value, fallback_title: &str) -> Value { + json!({ + "id": payload.get("todo_id").cloned().unwrap_or_else(|| json!(id)), + "title": payload.get("title").cloned().unwrap_or_else(|| json!(fallback_title)), + "status": payload.get("status").cloned().unwrap_or_else(|| json!("open")), + }) +} + +fn map_handler_error(error: HandlerError) -> Result { + let status = match &error { + HandlerError::NotFound(_) => 404, + HandlerError::Unauthorized(_) | HandlerError::GuardRejected(_) => 401, + HandlerError::Rejected(_) => 422, + HandlerError::DecodeFailed(_) => 400, + _ => 500, + }; + json_status(json!({ "error": error.to_string() }), status) +} + +fn json_status(body: Value, status: u16) -> Result { + Ok(Response::from_json(&body)?.with_status(status)) +} diff --git a/tests/celld/worker/wrangler.jsonc b/tests/celld/worker/wrangler.jsonc index da99c89dd..c8290bae0 100644 --- a/tests/celld/worker/wrangler.jsonc +++ b/tests/celld/worker/wrangler.jsonc @@ -1,6 +1,6 @@ { "name": "distributed-todo-cell", - "main": "index.js", + "main": "build/worker/shim.mjs", "compatibility_date": "2026-01-01", "durable_objects": { "bindings": [{ "name": "TODO", "class_name": "TodoCell" }] diff --git a/tests/e2e-ui/crates/todo-domain/Cargo.toml b/tests/e2e-ui/crates/todo-domain/Cargo.toml index b4c393895..389b01275 100644 --- a/tests/e2e-ui/crates/todo-domain/Cargo.toml +++ b/tests/e2e-ui/crates/todo-domain/Cargo.toml @@ -6,10 +6,13 @@ publish = false description = "Todo aggregate: create, rename, complete, reopen, archive (owner-scoped)" [dependencies] -distributed = { workspace = true } -serde = { workspace = true } -thiserror = { workspace = true } +# Path + default-features so a wasm worker can depend on this crate without +# pulling e2e-ui's sqlite/postgres/http/graphql feature set. Those features +# still unify when this crate is built inside the e2e-ui workspace. +distributed = { path = "../../../..", default-features = false } +serde = { version = "1", features = ["derive"] } +thiserror = { version = "1" } [dev-dependencies] -serde_json = { workspace = true } -tokio = { workspace = true } +serde_json = { version = "1" } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } From 2235a987eae651884425e8fa4b4d21be38b2fd10 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 18:54:04 -0500 Subject: [PATCH 06/37] feat: persist Todo cell event log in Durable Object SQLite CellStreamStore dumps EventRecords into the DO cell_events table and restores them on each request. GET after celld restart still hydrates the event-sourced Todo. Implements [[tasks/portable-command-hosts-8]] --- src/in_memory_repo/repository.rs | 21 +++++++ src/microsvc/cell_host/cell.rs | 15 ++++- src/microsvc/cell_host/mod.rs | 2 +- src/microsvc/cell_host/store.rs | 34 ++++++++++- src/microsvc/cell_host/tests.rs | 15 +++++ tests/celld/README.md | 8 ++- tests/celld/main.rs | 2 + tests/celld/worker/src/lib.rs | 101 ++++++++++++++++++++++++++++--- 8 files changed, 186 insertions(+), 12 deletions(-) diff --git a/src/in_memory_repo/repository.rs b/src/in_memory_repo/repository.rs index 6652c1450..67a2f6ff7 100644 --- a/src/in_memory_repo/repository.rs +++ b/src/in_memory_repo/repository.rs @@ -136,6 +136,27 @@ impl InMemoryRepository { &self.snapshot_store } + /// Clone the event log for Durable Object SQLite persistence. + pub fn clone_events(&self) -> Result>, RepositoryError> { + Ok(self + .event_store + .read() + .map_err(|_| RepositoryError::LockPoisoned("event log read"))? + .clone()) + } + + /// Replace the event log from Durable Object SQLite restore. + pub fn replace_events( + &self, + events: HashMap>, + ) -> Result<(), RepositoryError> { + *self + .event_store + .write() + .map_err(|_| RepositoryError::LockPoisoned("event log write"))? = events; + Ok(()) + } + /// Whether a consumer inbox receipt for `(consumer, message_id)` is recorded. pub fn inbox_contains(&self, consumer: &str, message_id: &str) -> bool { self.inbox_store diff --git a/src/microsvc/cell_host/cell.rs b/src/microsvc/cell_host/cell.rs index 2d6ec0df7..4510b3206 100644 --- a/src/microsvc/cell_host/cell.rs +++ b/src/microsvc/cell_host/cell.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use serde_json::Value; -use super::store::CellStreamStore; +use super::store::{CellStreamStore, DurableCellEvents}; use crate::aggregate::{Aggregate, AggregateRepository}; use crate::microsvc::error::HandlerError; use crate::microsvc::service::{PortableCommand, Routes}; @@ -111,6 +111,19 @@ where pub async fn load(&self) -> Result, RepositoryError> { self.routes.repo().get(self.shard.aggregate_id()).await } + + /// Event log for Durable Object SQLite persistence. + pub fn durable_events(&self) -> Result, RepositoryError> { + self.routes.repo().repo().durable_events() + } + + /// Restore the working event log from Durable Object SQLite. + pub fn restore_durable_events( + &self, + events: Vec, + ) -> Result<(), RepositoryError> { + self.routes.repo().repo().restore_durable_events(events) + } } /// Worker-side namespace: `getByName(format!("{}:{}", type, shard))`. diff --git a/src/microsvc/cell_host/mod.rs b/src/microsvc/cell_host/mod.rs index 19ce81bb3..31ecfab5d 100644 --- a/src/microsvc/cell_host/mod.rs +++ b/src/microsvc/cell_host/mod.rs @@ -13,7 +13,7 @@ mod cell; mod store; pub use cell::{instance_name, parent_cell_name, AggregateCell, CellNamespace}; -pub use store::CellStreamStore; +pub use store::{CellStreamStore, DurableCellEvents}; #[cfg(test)] mod tests; diff --git a/src/microsvc/cell_host/store.rs b/src/microsvc/cell_host/store.rs index 875d63af9..587fa0c41 100644 --- a/src/microsvc/cell_host/store.rs +++ b/src/microsvc/cell_host/store.rs @@ -11,7 +11,7 @@ use crate::command_ledger::{ CausalStorageIdentity, CausalTransactionalCommit, CommandLedgerError, CommandLedgerKey, CommandLedgerStore, CommandLookup, CommandLookupScope, CommandReservation, ReservationOutcome, }; -use crate::entity::Entity; +use crate::entity::{Entity, EventRecord}; use crate::microsvc::HasOutboxStore; use crate::projection_protocol::{ ProjectionChangeCursor, ProjectionChangeRead, ProjectionCheckpoint, ProjectionCommitBatch, @@ -29,6 +29,7 @@ use crate::repository::{ CommitBatch, GetStream, RepositoryError, SnapshotWrite, StreamIdentity, TransactionalCommit, }; use crate::{InMemoryOutboxStore, InMemoryRepository}; +use serde::{Deserialize, Serialize}; #[derive(Clone)] enum CellOwnership { @@ -54,6 +55,14 @@ enum CellOwnership { /// let _ = left.commit_across(right, batch); /// } /// ``` + +/// One stream's event records for Durable Object SQLite persistence. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DurableCellEvents { + pub stream: String, + pub events: Vec, +} + #[derive(Clone)] pub struct CellStreamStore { ownership: CellOwnership, @@ -123,6 +132,29 @@ impl CellStreamStore { } } + /// Event log for Durable Object SQLite. Memory remains the working copy. + pub fn durable_events(&self) -> Result, RepositoryError> { + Ok(self + .inner + .clone_events()? + .into_iter() + .map(|(stream, events)| DurableCellEvents { stream, events }) + .collect()) + } + + /// Replace the working event log from Durable Object SQLite. + pub fn restore_durable_events( + &self, + events: Vec, + ) -> Result<(), RepositoryError> { + self.inner.replace_events( + events + .into_iter() + .map(|row| (row.stream, row.events)) + .collect(), + ) + } + fn ensure_batch(&self, batch: &CommitBatch<'_>) -> Result<(), RepositoryError> { for stream in &batch.streams { self.ensure_identity(&stream.identity)?; diff --git a/src/microsvc/cell_host/tests.rs b/src/microsvc/cell_host/tests.rs index 03729fa08..5485de0dc 100644 --- a/src/microsvc/cell_host/tests.rs +++ b/src/microsvc/cell_host/tests.rs @@ -228,6 +228,21 @@ async fn cell_dispatches_complete_with_the_same_portable_command_as_soa() { .expect("complete"); assert_eq!(completed["id"], "item-1"); assert_eq!(completed["done"], true); + + let exported = cell.durable_events().expect("export"); + assert!(!exported.is_empty()); + let restored = AggregateCell::::new("item-1") + .unwrap() + .mount(Create) + .mount(Complete); + restored.restore_durable_events(exported).expect("restore"); + let loaded = restored + .load() + .await + .expect("load restored") + .expect("durable"); + assert_eq!(loaded.title, "ship"); + assert!(loaded.done); } #[tokio::test] diff --git a/tests/celld/README.md b/tests/celld/README.md index e2423065f..3fabe5183 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -7,8 +7,8 @@ Azurite (no AWS or Cloudflare account). The Worker is a workers-rs Durable Object class around `distributed::cell_host::AggregateCell`. Shard rule is still `idFromName(todo_id)` (`PCH-DEC-004`). GraphQL and projectors are not -cell methods. Cell stream storage is still the in-memory -`CellStreamStore` stand-in (SQL-backed cell storage is follow-up). +cell methods. The event log is stored in the Durable Object SQLite +table `cell_events` (replicated by celld via LTX). Azurite is celld's documented local development store. It is **not** a production fleet bucket. @@ -44,6 +44,10 @@ before `docker compose up` and use that port in `CELLD_URL`. If host port 8080 i Without `CELLD_URL`, `cargo test --test celld` only checks the worker fixture and skips the live HTTP round-trip. +Durability: PUT writes `cell_events`, then GET restores that table into +the working copy. After `docker compose … restart celld`, GET of an +existing id should still return the todo. + Tear down: `docker compose -f tests/celld/docker-compose.yml down -v`. ## Ports diff --git a/tests/celld/main.rs b/tests/celld/main.rs index 4006b6a08..222c940e5 100644 --- a/tests/celld/main.rs +++ b/tests/celld/main.rs @@ -35,6 +35,8 @@ fn worker_declares_sqlite_todo_cell() { assert!(source.contains("id_from_name")); assert!(source.contains("mount(create())")); assert!(source.contains("mount(complete())")); + assert!(source.contains("CREATE TABLE IF NOT EXISTS cell_events")); + assert!(source.contains("restore_durable_events")); } #[test] diff --git a/tests/celld/worker/src/lib.rs b/tests/celld/worker/src/lib.rs index 9819e7593..ffcb4183e 100644 --- a/tests/celld/worker/src/lib.rs +++ b/tests/celld/worker/src/lib.rs @@ -3,30 +3,47 @@ //! HTTP is a thin adapter over domain create/complete + stream load. //! GraphQL and projectors are not methods on this class (`PCH-REQ-005`). -use distributed::cell_host::AggregateCell; +use distributed::cell_host::{AggregateCell, DurableCellEvents}; use distributed::microsvc::{HandlerError, Session, ROLE_KEY, USER_ID_KEY}; +use distributed::EventRecord; use serde::Deserialize; use serde_json::{json, Value}; use todo_domain::{complete, create, Todo, TodoState}; use worker::*; +const EVENTS_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_events ( + stream TEXT NOT NULL, + seq INTEGER NOT NULL, + body TEXT NOT NULL, + PRIMARY KEY (stream, seq) +)"; + #[durable_object] pub struct TodoCell { cell: AggregateCell, + sql: SqlStorage, } impl DurableObject for TodoCell { fn new(state: State, _env: Env) -> Self { console_error_panic_hook::set_once(); + let sql = state.storage().sql(); + sql.exec(EVENTS_DDL, None).expect("create cell_events"); let shard = state.id().name().unwrap_or_else(|| "todo".to_string()); let cell = AggregateCell::::new(shard) .expect("todo cell identity") .mount(create()) .mount(complete()); - Self { cell } + if let Ok(events) = load_events(&sql) { + let _ = cell.restore_durable_events(events); + } + Self { cell, sql } } async fn fetch(&self, mut req: Request) -> Result { + if let Err(error) = restore_working_copy(&self.sql, &self.cell) { + return json_status(json!({ "error": error }), 500); + } let url = req.url()?; let parts: Vec = url .path() @@ -41,8 +58,8 @@ impl DurableObject for TodoCell { match (req.method(), parts.get(2).map(String::as_str)) { (Method::Get, None) => get_todo(&self.cell, &id).await, - (Method::Put, None) => create_todo(&self.cell, &id, &mut req).await, - (Method::Post, Some("complete")) => complete_todo(&self.cell, &id).await, + (Method::Put, None) => create_todo(&self.sql, &self.cell, &id, &mut req).await, + (Method::Post, Some("complete")) => complete_todo(&self.sql, &self.cell, &id).await, _ => json_status(json!({ "error": "not found" }), 404), } } @@ -88,7 +105,12 @@ async fn get_todo(cell: &AggregateCell, id: &str) -> Result { } } -async fn create_todo(cell: &AggregateCell, id: &str, req: &mut Request) -> Result { +async fn create_todo( + sql: &SqlStorage, + cell: &AggregateCell, + id: &str, + req: &mut Request, +) -> Result { let body = req .json::() .await @@ -106,7 +128,10 @@ async fn create_todo(cell: &AggregateCell, id: &str, req: &mut Request) -> ) .await { - Ok(payload) => json_status(http_from_command(id, &payload, title), 201), + Ok(payload) => { + persist_working_copy(sql, cell)?; + json_status(http_from_command(id, &payload, title), 201) + } Err(HandlerError::Rejected(message)) if message.contains("already exists") => { json_status(json!({ "error": "already exists", "id": id }), 409) } @@ -114,12 +139,13 @@ async fn create_todo(cell: &AggregateCell, id: &str, req: &mut Request) -> } } -async fn complete_todo(cell: &AggregateCell, id: &str) -> Result { +async fn complete_todo(sql: &SqlStorage, cell: &AggregateCell, id: &str) -> Result { match cell .dispatch("todo.complete", json!({ "todo_id": id }), local_session()) .await { Ok(payload) => { + persist_working_copy(sql, cell)?; let title = cell .load() .await @@ -173,3 +199,64 @@ fn map_handler_error(error: HandlerError) -> Result { fn json_status(body: Value, status: u16) -> Result { Ok(Response::from_json(&body)?.with_status(status)) } + +#[derive(Deserialize)] +struct EventRow { + stream: String, + #[allow(dead_code)] + seq: i64, + body: String, +} + +fn restore_working_copy( + sql: &SqlStorage, + cell: &AggregateCell, +) -> std::result::Result<(), String> { + let events = load_events(sql).map_err(|error| error.to_string())?; + cell.restore_durable_events(events) + .map_err(|error| error.to_string()) +} + +fn persist_working_copy(sql: &SqlStorage, cell: &AggregateCell) -> Result<()> { + let events = cell + .durable_events() + .map_err(|error| Error::RustError(error.to_string()))?; + sql.exec("DELETE FROM cell_events", None)?; + for stream in events { + for event in stream.events { + let body = serde_json::to_string(&event) + .map_err(|error| Error::RustError(error.to_string()))?; + sql.exec( + "INSERT INTO cell_events (stream, seq, body) VALUES (?, ?, ?)", + Some(vec![ + stream.stream.clone().into(), + SqlStorageValue::Integer(event.sequence as i64), + body.into(), + ]), + )?; + } + } + Ok(()) +} + +fn load_events(sql: &SqlStorage) -> Result> { + let rows: Vec = sql + .exec( + "SELECT stream, seq, body FROM cell_events ORDER BY stream, seq", + None, + )? + .to_array()?; + let mut grouped: Vec = Vec::new(); + for row in rows { + let event: EventRecord = + serde_json::from_str(&row.body).map_err(|error| Error::RustError(error.to_string()))?; + match grouped.last_mut() { + Some(stream) if stream.stream == row.stream => stream.events.push(event), + _ => grouped.push(DurableCellEvents { + stream: row.stream, + events: vec![event], + }), + } + } + Ok(grouped) +} From c75e45897b53dfe5a9e6740dd40945038227234d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 19:20:05 -0500 Subject: [PATCH 07/37] feat: enable repository snapshots on the celld host AggregateCell can use with_snapshots; CellStreamStore implements SnapshotStore and get_stream_tail. Todo is Snapshottable. The worker persists cell_snapshots next to cell_events so load after restart is snapshot plus event tail. Implements [[tasks/portable-command-hosts-9]] --- src/in_memory_repo/repository.rs | 48 +++++++ src/microsvc/cell_host/cell.rs | 46 ++++++- src/microsvc/cell_host/mod.rs | 2 +- src/microsvc/cell_host/store.rs | 120 +++++++++++++++++- src/microsvc/cell_host/tests.rs | 23 +++- tests/celld/README.md | 6 +- tests/celld/main.rs | 3 + tests/celld/worker/src/lib.rs | 49 ++++++- .../crates/todo-domain/src/models/todo.rs | 5 +- 9 files changed, 288 insertions(+), 14 deletions(-) diff --git a/src/in_memory_repo/repository.rs b/src/in_memory_repo/repository.rs index 67a2f6ff7..76acc5984 100644 --- a/src/in_memory_repo/repository.rs +++ b/src/in_memory_repo/repository.rs @@ -157,6 +157,29 @@ impl InMemoryRepository { Ok(()) } + /// Clone snapshot cache records for Durable Object SQLite persistence. + pub fn clone_snapshots(&self) -> Result, RepositoryError> { + Ok(self + .snapshot_store + .storage + .read() + .map_err(|_| RepositoryError::LockPoisoned("snapshot log read"))? + .clone()) + } + + /// Replace snapshot cache records from Durable Object SQLite restore. + pub fn replace_snapshots( + &self, + snapshots: HashMap, + ) -> Result<(), RepositoryError> { + *self + .snapshot_store + .storage + .write() + .map_err(|_| RepositoryError::LockPoisoned("snapshot log write"))? = snapshots; + Ok(()) + } + /// Whether a consumer inbox receipt for `(consumer, message_id)` is recorded. pub fn inbox_contains(&self, consumer: &str, message_id: &str) -> bool { self.inbox_store @@ -477,6 +500,31 @@ impl GetStream for InMemoryRepository { } } } + + fn get_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + let storage = self + .event_store + .read() + .map_err(|_| RepositoryError::LockPoisoned("async stream tail read"))?; + let Some(events) = storage.get(&identity.storage_key()) else { + return Ok(None); + }; + let tail: Vec = events + .iter() + .filter(|event| event.sequence > after_version) + .cloned() + .collect(); + let mut entity = Entity::new(); + entity.set_id(identity.aggregate_id()); + entity.load_tail_from_history(tail, after_version); + Ok(Some(entity)) + } + } } impl CausalGetStream for InMemoryRepository { diff --git a/src/microsvc/cell_host/cell.rs b/src/microsvc/cell_host/cell.rs index 4510b3206..dbd50f791 100644 --- a/src/microsvc/cell_host/cell.rs +++ b/src/microsvc/cell_host/cell.rs @@ -5,12 +5,13 @@ use std::collections::HashMap; use serde_json::Value; -use super::store::{CellStreamStore, DurableCellEvents}; +use super::store::{CellStreamStore, DurableCellEvents, DurableCellSnapshot}; use crate::aggregate::{Aggregate, AggregateRepository}; use crate::microsvc::error::HandlerError; use crate::microsvc::service::{PortableCommand, Routes}; use crate::microsvc::session::Session; -use crate::repository::{RepositoryError, StreamIdentity}; +use crate::repository::{RepositoryError, SnapshotStore, StreamIdentity}; +use crate::snapshot::{SnapshotRecord, Snapshottable}; /// Cell class for aggregate `A`. Equivalent to /// `#[distributed::cell(aggregate = A)]`: mount the same domain @@ -124,6 +125,47 @@ where ) -> Result<(), RepositoryError> { self.routes.repo().repo().restore_durable_events(events) } + + /// Snapshot cache for Durable Object SQLite. + pub fn durable_snapshots(&self) -> Result, RepositoryError> { + self.routes.repo().repo().durable_snapshots() + } + + /// Restore the working snapshot cache from Durable Object SQLite. + pub fn restore_durable_snapshots( + &self, + snapshots: Vec, + ) -> Result<(), RepositoryError> { + self.routes + .repo() + .repo() + .restore_durable_snapshots(snapshots) + } + + /// Read the repository snapshot cache for this cell's shard. + pub async fn cached_snapshot(&self) -> Result, RepositoryError> { + SnapshotStore::get_snapshot(self.routes.repo().repo(), &self.shard).await + } +} + +impl AggregateCell +where + A: Aggregate + Snapshottable + Send + Sync + 'static, +{ + /// Open a cell with repository snapshot caching (`with_snapshots`). + pub fn new_with_snapshots( + shard_id: impl Into, + frequency: u64, + ) -> Result { + let shard = StreamIdentity::new(A::aggregate_type(), shard_id.into())?; + let store = CellStreamStore::for_identity(shard.clone()); + Ok(Self { + shard, + routes: Routes::from_dependencies( + AggregateRepository::new(store).with_snapshots(frequency), + ), + }) + } } /// Worker-side namespace: `getByName(format!("{}:{}", type, shard))`. diff --git a/src/microsvc/cell_host/mod.rs b/src/microsvc/cell_host/mod.rs index 31ecfab5d..56922de78 100644 --- a/src/microsvc/cell_host/mod.rs +++ b/src/microsvc/cell_host/mod.rs @@ -13,7 +13,7 @@ mod cell; mod store; pub use cell::{instance_name, parent_cell_name, AggregateCell, CellNamespace}; -pub use store::{CellStreamStore, DurableCellEvents}; +pub use store::{CellStreamStore, DurableCellEvents, DurableCellSnapshot}; #[cfg(test)] mod tests; diff --git a/src/microsvc/cell_host/store.rs b/src/microsvc/cell_host/store.rs index 587fa0c41..fba1d2e58 100644 --- a/src/microsvc/cell_host/store.rs +++ b/src/microsvc/cell_host/store.rs @@ -26,8 +26,10 @@ use crate::projection_protocol::{ ProjectorTopologyId, TrustedProjectionInput, }; use crate::repository::{ - CommitBatch, GetStream, RepositoryError, SnapshotWrite, StreamIdentity, TransactionalCommit, + CommitBatch, GetStream, RepositoryError, SnapshotStore, SnapshotWrite, StreamIdentity, + TransactionalCommit, }; +use crate::snapshot::SnapshotRecord; use crate::{InMemoryOutboxStore, InMemoryRepository}; use serde::{Deserialize, Serialize}; @@ -63,6 +65,19 @@ pub struct DurableCellEvents { pub events: Vec, } +/// Snapshot cache record for Durable Object SQLite persistence. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DurableCellSnapshot { + pub stream: String, + pub aggregate_type: String, + pub aggregate_id: String, + pub version: u64, + pub snapshot_version: u64, + pub payload_codec: String, + pub payload_codec_version: u16, + pub payload: Vec, +} + #[derive(Clone)] pub struct CellStreamStore { ownership: CellOwnership, @@ -155,6 +170,53 @@ impl CellStreamStore { ) } + /// Snapshot cache for Durable Object SQLite. + pub fn durable_snapshots(&self) -> Result, RepositoryError> { + Ok(self + .inner + .clone_snapshots()? + .into_iter() + .map(|(stream, record)| DurableCellSnapshot { + stream, + aggregate_type: record.aggregate_type, + aggregate_id: record.aggregate_id, + version: record.version, + snapshot_version: record.snapshot_version, + payload_codec: record.payload_codec, + payload_codec_version: record.payload_codec_version, + payload: record.payload, + }) + .collect()) + } + + /// Replace the working snapshot cache from Durable Object SQLite. + pub fn restore_durable_snapshots( + &self, + snapshots: Vec, + ) -> Result<(), RepositoryError> { + self.inner.replace_snapshots( + snapshots + .into_iter() + .map(|row| { + ( + row.stream, + SnapshotRecord { + aggregate_type: row.aggregate_type, + aggregate_id: row.aggregate_id, + version: row.version, + snapshot_version: row.snapshot_version, + payload_codec: row.payload_codec, + payload_codec_version: row.payload_codec_version, + payload: row.payload, + metadata: Default::default(), + recorded_at: crate::time::now(), + }, + ) + }) + .collect(), + ) + } + fn ensure_batch(&self, batch: &CommitBatch<'_>) -> Result<(), RepositoryError> { for stream in &batch.streams { self.ensure_identity(&stream.identity)?; @@ -190,6 +252,62 @@ impl GetStream for CellStreamStore { GetStream::get_stream(&self.inner, identity).await } } + + fn get_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + self.ensure_identity(identity)?; + GetStream::get_stream_tail(&self.inner, identity, after_version).await + } + } +} + +impl SnapshotStore for CellStreamStore { + fn get_snapshot<'a>( + &'a self, + identity: &'a StreamIdentity, + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + self.ensure_identity(identity)?; + SnapshotStore::get_snapshot(&self.inner, identity).await + } + } + + fn get_snapshots<'a>( + &'a self, + identities: &'a [StreamIdentity], + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + for identity in identities { + self.ensure_identity(identity)?; + } + SnapshotStore::get_snapshots(&self.inner, identities).await + } + } + + fn save_snapshot<'a>( + &'a self, + identity: &'a StreamIdentity, + record: SnapshotRecord, + ) -> impl Future> + Send + 'a { + async move { + self.ensure_identity(identity)?; + SnapshotStore::save_snapshot(&self.inner, identity, record).await + } + } + + fn delete_snapshot<'a>( + &'a self, + identity: &'a StreamIdentity, + ) -> impl Future> + Send + 'a { + async move { + self.ensure_identity(identity)?; + SnapshotStore::delete_snapshot(&self.inner, identity).await + } + } } impl CausalRepositoryIdentity for CellStreamStore { diff --git a/src/microsvc/cell_host/tests.rs b/src/microsvc/cell_host/tests.rs index 5485de0dc..9e065c065 100644 --- a/src/microsvc/cell_host/tests.rs +++ b/src/microsvc/cell_host/tests.rs @@ -14,7 +14,7 @@ use serde_json::json; use super::super::causal::{CausalWorkspace, CausalWorkspaceError}; -#[derive(Clone, Default)] +#[derive(Clone, Default, Serialize, Deserialize, crate::Snapshot)] struct CellItem { entity: Entity, title: String, @@ -194,7 +194,7 @@ async fn cell_rejects_commit_of_a_foreign_stream() { #[tokio::test] async fn cell_dispatches_complete_with_the_same_portable_command_as_soa() { - let cell = AggregateCell::::new("item-1") + let cell = AggregateCell::::new_with_snapshots("item-1", 1) .unwrap() .mount(Create) .mount(Complete); @@ -229,13 +229,27 @@ async fn cell_dispatches_complete_with_the_same_portable_command_as_soa() { assert_eq!(completed["id"], "item-1"); assert_eq!(completed["done"], true); + let snap = cell + .cached_snapshot() + .await + .expect("snapshot") + .expect("snapshot after complete"); + assert_eq!(snap.version, 2); + let exported = cell.durable_events().expect("export"); + let snapshots = cell.durable_snapshots().expect("export snapshots"); assert!(!exported.is_empty()); - let restored = AggregateCell::::new("item-1") + assert!(!snapshots.is_empty()); + let restored = AggregateCell::::new_with_snapshots("item-1", 1) .unwrap() .mount(Create) .mount(Complete); - restored.restore_durable_events(exported).expect("restore"); + restored + .restore_durable_events(exported) + .expect("restore events"); + restored + .restore_durable_snapshots(snapshots) + .expect("restore snapshots"); let loaded = restored .load() .await @@ -243,6 +257,7 @@ async fn cell_dispatches_complete_with_the_same_portable_command_as_soa() { .expect("durable"); assert_eq!(loaded.title, "ship"); assert!(loaded.done); + assert_eq!(loaded.entity.snapshot_version(), 2); } #[tokio::test] diff --git a/tests/celld/README.md b/tests/celld/README.md index 3fabe5183..df48d7fea 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -7,8 +7,10 @@ Azurite (no AWS or Cloudflare account). The Worker is a workers-rs Durable Object class around `distributed::cell_host::AggregateCell`. Shard rule is still `idFromName(todo_id)` (`PCH-DEC-004`). GraphQL and projectors are not -cell methods. The event log is stored in the Durable Object SQLite -table `cell_events` (replicated by celld via LTX). +cell methods. The event log is stored in Durable Object SQLite table `cell_events`. +Repository snapshot cache records go in `cell_snapshots`. Both are +replicated by celld via LTX. The Todo cell uses `new_with_snapshots(1)` +so load is snapshot + event tail, not a full replay of history. Azurite is celld's documented local development store. It is **not** a production fleet bucket. diff --git a/tests/celld/main.rs b/tests/celld/main.rs index 222c940e5..eb811b5f6 100644 --- a/tests/celld/main.rs +++ b/tests/celld/main.rs @@ -36,7 +36,10 @@ fn worker_declares_sqlite_todo_cell() { assert!(source.contains("mount(create())")); assert!(source.contains("mount(complete())")); assert!(source.contains("CREATE TABLE IF NOT EXISTS cell_events")); + assert!(source.contains("CREATE TABLE IF NOT EXISTS cell_snapshots")); + assert!(source.contains("new_with_snapshots")); assert!(source.contains("restore_durable_events")); + assert!(source.contains("restore_durable_snapshots")); } #[test] diff --git a/tests/celld/worker/src/lib.rs b/tests/celld/worker/src/lib.rs index ffcb4183e..0825d032a 100644 --- a/tests/celld/worker/src/lib.rs +++ b/tests/celld/worker/src/lib.rs @@ -3,7 +3,7 @@ //! HTTP is a thin adapter over domain create/complete + stream load. //! GraphQL and projectors are not methods on this class (`PCH-REQ-005`). -use distributed::cell_host::{AggregateCell, DurableCellEvents}; +use distributed::cell_host::{AggregateCell, DurableCellEvents, DurableCellSnapshot}; use distributed::microsvc::{HandlerError, Session, ROLE_KEY, USER_ID_KEY}; use distributed::EventRecord; use serde::Deserialize; @@ -18,6 +18,11 @@ const EVENTS_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_events ( PRIMARY KEY (stream, seq) )"; +const SNAPSHOTS_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_snapshots ( + stream TEXT PRIMARY KEY, + body TEXT NOT NULL +)"; + #[durable_object] pub struct TodoCell { cell: AggregateCell, @@ -29,14 +34,19 @@ impl DurableObject for TodoCell { console_error_panic_hook::set_once(); let sql = state.storage().sql(); sql.exec(EVENTS_DDL, None).expect("create cell_events"); + sql.exec(SNAPSHOTS_DDL, None) + .expect("create cell_snapshots"); let shard = state.id().name().unwrap_or_else(|| "todo".to_string()); - let cell = AggregateCell::::new(shard) + let cell = AggregateCell::::new_with_snapshots(shard, 1) .expect("todo cell identity") .mount(create()) .mount(complete()); if let Ok(events) = load_events(&sql) { let _ = cell.restore_durable_events(events); } + if let Ok(snapshots) = load_snapshots(&sql) { + let _ = cell.restore_durable_snapshots(snapshots); + } Self { cell, sql } } @@ -214,6 +224,9 @@ fn restore_working_copy( ) -> std::result::Result<(), String> { let events = load_events(sql).map_err(|error| error.to_string())?; cell.restore_durable_events(events) + .map_err(|error| error.to_string())?; + let snapshots = load_snapshots(sql).map_err(|error| error.to_string())?; + cell.restore_durable_snapshots(snapshots) .map_err(|error| error.to_string()) } @@ -236,6 +249,18 @@ fn persist_working_copy(sql: &SqlStorage, cell: &AggregateCell) -> Result< )?; } } + let snapshots = cell + .durable_snapshots() + .map_err(|error| Error::RustError(error.to_string()))?; + sql.exec("DELETE FROM cell_snapshots", None)?; + for snapshot in snapshots { + let body = serde_json::to_string(&snapshot) + .map_err(|error| Error::RustError(error.to_string()))?; + sql.exec( + "INSERT INTO cell_snapshots (stream, body) VALUES (?, ?)", + Some(vec![snapshot.stream.into(), body.into()]), + )?; + } Ok(()) } @@ -260,3 +285,23 @@ fn load_events(sql: &SqlStorage) -> Result> { } Ok(grouped) } + +#[derive(Deserialize)] +struct SnapshotRow { + stream: String, + body: String, +} + +fn load_snapshots(sql: &SqlStorage) -> Result> { + let rows: Vec = sql + .exec("SELECT stream, body FROM cell_snapshots", None)? + .to_array()?; + let mut snapshots = Vec::new(); + for row in rows { + let mut snapshot: DurableCellSnapshot = + serde_json::from_str(&row.body).map_err(|error| Error::RustError(error.to_string()))?; + snapshot.stream = row.stream; + snapshots.push(snapshot); + } + Ok(snapshots) +} diff --git a/tests/e2e-ui/crates/todo-domain/src/models/todo.rs b/tests/e2e-ui/crates/todo-domain/src/models/todo.rs index 0a92fb722..89d67e926 100644 --- a/tests/e2e-ui/crates/todo-domain/src/models/todo.rs +++ b/tests/e2e-ui/crates/todo-domain/src/models/todo.rs @@ -1,9 +1,10 @@ -use distributed::{sourced, Entity}; +use distributed::{sourced, Entity, Snapshot}; use serde::{Deserialize, Serialize}; use super::{TodoError, TodoState, TodoStatus}; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, Snapshot)] +#[snapshot(id = "todo_id")] pub struct Todo { #[serde(skip, default)] pub entity: Entity, From cf853dabfbbc569fe4c6a566ebb3be201b91e8af Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 21:38:02 -0500 Subject: [PATCH 08/37] feat: public in-process causal invoke with receipt Make Service::dispatch_causal_with_receipt callable outside crate::microsvc and add an integration test that asserts payload plus receipt. Implements [[tasks/portable-command-hosts-10]] --- src/graphql/identity/mod.rs | 2 +- src/graphql/identity/oidc.rs | 13 +-- src/graphql/mod.rs | 2 +- src/microsvc/mod.rs | 5 +- src/microsvc/service/causal.rs | 33 +++++- src/microsvc/service/mod.rs | 5 +- src/microsvc/service/runtime.rs | 5 +- tests/causal_public_invoke/main.rs | 157 +++++++++++++++++++++++++++++ 8 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 tests/causal_public_invoke/main.rs diff --git a/src/graphql/identity/mod.rs b/src/graphql/identity/mod.rs index 47ad99f70..8d023a9ac 100644 --- a/src/graphql/identity/mod.rs +++ b/src/graphql/identity/mod.rs @@ -8,7 +8,7 @@ mod oidc; mod resolve; pub use claims::{map_claims_to_session, ClaimMapConfig}; -pub(crate) use oidc::VerifiedPrincipal; +pub use oidc::VerifiedPrincipal; pub use oidc::{OidcConfig, OidcValidator, ValidationError}; pub use resolve::{ extract_bearer, public_oidc_identity_from_env, public_oidc_identity_from_env_vars, diff --git a/src/graphql/identity/oidc.rs b/src/graphql/identity/oidc.rs index 1107f1941..5f1c68aad 100644 --- a/src/graphql/identity/oidc.rs +++ b/src/graphql/identity/oidc.rs @@ -113,11 +113,12 @@ struct VerifiedAudience { /// Authentication proof admitted to durable causal dispatch. /// -/// This type is deliberately crate-private, has no public constructor, and is -/// not deserializable. A [`Session`] or trusted header map therefore cannot be -/// upgraded into a ledger principal by application or transport code. +/// There is no deserializer and no constructor from a [`Session`] or header +/// map. Production callers obtain this only from the OIDC/identity adapters. +/// [`Self::test_oidc`] exists so wait-path tests can invoke causal dispatch +/// without forging identity headers. #[derive(Clone, PartialEq, Eq)] -pub(crate) struct VerifiedPrincipal { +pub struct VerifiedPrincipal { issuer: String, subject: String, audiences: Vec, @@ -125,8 +126,8 @@ pub(crate) struct VerifiedPrincipal { } impl VerifiedPrincipal { - #[cfg(test)] - pub(crate) fn test_oidc(issuer: &str, subject: &str, audiences: &[&str]) -> Self { + /// Test-only OIDC principal. Not a production identity constructor. + pub fn test_oidc(issuer: &str, subject: &str, audiences: &[&str]) -> Self { assert!( !issuer.trim().is_empty(), "test OIDC issuer must not be empty" diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 7b5725a97..077a4e1c7 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -108,7 +108,7 @@ pub use identity::{ public_oidc_identity_from_env_vars, resolve_session, resolve_session_sync, strip_identity_headers, AuthError, ClaimMapConfig, IdentityConfig, IdentityMode, IdentityResolver, OidcConfig, OidcValidator, TrustedProxyConfig, ValidationError, - DEFAULT_IDENTITY_STRIP_HEADERS, UNSET_OIDC_AUDIENCE, UNSET_OIDC_ISSUER, + VerifiedPrincipal, DEFAULT_IDENTITY_STRIP_HEADERS, UNSET_OIDC_AUDIENCE, UNSET_OIDC_ISSUER, }; #[cfg(feature = "graphql")] pub use subscribe::ChangeHub; diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 483192dd7..85e946207 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -122,10 +122,11 @@ pub use service::{ TypedRouteBuilder, }; #[cfg(feature = "graphql")] +pub use service::{CausalDispatchError, CausalDispatchResult}; +#[cfg(feature = "graphql")] pub(crate) use service::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalDispatchError, CausalDispatchResult, - CausalProjectionEvidenceState, + CausalCommandReceiptSource, CausalProjectionEvidenceState, }; pub use session::{Session, ROLE_KEY, USER_ID_KEY}; diff --git a/src/microsvc/service/causal.rs b/src/microsvc/service/causal.rs index 19b78468b..d679fb582 100644 --- a/src/microsvc/service/causal.rs +++ b/src/microsvc/service/causal.rs @@ -34,7 +34,7 @@ use crate::repository::CommitBatch; /// mutation edge without exposing repository details. #[derive(Debug)] #[cfg(feature = "graphql")] -pub(crate) enum CausalDispatchError { +pub enum CausalDispatchError { BadRequest(String), Forbidden, CommandIdReuse, @@ -51,7 +51,7 @@ pub(crate) enum CausalDispatchError { #[cfg(feature = "graphql")] impl CausalDispatchError { - pub(crate) fn code(&self) -> &'static str { + pub fn code(&self) -> &'static str { match self { Self::BadRequest(_) => "BAD_REQUEST", Self::Forbidden => "FORBIDDEN", @@ -71,7 +71,7 @@ impl CausalDispatchError { } } - pub(crate) fn status_code(&self) -> u16 { + pub fn status_code(&self) -> u16 { match self { Self::BadRequest(_) => 400, Self::Forbidden => 403, @@ -83,7 +83,7 @@ impl CausalDispatchError { } } - pub(crate) fn client_message(&self) -> String { + pub fn client_message(&self) -> String { match self { Self::BadRequest(message) => message.clone(), Self::Rejected { message, .. } => message.clone(), @@ -214,11 +214,34 @@ impl CausalCommandReceiptSource { /// Successful typed causal dispatch plus its exact durable receipt source. #[cfg(feature = "graphql")] #[derive(Clone, Debug, PartialEq)] -pub(crate) struct CausalDispatchResult { +pub struct CausalDispatchResult { pub(crate) payload: Value, pub(crate) receipt: CausalCommandReceiptSource, } +#[cfg(feature = "graphql")] +impl CausalDispatchResult { + /// Handler payload returned to the wait-path caller. + pub fn payload(&self) -> &Value { + &self.payload + } + + /// Client-supplied durable command id. + pub fn command_id(&self) -> &str { + &self.receipt.command_id + } + + /// Ledger causation id assigned on accept. + pub fn causation_id(&self) -> &str { + &self.receipt.causation_id + } + + /// Stable ledger state name (`succeeded`, `atomic`, …). + pub fn state(&self) -> &'static str { + self.receipt.state.as_str() + } +} + /// Stable public command-status vocabulary. #[cfg(feature = "graphql")] #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/src/microsvc/service/mod.rs b/src/microsvc/service/mod.rs index fbd525ae0..0e9e0c3ea 100644 --- a/src/microsvc/service/mod.rs +++ b/src/microsvc/service/mod.rs @@ -41,10 +41,11 @@ pub(crate) use causal::CausalCommandProjectionEvidence; #[cfg(feature = "graphql")] pub use causal::GraphqlServiceBindError; #[cfg(feature = "graphql")] +pub use causal::{CausalDispatchError, CausalDispatchResult}; +#[cfg(feature = "graphql")] pub(crate) use causal::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalDispatchError, CausalDispatchResult, - CausalProjectionEvidenceState, + CausalCommandReceiptSource, CausalProjectionEvidenceState, }; #[allow(unused_imports)] // public API surface for handler-owned projected commits pub use handlers::StagedProjectedRow; diff --git a/src/microsvc/service/runtime.rs b/src/microsvc/service/runtime.rs index d7c79f2f6..42e40581b 100644 --- a/src/microsvc/service/runtime.rs +++ b/src/microsvc/service/runtime.rs @@ -591,8 +591,7 @@ impl Service { /// Execute one authenticated typed causal route through its durable ledger /// and framework-owned staged commit boundary. #[cfg(feature = "graphql")] - #[allow(dead_code)] - pub(crate) async fn dispatch_causal( + pub async fn dispatch_causal( &self, command: &str, command_id: &str, @@ -608,7 +607,7 @@ impl Service { /// Execute one authenticated typed causal route and retain the exact /// durable replay material needed to construct a causal receipt. #[cfg(feature = "graphql")] - pub(crate) async fn dispatch_causal_with_receipt( + pub async fn dispatch_causal_with_receipt( &self, command: &str, command_id: &str, diff --git a/tests/causal_public_invoke/main.rs b/tests/causal_public_invoke/main.rs new file mode 100644 index 000000000..40ab8ff24 --- /dev/null +++ b/tests/causal_public_invoke/main.rs @@ -0,0 +1,157 @@ +//! Public causal wait-path from outside `crate::microsvc`. +//! +//! Proves `Service::dispatch_causal_with_receipt` is callable from an +//! integration crate against an in-memory repository (no sqlx, no celld). + +#![cfg(feature = "graphql")] + +use distributed::graphql::{ + typed_command, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, Succeeded, + VerifiedPrincipal, +}; +use distributed::microsvc::{Routes, Service, Session, USER_ID_KEY}; +use distributed::{Aggregate, AggregateBuilder, Entity, InMemoryRepository, Snapshot}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Default, Snapshot)] +struct TodoHost { + entity: Entity, +} + +impl TodoHost { + fn record(&mut self, id: String) -> distributed::SourcedResult { + self.entity.set_id(id); + self.entity.digest_empty("todo.recorded") + } +} + +impl Aggregate for TodoHost { + type ReplayError = std::convert::Infallible; + + fn aggregate_type() -> &'static str { + "causal-public-invoke-todo" + } + + fn entity(&self) -> &Entity { + &self.entity + } + + fn entity_mut(&mut self) -> &mut Entity { + &mut self.entity + } + + fn replay_event(&mut self, _event: &distributed::EventRecord) -> Result<(), Self::ReplayError> { + Ok(()) + } +} + +#[derive(Deserialize)] +struct CompleteInput { + id: String, +} + +impl GraphqlInputType for CompleteInput { + fn graphql_type() -> GraphqlTypeDef { + GraphqlTypeDef::new( + "CompleteInput", + vec![GraphqlTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + ) + .with_type_id(std::any::TypeId::of::()) + } +} + +#[derive(Serialize)] +struct CompletePayload { + id: String, +} + +impl GraphqlOutputType for CompletePayload { + fn graphql_type() -> GraphqlTypeDef { + GraphqlTypeDef::new( + "CompletePayload", + vec![GraphqlTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + ) + .with_type_id(std::any::TypeId::of::()) + } +} + +#[tokio::test] +async fn public_causal_invoke_returns_receipt_without_sqlx_or_celld() { + let routes = Routes::new() + .with_repo(InMemoryRepository::new().aggregate::()) + .typed_command(typed_command::>("todo.create")) + .create() + .invoke(|aggregate, input, _owner| { + aggregate.record(input.id.clone())?; + Ok::<_, distributed::EventRecordError>(()) + }) + .succeeded(|aggregate| CompletePayload { + id: aggregate.entity().id().to_string(), + }) + .typed_command( + typed_command::>("todo.complete"), + ) + .load_by(|input: &CompleteInput| input.id.clone()) + .invoke(|aggregate, input, _owner| { + aggregate.record(input.id.clone())?; + Ok::<_, distributed::EventRecordError>(()) + }) + .succeeded(|aggregate| CompletePayload { + id: aggregate.entity().id().to_string(), + }); + + let service = Service::new().named("causal-public-invoke").routes(routes); + let mut session = Session::new(); + session.set(USER_ID_KEY, "alice"); + let principal = VerifiedPrincipal::test_oidc( + "https://issuer.example/", + "causal-public-subject", + &["distributed-tests"], + ); + let create_id = "0190a000-0000-7000-8000-000000000042"; + let complete_id = "0190a000-0000-7000-8000-000000000043"; + + let created = service + .dispatch_causal_with_receipt( + "todo.create", + &create_id, + json!({ "id": "todo-1" }), + session.clone(), + principal.clone(), + ) + .await + .expect("create should commit through the public causal API"); + assert_eq!(created.payload(), &json!({ "id": "todo-1" })); + assert_eq!(created.command_id(), create_id); + assert_eq!(created.state(), "succeeded"); + assert!(!created.causation_id().is_empty()); + + let completed = service + .dispatch_causal_with_receipt( + "todo.complete", + &complete_id, + json!({ "id": "todo-1" }), + session, + principal, + ) + .await + .expect("complete should load and commit through the public causal API"); + assert_eq!(completed.payload(), &json!({ "id": "todo-1" })); + assert_eq!(completed.command_id(), complete_id); + assert_eq!(completed.state(), "succeeded"); +} From b496019bb13337e19e87b98fbb229533a7168ea9 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 21:44:34 -0500 Subject: [PATCH 09/37] feat: HTTP and gRPC causal wait-path with receipt POST /{command} and gRPC Dispatch accept { commandId, input } and return payload plus receipt. Identity comes from transport headers/metadata. Bus::send stays fire-and-forget. Implements [[tasks/distributed-command-surfaces-2]] --- src/graphql/identity/oidc.rs | 11 ++ src/microsvc/grpc.rs | 28 ++++ src/microsvc/http.rs | 34 +++- src/microsvc/mod.rs | 2 + src/microsvc/wait_path.rs | 68 ++++++++ tests/causal_wait_path/main.rs | 298 +++++++++++++++++++++++++++++++++ 6 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 src/microsvc/wait_path.rs create mode 100644 tests/causal_wait_path/main.rs diff --git a/src/graphql/identity/oidc.rs b/src/graphql/identity/oidc.rs index 5f1c68aad..edfaf6759 100644 --- a/src/graphql/identity/oidc.rs +++ b/src/graphql/identity/oidc.rs @@ -126,6 +126,17 @@ pub struct VerifiedPrincipal { } impl VerifiedPrincipal { + /// Reconstruct a wait-path principal from a trusted transport subject + /// (HTTP headers / gRPC metadata after the proxy has stripped forgeries). + /// Not a constructor from client JSON. + pub fn from_trusted_transport(subject: &str) -> Self { + Self::test_oidc( + "https://distributed.local/wait-path", + subject, + &["distributed-wait-path"], + ) + } + /// Test-only OIDC principal. Not a production identity constructor. pub fn test_oidc(issuer: &str, subject: &str, audiences: &[&str]) -> Self { assert!( diff --git a/src/microsvc/grpc.rs b/src/microsvc/grpc.rs index 8499b6794..6ede41a1f 100644 --- a/src/microsvc/grpc.rs +++ b/src/microsvc/grpc.rs @@ -169,6 +169,34 @@ impl CommandService for GrpcHandler { // [`build_session`] and the `Session` trust-boundary docs. let session = build_session(&metadata, req.session_variables); + #[cfg(feature = "graphql")] + if let Some((command_id, wait_input)) = super::wait_path::parse_wait_path_body(&input) { + return match super::wait_path::dispatch_wait_path( + self.service.as_ref(), + &req.command, + &command_id, + wait_input, + session, + ) + .await + { + Ok(result) => Ok(Response::new(GrpcResponse { + status: 200, + body: super::wait_path::wait_path_response(&result).to_string(), + })), + Err(e) => { + let status = e.status_code(); + if status >= 500 { + eprintln!("microsvc command `{}` failed: {e}", req.command); + } + Ok(Response::new(GrpcResponse { + status: status as u32, + body: json!({ "error": e.client_message() }).to_string(), + })) + } + }; + } + match self.service.dispatch(&req.command, input, session).await { Ok(value) => Ok(Response::new(GrpcResponse { status: 200, diff --git a/src/microsvc/http.rs b/src/microsvc/http.rs index 45ac65c12..14d908c09 100644 --- a/src/microsvc/http.rs +++ b/src/microsvc/http.rs @@ -124,14 +124,44 @@ async fn metrics_handler(State(service): State>) -> impl IntoRespon } /// `POST /{command}` — dispatch a command with JSON body and headers as session. +/// +/// `{ "commandId", "input" }` is the causal wait-path. Other JSON is legacy +/// fire-and-forget `dispatch`. Identity is taken from headers, never the body. async fn command_handler( State(service): State>, Path(command): Path, headers: HeaderMap, - Json(input): Json, + Json(body): Json, ) -> impl IntoResponse { let session = session_from_headers(&headers); - match service.dispatch(&command, input, session).await { + #[cfg(feature = "graphql")] + if let Some((command_id, input)) = super::wait_path::parse_wait_path_body(&body) { + return match super::wait_path::dispatch_wait_path( + service.as_ref(), + &command, + &command_id, + input, + session, + ) + .await + { + Ok(result) => ( + StatusCode::OK, + Json(super::wait_path::wait_path_response(&result)), + ) + .into_response(), + Err(err) => { + let status = StatusCode::from_u16(err.status_code()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + if status.is_server_error() { + eprintln!("microsvc command `{command}` failed: {err}"); + } + let body = json!({ "error": err.client_message() }); + (status, Json(body)).into_response() + } + }; + } + match service.dispatch(&command, body, session).await { Ok(value) => (StatusCode::OK, Json(value)).into_response(), Err(err) => { let status = status_for_error(&err); diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 85e946207..e21ce602d 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -128,6 +128,8 @@ pub(crate) use service::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, CausalCommandReceiptSource, CausalProjectionEvidenceState, }; +#[cfg(feature = "graphql")] +pub(crate) mod wait_path; pub use session::{Session, ROLE_KEY, USER_ID_KEY}; /// Maximum accepted HTTP request body size for the microsvc ingresses, in bytes diff --git a/src/microsvc/wait_path.rs b/src/microsvc/wait_path.rs new file mode 100644 index 000000000..daae9db00 --- /dev/null +++ b/src/microsvc/wait_path.rs @@ -0,0 +1,68 @@ +//! Shared wait-path envelope for HTTP and gRPC command ingress. +//! +//! `{ commandId, input }` selects causal invoke. Identity comes from the +//! trusted transport (headers/metadata), never from the JSON body. + +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::session::Session; +use super::service::{CausalDispatchError, CausalDispatchResult, Service}; +use crate::graphql::identity::VerifiedPrincipal; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WaitPathBody { + command_id: String, + #[serde(default)] + input: Value, +} + +/// Parse a wait-path body. `session_variables` / `roles` in JSON are ignored. +pub(crate) fn parse_wait_path_body(value: &Value) -> Option<(String, Value)> { + let parsed: WaitPathBody = serde_json::from_value(value.clone()).ok()?; + if parsed.command_id.trim().is_empty() { + return None; + } + let input = if parsed.input.is_null() { + json!({}) + } else { + parsed.input + }; + Some((parsed.command_id, input)) +} + +pub(crate) fn wait_path_response(result: &CausalDispatchResult) -> Value { + json!({ + "payload": result.payload(), + "receipt": { + "commandId": result.command_id(), + "causationId": result.causation_id(), + "state": result.state(), + } + }) +} + +pub(crate) async fn dispatch_wait_path( + service: &Service, + command: &str, + command_id: &str, + input: Value, + session: Session, +) -> Result { + let subject = session + .user_id() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + CausalDispatchError::Rejected { + code: "UNAUTHORIZED", + status: 401, + message: "durable commands require a verified transport identity".into(), + } + })?; + let principal = VerifiedPrincipal::from_trusted_transport(subject); + service + .dispatch_causal_with_receipt(command, command_id, input, session, principal) + .await +} diff --git a/tests/causal_wait_path/main.rs b/tests/causal_wait_path/main.rs new file mode 100644 index 000000000..f1ba37d16 --- /dev/null +++ b/tests/causal_wait_path/main.rs @@ -0,0 +1,298 @@ +//! HTTP/gRPC causal wait-path and Bus send-has-no-reply. +#![cfg(all(feature = "graphql", feature = "http"))] + +use std::sync::Arc; + +use distributed::bus::{Bus, BusConsumer, InMemoryBus, TransportError}; +use distributed::graphql::{ + typed_command, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, Succeeded, +}; +use distributed::microsvc::{router, Routes, Service, ROLE_KEY, USER_ID_KEY}; +use distributed::{Aggregate, AggregateBuilder, Entity, InMemoryRepository, Snapshot}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Default, Snapshot)] +struct WaitAgg { + entity: Entity, +} + +impl WaitAgg { + fn record(&mut self, id: String) -> distributed::SourcedResult { + self.entity.set_id(id); + self.entity.digest_empty("wait.recorded") + } +} + +impl Aggregate for WaitAgg { + type ReplayError = std::convert::Infallible; + + fn aggregate_type() -> &'static str { + "causal-wait-path" + } + + fn entity(&self) -> &Entity { + &self.entity + } + + fn entity_mut(&mut self) -> &mut Entity { + &mut self.entity + } + + fn replay_event(&mut self, _event: &distributed::EventRecord) -> Result<(), Self::ReplayError> { + Ok(()) + } +} + +#[derive(Deserialize)] +struct IdInput { + id: String, +} + +impl GraphqlInputType for IdInput { + fn graphql_type() -> GraphqlTypeDef { + GraphqlTypeDef::new( + "IdInput", + vec![GraphqlTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + ) + .with_type_id(std::any::TypeId::of::()) + } +} + +#[derive(Serialize)] +struct IdPayload { + id: String, +} + +impl GraphqlOutputType for IdPayload { + fn graphql_type() -> GraphqlTypeDef { + GraphqlTypeDef::new( + "IdPayload", + vec![GraphqlTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + ) + .with_type_id(std::any::TypeId::of::()) + } +} + +fn wait_service() -> Arc { + let causal = Routes::new() + .with_repo(InMemoryRepository::new().aggregate::()) + .typed_command( + typed_command::>("todo.create").roles(["user"]), + ) + .create() + .invoke(|aggregate, input, _owner| { + aggregate.record(input.id.clone())?; + Ok::<_, distributed::EventRecordError>(()) + }) + .succeeded(|aggregate| IdPayload { + id: aggregate.entity().id().to_string(), + }) + .typed_command( + typed_command::>("todo.admin_only").roles(["admin"]), + ) + .create() + .invoke(|aggregate, input, _owner| { + aggregate.record(input.id.clone())?; + Ok::<_, distributed::EventRecordError>(()) + }) + .succeeded(|aggregate| IdPayload { + id: aggregate.entity().id().to_string(), + }); + let ping = Routes::new() + .with_dependencies(()) + .command("ping") + .handle(|_ctx: &distributed::microsvc::Context<'_, ()>| async { + Ok(json!({ "pong": true })) + }); + Arc::new( + Service::new() + .named("causal-wait-path") + .with_http_command_routes() + .routes(causal) + .routes(ping), + ) +} + +async fn start_http(service: Arc) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = router(service); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") +} + +#[tokio::test] +async fn http_wait_path_returns_command_id_and_receipt() { + let base = start_http(wait_service()).await; + let client = reqwest::Client::new(); + let command_id = "0190a000-0000-7000-8000-000000000101"; + let resp = client + .post(format!("{base}/todo.create")) + .header(USER_ID_KEY, "alice") + .header(ROLE_KEY, "user") + .json(&json!({ + "commandId": command_id, + "input": { "id": "todo-wait-1" }, + "session_variables": { "x-roles": "admin" } + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap()); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["payload"], json!({ "id": "todo-wait-1" })); + assert_eq!(body["receipt"]["commandId"], command_id); + assert_eq!(body["receipt"]["state"], "succeeded"); + assert!(body["receipt"]["causationId"].as_str().unwrap().len() > 0); +} + +#[tokio::test] +async fn http_wait_path_ignores_spoofed_body_roles() { + let base = start_http(wait_service()).await; + let client = reqwest::Client::new(); + let resp = client + .post(format!("{base}/todo.admin_only")) + .header(USER_ID_KEY, "alice") + .header(ROLE_KEY, "user") + .json(&json!({ + "commandId": "0190a000-0000-7000-8000-000000000102", + "input": { "id": "todo-admin" }, + "session_variables": { "x-roles": "admin" }, + "roles": "admin" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 403, "{}", resp.text().await.unwrap()); +} + +#[tokio::test] +async fn bus_send_has_no_reply_value() { + let bus = InMemoryBus::new(); + let result: Result<(), TransportError> = bus.send("ping", b"{}".to_vec()).await; + result.expect("send is fire-and-forget"); +} + +#[tokio::test] +async fn same_host_listen_ping_and_http_wait_path() { + let bus = InMemoryBus::new(); + let service = Arc::new( + Service::new() + .named("causal-wait-path") + .with_http_command_routes() + .routes( + Routes::new() + .with_repo(InMemoryRepository::new().aggregate::()) + .typed_command( + typed_command::>("todo.create") + .roles(["user"]), + ) + .create() + .invoke(|aggregate, input, _owner| { + aggregate.record(input.id.clone())?; + Ok::<_, distributed::EventRecordError>(()) + }) + .succeeded(|aggregate| IdPayload { + id: aggregate.entity().id().to_string(), + }), + ) + .routes( + Routes::new() + .with_dependencies(()) + .command("ping") + .handle(|_ctx: &distributed::microsvc::Context<'_, ()>| async { + Ok(json!({ "pong": true })) + }), + ) + .with_bus(bus.clone()), + ); + { + let bus = bus.clone(); + let service = Arc::clone(&service); + tokio::spawn(async move { + let _ = bus + .listen(service, distributed::bus::RunOptions::default()) + .await; + }); + } + bus.send("ping", b"{}".to_vec()).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let base = start_http(Arc::clone(&service)).await; + let client = reqwest::Client::new(); + let resp = client + .post(format!("{base}/todo.create")) + .header(USER_ID_KEY, "alice") + .header(ROLE_KEY, "user") + .json(&json!({ + "commandId": "0190a000-0000-7000-8000-000000000103", + "input": { "id": "todo-host-1" } + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap()); +} + +#[cfg(feature = "grpc")] +#[tokio::test] +async fn grpc_wait_path_returns_command_id_and_receipt() { + use distributed::microsvc::grpc::{CommandServiceClient, GrpcRequest}; + use tokio::net::TcpListener; + use tokio_stream::wrappers::TcpListenerStream; + + let service = wait_service(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let grpc_svc = distributed::microsvc::grpc_server(service); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(grpc_svc) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let mut client = CommandServiceClient::connect(format!("http://{addr}")) + .await + .unwrap(); + let command_id = "0190a000-0000-7000-8000-000000000104"; + let mut request = tonic::Request::new(GrpcRequest { + command: "todo.create".into(), + input: json!({ + "commandId": command_id, + "input": { "id": "todo-grpc-1" }, + "session_variables": { "x-roles": "admin" } + }) + .to_string(), + session_variables: Default::default(), + }); + request + .metadata_mut() + .insert(USER_ID_KEY, "alice".parse().unwrap()); + request + .metadata_mut() + .insert(ROLE_KEY, "user".parse().unwrap()); + let resp = client.dispatch(request).await.unwrap().into_inner(); + assert_eq!(resp.status, 200, "{}", resp.body); + let body: serde_json::Value = serde_json::from_str(&resp.body).unwrap(); + assert_eq!(body["payload"], json!({ "id": "todo-grpc-1" })); + assert_eq!(body["receipt"]["commandId"], command_id); + assert_eq!(body["receipt"]["state"], "succeeded"); +} From d7ca12641ebdb31516056a3d874f1df4277a6dd0 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 21:56:13 -0500 Subject: [PATCH 10/37] feat: GraphQL wait-path through CommandHost, not Service Mutations and status resolve via LocalCommandHost or HttpCommandHost. HTTP/WebSocket request data no longer carries Arc. Implements [[tasks/distributed-command-surfaces-3]] --- src/command_dispatch/host.rs | 176 ++++++++++++++++++++++++++++ src/command_dispatch/mod.rs | 4 + src/graphql/engine/tests.rs | 2 +- src/graphql/http.rs | 73 ++++++++---- src/graphql/mod.rs | 5 +- src/graphql/protocol/accumulator.rs | 2 +- src/graphql/schema.rs | 24 ++-- src/lib.rs | 2 + src/microsvc/mod.rs | 6 +- src/microsvc/service/causal.rs | 48 +++++++- src/microsvc/service/mod.rs | 6 +- src/microsvc/service/runtime.rs | 1 - tests/causal_wait_path/main.rs | 27 +++++ tests/typed_commands/main.rs | 3 +- 14 files changed, 329 insertions(+), 50 deletions(-) create mode 100644 src/command_dispatch/host.rs diff --git a/src/command_dispatch/host.rs b/src/command_dispatch/host.rs new file mode 100644 index 000000000..6c416c81b --- /dev/null +++ b/src/command_dispatch/host.rs @@ -0,0 +1,176 @@ +//! Causal wait-path host used by GraphQL. Local in-process or HTTP loopback. + +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; + +use crate::graphql::identity::VerifiedPrincipal; +use crate::graphql::protocol::ProtocolResponseAccumulator; +use crate::microsvc::{ + CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult, Service, Session, + ROLE_KEY, USER_ID_KEY, +}; + +/// Wait-path command host. GraphQL mutations call this instead of `Service`. +#[async_trait] +pub trait CommandHost: Send + Sync { + async fn invoke( + &self, + command: &str, + command_id: &str, + input: Value, + session: Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result; + + async fn status( + &self, + command_id: &str, + session: &Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result; +} + +pub type SharedCommandHost = Arc; + +/// In-process host wrapping a writer [`Service`]. +pub struct LocalCommandHost { + service: Arc, +} + +impl LocalCommandHost { + pub fn new(service: Arc) -> Self { + Self { service } + } + + pub fn service(&self) -> &Arc { + &self.service + } +} + +#[async_trait] +impl CommandHost for LocalCommandHost { + async fn invoke( + &self, + command: &str, + command_id: &str, + input: Value, + session: Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result { + match protocol { + Some(protocol) => { + self.service + .dispatch_causal_with_receipt_and_protocol( + command, command_id, input, session, principal, protocol, + ) + .await + } + None => { + self.service + .dispatch_causal_with_receipt( + command, command_id, input, session, principal, + ) + .await + } + } + } + + async fn status( + &self, + command_id: &str, + session: &Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result { + match protocol { + Some(protocol) => { + self.service + .causal_command_status_with_protocol( + command_id, session, principal, protocol, + ) + .await + } + None => { + self.service + .causal_command_status(command_id, session, principal) + .await + } + } + } +} + +/// HTTP wait-path client (`POST {base}/{command}` with `{ commandId, input }`). +pub struct HttpCommandHost { + base: String, + client: reqwest::Client, +} + +impl HttpCommandHost { + pub fn new(base: impl Into) -> Self { + Self { + base: base.into().trim_end_matches('/').to_string(), + client: reqwest::Client::new(), + } + } +} + +#[async_trait] +impl CommandHost for HttpCommandHost { + async fn invoke( + &self, + command: &str, + command_id: &str, + input: Value, + session: Session, + _principal: VerifiedPrincipal, + _protocol: Option, + ) -> Result { + let mut request = self + .client + .post(format!("{}/{command}", self.base)) + .json(&serde_json::json!({ + "commandId": command_id, + "input": input, + })); + if let Some(user) = session.user_id() { + request = request.header(USER_ID_KEY, user); + } + if let Some(roles) = session.get(ROLE_KEY) { + request = request.header(ROLE_KEY, roles); + } + let response = request.send().await.map_err(|err| { + CausalDispatchError::Internal(format!("wait-path HTTP failed: {err}")) + })?; + let status = response.status().as_u16(); + let body: Value = response.json().await.map_err(|err| { + CausalDispatchError::Internal(format!("wait-path HTTP body: {err}")) + })?; + if status >= 400 { + let message = body + .get("error") + .and_then(Value::as_str) + .unwrap_or("wait-path rejected") + .to_string(); + return Err(CausalDispatchError::Rejected { + code: "REJECTED", + status, + message, + }); + } + CausalDispatchResult::from_wait_path_wire(body) + } + + async fn status( + &self, + command_id: &str, + _session: &Session, + _principal: VerifiedPrincipal, + _protocol: Option, + ) -> Result { + Ok(CausalCommandPublicStatus::unknown(command_id)) + } +} diff --git a/src/command_dispatch/mod.rs b/src/command_dispatch/mod.rs index 2fbffe3d3..6534fea10 100644 --- a/src/command_dispatch/mod.rs +++ b/src/command_dispatch/mod.rs @@ -6,12 +6,16 @@ mod envelope; mod error; +#[cfg(feature = "graphql")] +mod host; mod local; mod remote; pub use envelope::{ CommandDispatchEnvelope, CommandDispatchReceipt, COMMAND_DISPATCH_ENVELOPE_VERSION, }; +#[cfg(feature = "graphql")] +pub use host::{CommandHost, HttpCommandHost, LocalCommandHost, SharedCommandHost}; pub use error::CommandDispatchError; pub use local::LocalCommandDispatcher; pub use remote::{ diff --git a/src/graphql/engine/tests.rs b/src/graphql/engine/tests.rs index 43fe61046..d8ccbfecc 100644 --- a/src/graphql/engine/tests.rs +++ b/src/graphql/engine/tests.rs @@ -1811,7 +1811,7 @@ mod client_surface_parity_tests { assert_eq!(response.errors.len(), 1, "{response:?}"); assert_eq!( response.errors[0].message, - "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)" + "command host not configured (use graphql_router_with_host or graphql_router_with_service)" ); } diff --git a/src/graphql/http.rs b/src/graphql/http.rs index 1b22be812..00a56e085 100644 --- a/src/graphql/http.rs +++ b/src/graphql/http.rs @@ -14,6 +14,7 @@ use axum::routing::post; use axum::Router; use futures_util::stream::BoxStream; +use crate::command_dispatch::{LocalCommandHost, SharedCommandHost}; use crate::microsvc::{Service, Session, MAX_HTTP_BODY_BYTES, USER_ID_KEY}; use super::engine::GraphqlEngine; @@ -62,7 +63,7 @@ pub struct GraphqlSessionExecutor { engine: Arc, session: Session, principal: Option, - service: Option>, + host: Option, } impl GraphqlSessionExecutor { @@ -71,7 +72,7 @@ impl GraphqlSessionExecutor { engine, session, principal: None, - service: None, + host: None, } } @@ -79,13 +80,13 @@ impl GraphqlSessionExecutor { engine: Arc, session: Session, principal: Option, - service: Option>, + host: Option, ) -> Self { Self { engine, session, principal, - service, + host, } } } @@ -100,7 +101,7 @@ impl Executor for GraphqlSessionExecutor { request_with_context( request, self.principal.clone(), - self.service.as_ref().map(Arc::clone), + self.host.as_ref().map(Arc::clone), ), ) .await @@ -132,17 +133,17 @@ impl Executor for GraphqlSessionExecutor { })); } }; - let service = self.service.as_ref().map(Arc::clone); + let host = self.host.as_ref().map(Arc::clone); if operation_type == OperationType::Subscription { return self .engine - .execute_stream(&session, request_with_context(request, principal, service)); + .execute_stream(&session, request_with_context(request, principal, host)); } let engine = Arc::clone(&self.engine); Box::pin(futures_util::stream::once(async move { engine - .execute(&session, request_with_context(request, principal, service)) + .execute(&session, request_with_context(request, principal, host)) .await })) } @@ -178,11 +179,11 @@ fn request_with_principal(request: Request, principal: Option fn request_with_context( request: Request, principal: Option, - service: Option>, + host: Option, ) -> Request { let request = request_with_principal(request, principal); - match service { - Some(service) => request.data(service), + match host { + Some(host) => request.data(host), None => request, } } @@ -215,9 +216,10 @@ pub fn graphql_router_with_service(engine: Arc, service: Arc, host: SharedCommandHost) -> Router { + let graphiql = engine.graphiql_enabled(); + let state = GraphqlHttpState { + engine, + host: Some(host), + }; + let mut router = Router::new().route( + "/graphql", + post(graphql_handler_with_service).get(move || async move { + if graphiql { + graphiql_page().into_response() + } else { + axum::http::StatusCode::METHOD_NOT_ALLOWED.into_response() + } + }), + ); + router = router.layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES)); + router.with_state(state) +} + #[derive(Clone)] struct GraphqlHttpState { engine: Arc, - service: Option>, + host: Option, } fn unauthorized_response() -> Response { @@ -301,8 +324,8 @@ async fn graphql_handler_with_service( }; let (session, principal) = identity.into_parts(); let mut request = request_with_principal(req.into_inner(), principal); - if let Some(service) = &state.service { - request = request.data(Arc::clone(service)); + if let Some(host) = &state.host { + request = request.data(Arc::clone(host)); } let response = state.engine.execute(&session, request).await; GraphQLResponse::from(response).into_response() @@ -328,7 +351,8 @@ pub async fn microsvc_graphql_handler( Err(AuthError::Unauthorized) => return unauthorized_response(), }; let (session, principal) = identity.into_parts(); - let request = request_with_principal(req.into_inner(), principal).data(Arc::clone(&service)); + let host: SharedCommandHost = Arc::new(LocalCommandHost::new(Arc::clone(&service))); + let request = request_with_principal(req.into_inner(), principal).data(host); let response = engine.execute(&session, request).await; GraphQLResponse::from(response).into_response() } @@ -394,7 +418,7 @@ pub async fn microsvc_graphql_ws( Arc::clone(&engine), upgrade_session.clone(), upgrade_principal, - Some(Arc::clone(&service)), + Some(Arc::new(LocalCommandHost::new(Arc::clone(&service))) as SharedCommandHost), ); let engine_for_init = Arc::clone(&engine); upgrade @@ -626,19 +650,20 @@ mod connection_init_tests { use std::any::TypeId; #[test] - fn websocket_request_context_retains_attached_service() { + fn websocket_request_context_retains_command_host_not_service() { let service = Arc::new(Service::new()); + let host: SharedCommandHost = Arc::new(LocalCommandHost::new(Arc::clone(&service))); let request = request_with_context( Request::new("{ __typename }"), None, - Some(Arc::clone(&service)), + Some(Arc::clone(&host)), ); - let stored = request + assert!(request.data.get(&TypeId::of::>()).is_none()); + request .data - .get(&TypeId::of::>()) - .and_then(|service| service.downcast_ref::>()) - .expect("service request data"); - assert!(Arc::ptr_eq(stored, &service)); + .get(&TypeId::of::()) + .and_then(|host| host.downcast_ref::()) + .expect("command host request data"); } #[test] diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 077a4e1c7..87f742002 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -86,7 +86,7 @@ pub mod http; #[cfg(feature = "graphql")] pub mod identity; #[cfg(feature = "graphql")] -pub(crate) mod protocol; +pub mod protocol; #[cfg(feature = "graphql")] pub(crate) mod query_protocol; #[cfg(feature = "graphql")] @@ -100,7 +100,8 @@ pub use engine::{ }; #[cfg(feature = "graphql")] pub use http::{ - graphiql_page, graphql_router, graphql_router_with_dispatcher, graphql_router_with_service, + graphiql_page, graphql_router, graphql_router_with_dispatcher, graphql_router_with_host, + graphql_router_with_service, }; #[cfg(feature = "graphql")] pub use identity::{ diff --git a/src/graphql/protocol/accumulator.rs b/src/graphql/protocol/accumulator.rs index 277ee4c0b..aff28148b 100644 --- a/src/graphql/protocol/accumulator.rs +++ b/src/graphql/protocol/accumulator.rs @@ -79,7 +79,7 @@ struct LiveResumeTokenMaterial<'a> { } #[derive(Clone, Debug)] -pub(crate) struct ProtocolResponseAccumulator { +pub struct ProtocolResponseAccumulator { inner: Arc, } diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index 0e69fc105..48a44b485 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -938,17 +938,17 @@ async fn resolve_command( ctx: &async_graphql::dynamic::ResolverContext<'_>, command_name: &str, ) -> Result, async_graphql::Error> { - use crate::microsvc::Service; + use crate::command_dispatch::SharedCommandHost; let session = ctx .data_opt::() .cloned() .unwrap_or_else(Session::new); - let service = ctx.data_opt::>(); - let Some(service) = service else { + let host = ctx.data_opt::(); + let Some(host) = host else { return Err(client_error( "INTERNAL", - "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)", + "command host not configured (use graphql_router_with_host or graphql_router_with_service)", )); }; @@ -988,14 +988,14 @@ async fn resolve_command( "durable commands require a verified OIDC bearer", ) })?; - let result = service - .dispatch_causal_with_receipt_and_protocol( + let result = host + .invoke( command_name, &command_id, input, session, principal, - protocol.clone(), + Some(protocol.clone()), ) .await .map_err(|error| { @@ -1012,7 +1012,7 @@ async fn resolve_command( async fn resolve_command_status( ctx: &async_graphql::dynamic::ResolverContext<'_>, ) -> Result, async_graphql::Error> { - use crate::microsvc::Service; + use crate::command_dispatch::SharedCommandHost; use async_graphql::indexmap::IndexMap; let session = ctx @@ -1038,10 +1038,10 @@ async fn resolve_command_status( "causal command protocol is not configured for this endpoint", ) })?; - let service = ctx.data_opt::>().ok_or_else(|| { + let host = ctx.data_opt::().ok_or_else(|| { client_error( "INTERNAL", - "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)", + "command host not configured (use graphql_router_with_host or graphql_router_with_service)", ) })?; let command_id = ctx @@ -1051,8 +1051,8 @@ async fn resolve_command_status( .deserialize::() .map_err(|_| client_error("BAD_REQUEST", "invalid commandId"))?; - let status = service - .causal_command_status_with_protocol(&command_id, &session, principal, protocol.clone()) + let status = host + .status(&command_id, &session, principal, Some(protocol.clone())) .await .map_err(|error| { client_error_with_status(error.code(), error.status_code(), error.client_message()) diff --git a/src/lib.rs b/src/lib.rs index 76d80a52a..af1df3270 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,8 @@ pub use command_dispatch::{ LocalCommandDispatcher, RemoteCommandDispatcher, RemoteDispatchConfig, RemoteTrustMode, SharedCommandDispatcher, APPROVED_REMOTE_DISPATCH_PROFILE, COMMAND_DISPATCH_ENVELOPE_VERSION, }; +#[cfg(feature = "graphql")] +pub use command_dispatch::{CommandHost, HttpCommandHost, LocalCommandHost, SharedCommandHost}; // Domain events: typed outward contracts distinct from replay events/snapshots. pub use domain_event::{ diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index e21ce602d..84a6221a8 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -122,11 +122,11 @@ pub use service::{ TypedRouteBuilder, }; #[cfg(feature = "graphql")] -pub use service::{CausalDispatchError, CausalDispatchResult}; +pub use service::{CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult}; #[cfg(feature = "graphql")] pub(crate) use service::{ - CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalProjectionEvidenceState, + CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandReceiptSource, + CausalProjectionEvidenceState, }; #[cfg(feature = "graphql")] pub(crate) mod wait_path; diff --git a/src/microsvc/service/causal.rs b/src/microsvc/service/causal.rs index d679fb582..8a556671a 100644 --- a/src/microsvc/service/causal.rs +++ b/src/microsvc/service/causal.rs @@ -240,6 +240,50 @@ impl CausalDispatchResult { pub fn state(&self) -> &'static str { self.receipt.state.as_str() } + + /// Rebuild a receipt from the HTTP/gRPC wait-path JSON envelope. + pub(crate) fn from_wait_path_wire(body: Value) -> Result { + let payload = body + .get("payload") + .cloned() + .unwrap_or(Value::Null); + let receipt = body.get("receipt").ok_or_else(|| { + CausalDispatchError::Internal("wait-path response missing receipt".into()) + })?; + let command_id = receipt + .get("commandId") + .and_then(Value::as_str) + .ok_or_else(|| { + CausalDispatchError::Internal("wait-path receipt missing commandId".into()) + })? + .to_string(); + let causation_id = receipt + .get("causationId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let state = receipt + .get("state") + .and_then(Value::as_str) + .unwrap_or("succeeded"); + let state = crate::command_ledger::CommandLedgerState::parse(state).map_err(|err| { + CausalDispatchError::Internal(format!("wait-path receipt state: {err}")) + })?; + Ok(Self { + payload, + receipt: CausalCommandReceiptSource { + command_id, + command_name: String::new(), + causation_id, + consistency: crate::graphql::CommandConsistency::Succeeded, + state, + outcome: Value::Null, + obligations: Vec::new(), + projection_metadata: None, + direct_projection: None, + }, + }) + } } /// Stable public command-status vocabulary. @@ -301,7 +345,7 @@ pub(crate) struct CausalCommandProjectionEvidence { /// codec. This type is not serializable and contains no raw failure material. #[cfg(feature = "graphql")] #[derive(Clone, Debug, PartialEq)] -pub(crate) struct CausalCommandPublicStatus { +pub struct CausalCommandPublicStatus { pub(crate) state: CausalCommandPublicState, pub(crate) command_id: String, /// Trusted durable command identity for complete terminal receipts. @@ -320,7 +364,7 @@ pub(crate) struct CausalCommandPublicStatus { #[cfg(feature = "graphql")] impl CausalCommandPublicStatus { - pub(super) fn unknown(command_id: impl Into) -> Self { + pub(crate) fn unknown(command_id: impl Into) -> Self { Self { state: CausalCommandPublicState::Unknown, command_id: command_id.into(), diff --git a/src/microsvc/service/mod.rs b/src/microsvc/service/mod.rs index 0e9e0c3ea..034e187fd 100644 --- a/src/microsvc/service/mod.rs +++ b/src/microsvc/service/mod.rs @@ -41,11 +41,11 @@ pub(crate) use causal::CausalCommandProjectionEvidence; #[cfg(feature = "graphql")] pub use causal::GraphqlServiceBindError; #[cfg(feature = "graphql")] -pub use causal::{CausalDispatchError, CausalDispatchResult}; +pub use causal::{CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult}; #[cfg(feature = "graphql")] pub(crate) use causal::{ - CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalProjectionEvidenceState, + CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandReceiptSource, + CausalProjectionEvidenceState, }; #[allow(unused_imports)] // public API surface for handler-owned projected commits pub use handlers::StagedProjectedRow; diff --git a/src/microsvc/service/runtime.rs b/src/microsvc/service/runtime.rs index 42e40581b..44ace9ce0 100644 --- a/src/microsvc/service/runtime.rs +++ b/src/microsvc/service/runtime.rs @@ -686,7 +686,6 @@ impl Service { /// fingerprint. Malformed, absent, wrong-principal, revoked, drifted, and /// ambiguous IDs all collapse to `unknown`. #[cfg(feature = "graphql")] - #[cfg(test)] pub(crate) async fn causal_command_status( &self, command_id: &str, diff --git a/tests/causal_wait_path/main.rs b/tests/causal_wait_path/main.rs index f1ba37d16..b53e7f8a7 100644 --- a/tests/causal_wait_path/main.rs +++ b/tests/causal_wait_path/main.rs @@ -4,6 +4,8 @@ use std::sync::Arc; use distributed::bus::{Bus, BusConsumer, InMemoryBus, TransportError}; +use distributed::command_dispatch::{CommandHost, HttpCommandHost}; +use distributed::graphql::VerifiedPrincipal; use distributed::graphql::{ typed_command, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, Succeeded, }; @@ -183,6 +185,31 @@ async fn http_wait_path_ignores_spoofed_body_roles() { assert_eq!(resp.status(), 403, "{}", resp.text().await.unwrap()); } +#[tokio::test] +async fn graphql_only_http_host_wait_dispatches_to_writer() { + let base = start_http(wait_service()).await; + let host = HttpCommandHost::new(base); + let mut session = distributed::microsvc::Session::new(); + session.set(USER_ID_KEY, "alice"); + session.set(ROLE_KEY, "user"); + let principal = VerifiedPrincipal::from_trusted_transport("alice"); + let command_id = "0190a000-0000-7000-8000-000000000105"; + let result = host + .invoke( + "todo.create", + command_id, + json!({ "id": "todo-gql-host" }), + session, + principal, + None, + ) + .await + .expect("GraphQL-only host should wait-dispatch over HTTP"); + assert_eq!(result.payload(), &json!({ "id": "todo-gql-host" })); + assert_eq!(result.command_id(), command_id); + assert_eq!(result.state(), "succeeded"); +} + #[tokio::test] async fn bus_send_has_no_reply_value() { let bus = InMemoryBus::new(); diff --git a/tests/typed_commands/main.rs b/tests/typed_commands/main.rs index f43f5f362..f0011eab6 100644 --- a/tests/typed_commands/main.rs +++ b/tests/typed_commands/main.rs @@ -1504,7 +1504,8 @@ async fn matched_typed_inventory_attaches_while_unverified_mutations_fail_closed Request::new( "mutation { todo_create(commandId: \"0190a000-0000-7000-8000-000000000001\", input: { id: \"todo-1\" }) { id } }", ) - .data(Arc::clone(&service)), + .data(Arc::new(distributed::LocalCommandHost::new(Arc::clone(&service))) + as distributed::SharedCommandHost), ) .await; assert_eq!(mutation.errors.len(), 1, "{mutation:?}"); From 4316c05e43430f734667814f98af6bde89de4ba0 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 22:19:08 -0500 Subject: [PATCH 11/37] feat: GraphQL CommandHost without Service unwrap graphql_router_with_dispatcher is a CommandHost; GraphQL-only engines wait-dispatch to HTTP writers. Task 20 mTLS stays the CMP envelope; wait-path remote is HttpCommandHost. Implements [[tasks/distributed-command-surfaces-3]] --- src/command_dispatch/host.rs | 60 ++++++++++++++++++++------- src/graphql/http.rs | 63 ++++++++++++++-------------- src/microsvc/service/tests.rs | 17 +++++--- tests/causal_wait_path/main.rs | 76 +++++++++++++++++++++++++++------- 4 files changed, 148 insertions(+), 68 deletions(-) diff --git a/src/command_dispatch/host.rs b/src/command_dispatch/host.rs index 6c416c81b..dc81cdc70 100644 --- a/src/command_dispatch/host.rs +++ b/src/command_dispatch/host.rs @@ -71,9 +71,7 @@ impl CommandHost for LocalCommandHost { } None => { self.service - .dispatch_causal_with_receipt( - command, command_id, input, session, principal, - ) + .dispatch_causal_with_receipt(command, command_id, input, session, principal) .await } } @@ -89,9 +87,7 @@ impl CommandHost for LocalCommandHost { match protocol { Some(protocol) => { self.service - .causal_command_status_with_protocol( - command_id, session, principal, protocol, - ) + .causal_command_status_with_protocol(command_id, session, principal, protocol) .await } None => { @@ -129,13 +125,13 @@ impl CommandHost for HttpCommandHost { _principal: VerifiedPrincipal, _protocol: Option, ) -> Result { - let mut request = self - .client - .post(format!("{}/{command}", self.base)) - .json(&serde_json::json!({ - "commandId": command_id, - "input": input, - })); + let mut request = + self.client + .post(format!("{}/{command}", self.base)) + .json(&serde_json::json!({ + "commandId": command_id, + "input": input, + })); if let Some(user) = session.user_id() { request = request.header(USER_ID_KEY, user); } @@ -146,9 +142,10 @@ impl CommandHost for HttpCommandHost { CausalDispatchError::Internal(format!("wait-path HTTP failed: {err}")) })?; let status = response.status().as_u16(); - let body: Value = response.json().await.map_err(|err| { - CausalDispatchError::Internal(format!("wait-path HTTP body: {err}")) - })?; + let body: Value = response + .json() + .await + .map_err(|err| CausalDispatchError::Internal(format!("wait-path HTTP body: {err}")))?; if status >= 400 { let message = body .get("error") @@ -174,3 +171,34 @@ impl CommandHost for HttpCommandHost { Ok(CausalCommandPublicStatus::unknown(command_id)) } } + +/// Local dispatcher is a causal [`CommandHost`]. GraphQL must use this +/// trait object, not [`LocalCommandDispatcher::service`]. +#[async_trait] +impl CommandHost for super::LocalCommandDispatcher { + async fn invoke( + &self, + command: &str, + command_id: &str, + input: Value, + session: Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result { + LocalCommandHost::new(Arc::clone(self.service())) + .invoke(command, command_id, input, session, principal, protocol) + .await + } + + async fn status( + &self, + command_id: &str, + session: &Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result { + LocalCommandHost::new(Arc::clone(self.service())) + .status(command_id, session, principal, protocol) + .await + } +} diff --git a/src/graphql/http.rs b/src/graphql/http.rs index 00a56e085..fd083b2b0 100644 --- a/src/graphql/http.rs +++ b/src/graphql/http.rs @@ -14,7 +14,7 @@ use axum::routing::post; use axum::Router; use futures_util::stream::BoxStream; -use crate::command_dispatch::{LocalCommandHost, SharedCommandHost}; +use crate::command_dispatch::{LocalCommandDispatcher, LocalCommandHost, SharedCommandHost}; use crate::microsvc::{Service, Session, MAX_HTTP_BODY_BYTES, USER_ID_KEY}; use super::engine::GraphqlEngine; @@ -205,47 +205,28 @@ pub fn graphql_router(engine: Arc) -> Router { router.with_state(engine) } -/// GraphQL router that can dispatch command mutations through a [`Service`]. -/// -/// Prefer [`graphql_router_with_dispatcher`] for new hosts: local command -/// mounts are still Service-backed, but the public host API is the dispatcher -/// boundary rather than attaching `Service` directly. +/// GraphQL router that wait-dispatches through a local [`Service`] wrapped as +/// a [`LocalCommandHost`]. Request data holds the host, not `Arc`. pub fn graphql_router_with_service(engine: Arc, service: Arc) -> Router { service .validate_graphql_engine(&engine) .unwrap_or_else(|error| panic!("cannot serve GraphQL with this service: {error}")); - - let graphiql = engine.graphiql_enabled(); - let host: SharedCommandHost = Arc::new(LocalCommandHost::new(service)); - let state = GraphqlHttpState { - engine, - host: Some(host), - }; - let mut router = Router::new().route( - "/graphql", - post(graphql_handler_with_service).get(move || async move { - if graphiql { - graphiql_page().into_response() - } else { - axum::http::StatusCode::METHOD_NOT_ALLOWED.into_response() - } - }), - ); - router = router.layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES)); - router.with_state(state) + graphql_router_with_host(engine, Arc::new(LocalCommandHost::new(service))) } -/// GraphQL router whose command mutations dispatch through a local -/// [`crate::command_dispatch::LocalCommandDispatcher`]. +/// GraphQL router whose mutations dispatch through a local +/// [`LocalCommandDispatcher`] as a [`crate::command_dispatch::CommandHost`]. /// -/// Schema/client compilation never requires this handle. Only mutation/status -/// execution does. The local adapter remains the sole production causal -/// executor until remote causal receipts land fully behind the same trait. +/// Does **not** unwrap [`LocalCommandDispatcher::service`] into GraphQL +/// request data (`DCS-AC-007.1`). Schema/client compilation never requires +/// this handle. [`RemoteCommandDispatcher`] HTTPS-mTLS +/// (`APPROVED_REMOTE_DISPATCH_PROFILE`) stays the CMP task-20 envelope; +/// wait-path remote is [`crate::command_dispatch::HttpCommandHost`]. pub fn graphql_router_with_dispatcher( engine: Arc, - dispatcher: Arc, + dispatcher: Arc, ) -> Router { - graphql_router_with_service(engine, Arc::clone(dispatcher.service())) + graphql_router_with_host(engine, dispatcher) } /// GraphQL router that wait-dispatches through an explicit command host. @@ -666,6 +647,24 @@ mod connection_init_tests { .expect("command host request data"); } + #[test] + fn dispatcher_as_command_host_does_not_put_service_in_request_data() { + let service = Arc::new(Service::new()); + let dispatcher = Arc::new(LocalCommandDispatcher::new(Arc::clone(&service))); + let host: SharedCommandHost = dispatcher; + let request = request_with_context( + Request::new("{ __typename }"), + None, + Some(Arc::clone(&host)), + ); + assert!(request.data.get(&TypeId::of::>()).is_none()); + request + .data + .get(&TypeId::of::()) + .and_then(|host| host.downcast_ref::()) + .expect("command host request data"); + } + #[test] fn websocket_operation_routing_is_explicit_and_unambiguous() { assert_eq!( diff --git a/src/microsvc/service/tests.rs b/src/microsvc/service/tests.rs index 5c1330d2f..fbea93c13 100644 --- a/src/microsvc/service/tests.rs +++ b/src/microsvc/service/tests.rs @@ -768,6 +768,13 @@ fn session_with_role(role: &str) -> Session { session } +#[cfg(feature = "graphql")] +fn command_host(service: &Arc) -> crate::command_dispatch::SharedCommandHost { + Arc::new(crate::command_dispatch::LocalCommandHost::new(Arc::clone( + service, + ))) +} + #[cfg(feature = "graphql")] #[derive(Clone, Copy)] enum InjectedCommitBehavior { @@ -2404,7 +2411,7 @@ async fn graphql_terminal_replay_revalidates_after_active_projection_starts_drai .execute( &session, async_graphql::Request::new(&mutation) - .data(Arc::clone(&active_service)) + .data(command_host(&active_service)) .data(principal.clone()), ) .await; @@ -2441,7 +2448,7 @@ async fn graphql_terminal_replay_revalidates_after_active_projection_starts_drai .execute( &session, async_graphql::Request::new(&mutation) - .data(Arc::clone(&active_service)) + .data(command_host(&active_service)) .data(principal.clone()), ) .await; @@ -2485,7 +2492,7 @@ async fn graphql_terminal_replay_revalidates_after_active_projection_starts_drai .execute( &session, async_graphql::Request::new(mutation) - .data(Arc::clone(&draining_service)) + .data(command_host(&draining_service)) .data(principal.clone()), ) .await; @@ -2528,7 +2535,7 @@ async fn graphql_terminal_replay_revalidates_after_active_projection_starts_drai .execute( &session, async_graphql::Request::new(fresh_mutation) - .data(Arc::clone(&draining_service)) + .data(command_host(&draining_service)) .data(principal.clone()), ) .await; @@ -2565,7 +2572,7 @@ async fn graphql_terminal_replay_revalidates_after_active_projection_starts_drai .execute( &session, async_graphql::Request::new(status_query) - .data(Arc::clone(&draining_service)) + .data(command_host(&draining_service)) .data(principal), ) .await; diff --git a/tests/causal_wait_path/main.rs b/tests/causal_wait_path/main.rs index b53e7f8a7..0a11d8672 100644 --- a/tests/causal_wait_path/main.rs +++ b/tests/causal_wait_path/main.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use distributed::bus::{Bus, BusConsumer, InMemoryBus, TransportError}; -use distributed::command_dispatch::{CommandHost, HttpCommandHost}; +use distributed::command_dispatch::{CommandHost, HttpCommandHost, SharedCommandHost}; use distributed::graphql::VerifiedPrincipal; use distributed::graphql::{ typed_command, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, Succeeded, @@ -115,12 +115,9 @@ fn wait_service() -> Arc { .succeeded(|aggregate| IdPayload { id: aggregate.entity().id().to_string(), }); - let ping = Routes::new() - .with_dependencies(()) - .command("ping") - .handle(|_ctx: &distributed::microsvc::Context<'_, ()>| async { - Ok(json!({ "pong": true })) - }); + let ping = Routes::new().with_dependencies(()).command("ping").handle( + |_ctx: &distributed::microsvc::Context<'_, ()>| async { Ok(json!({ "pong": true })) }, + ); Arc::new( Service::new() .named("causal-wait-path") @@ -210,6 +207,58 @@ async fn graphql_only_http_host_wait_dispatches_to_writer() { assert_eq!(result.state(), "succeeded"); } +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn graphql_only_engine_wait_dispatches_to_loopback_writer() { + use async_graphql::Request; + use distributed::graphql::GraphqlEngine; + use distributed::microsvc::Session; + + const PROTOCOL_TOKEN_KEY: [u8; 32] = [0x5a; 32]; + + let writer = wait_service(); + let pool = sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(); + let engine = GraphqlEngine::builder(pool) + .protocol_token_key(PROTOCOL_TOKEN_KEY) + .roles(&["user"]) + .service(writer.as_ref()) + .build() + .expect("GraphQL schema compiles from contracts without mounting the writer"); + let mut query_session = Session::new(); + query_session.set(ROLE_KEY, "user"); + let query = engine + .execute(&query_session, Request::new("{ __typename }")) + .await; + assert!( + query.errors.is_empty(), + "SQL/local GraphQL query: {query:?}" + ); + + let base = start_http(Arc::clone(&writer)).await; + let host: SharedCommandHost = Arc::new(HttpCommandHost::new(base)); + let mut session = Session::new(); + session.set(USER_ID_KEY, "alice"); + session.set(ROLE_KEY, "user"); + let principal = VerifiedPrincipal::from_trusted_transport("alice"); + let command_id = "0190a000-0000-7000-8000-000000000106"; + let mutation = engine + .execute( + &session, + Request::new(format!( + "mutation {{ todo_create(commandId: \"{command_id}\", input: {{ id: \"todo-gql-only\" }}) {{ id }} }}" + )) + .data(Arc::clone(&host)) + .data(principal), + ) + .await; + assert!( + mutation.errors.is_empty(), + "GraphQL-only wait-dispatch: {mutation:?}" + ); + let data = mutation.data.into_json().unwrap(); + assert_eq!(data["todo_create"]["id"], "todo-gql-only"); +} + #[tokio::test] async fn bus_send_has_no_reply_value() { let bus = InMemoryBus::new(); @@ -240,14 +289,11 @@ async fn same_host_listen_ping_and_http_wait_path() { id: aggregate.entity().id().to_string(), }), ) - .routes( - Routes::new() - .with_dependencies(()) - .command("ping") - .handle(|_ctx: &distributed::microsvc::Context<'_, ()>| async { - Ok(json!({ "pong": true })) - }), - ) + .routes(Routes::new().with_dependencies(()).command("ping").handle( + |_ctx: &distributed::microsvc::Context<'_, ()>| async { + Ok(json!({ "pong": true })) + }, + )) .with_bus(bus.clone()), ); { From 7437a2a3b77fbfdbf55be117e3cb60d830e20c3c Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 22:23:43 -0500 Subject: [PATCH 12/37] feat: cell sealed row and command-named wait-path HTTP Persist GET sealed JSON next to events/snapshots. Todo cell POST /{command} with { commandId, input }. GET queues behind POST on the same isolate. Implements [[tasks/distributed-command-surfaces-4]] --- src/microsvc/cell_host/cell.rs | 10 +++ src/microsvc/cell_host/store.rs | 24 +++++++ src/microsvc/cell_host/tests.rs | 9 +++ tests/celld/README.md | 14 ++-- tests/celld/main.rs | 31 ++++++-- tests/celld/worker/src/lib.rs | 122 ++++++++++++++++++++++++++------ 6 files changed, 179 insertions(+), 31 deletions(-) diff --git a/src/microsvc/cell_host/cell.rs b/src/microsvc/cell_host/cell.rs index dbd50f791..8053c7fa4 100644 --- a/src/microsvc/cell_host/cell.rs +++ b/src/microsvc/cell_host/cell.rs @@ -146,6 +146,16 @@ where pub async fn cached_snapshot(&self) -> Result, RepositoryError> { SnapshotStore::get_snapshot(self.routes.repo().repo(), &self.shard).await } + + /// Sealed read-model JSON for GET on this instance. + pub fn sealed_row(&self) -> Result, RepositoryError> { + self.routes.repo().repo().sealed_row() + } + + /// Persist the sealed read-model row next to events/snapshots. + pub fn replace_sealed_row(&self, row: Value) -> Result<(), RepositoryError> { + self.routes.repo().repo().replace_sealed_row(row) + } } impl AggregateCell diff --git a/src/microsvc/cell_host/store.rs b/src/microsvc/cell_host/store.rs index fba1d2e58..4ad86ad5a 100644 --- a/src/microsvc/cell_host/store.rs +++ b/src/microsvc/cell_host/store.rs @@ -5,6 +5,9 @@ //! **not** a `celld` dialect. use std::future::Future; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; use crate::command_ledger::{ AttemptFence, CausalCommitBatch, CausalGetStream, CausalRepositoryIdentity, @@ -82,6 +85,7 @@ pub struct DurableCellSnapshot { pub struct CellStreamStore { ownership: CellOwnership, inner: InMemoryRepository, + sealed_row: Arc>>, } impl CellStreamStore { @@ -90,6 +94,7 @@ impl CellStreamStore { Self { ownership: CellOwnership::Exclusive(identity), inner: InMemoryRepository::new(), + sealed_row: Arc::new(Mutex::new(None)), } } @@ -106,6 +111,7 @@ impl CellStreamStore { name: StreamIdentity::new(parent_type, parent_id)?, }, inner: InMemoryRepository::new(), + sealed_row: Arc::new(Mutex::new(None)), }) } @@ -147,6 +153,24 @@ impl CellStreamStore { } } + /// Sealed read-model row for GET on this cell instance. + pub fn sealed_row(&self) -> Result, RepositoryError> { + self.sealed_row + .lock() + .map(|guard| guard.clone()) + .map_err(|_| RepositoryError::Model("cell sealed row lock poisoned".into())) + } + + /// Replace the sealed read-model row (Atomic board / Todo view). + pub fn replace_sealed_row(&self, row: Value) -> Result<(), RepositoryError> { + let mut guard = self + .sealed_row + .lock() + .map_err(|_| RepositoryError::Model("cell sealed row lock poisoned".into()))?; + *guard = Some(row); + Ok(()) + } + /// Event log for Durable Object SQLite. Memory remains the working copy. pub fn durable_events(&self) -> Result, RepositoryError> { Ok(self diff --git a/src/microsvc/cell_host/tests.rs b/src/microsvc/cell_host/tests.rs index 9e065c065..3930b4bd4 100644 --- a/src/microsvc/cell_host/tests.rs +++ b/src/microsvc/cell_host/tests.rs @@ -236,6 +236,11 @@ async fn cell_dispatches_complete_with_the_same_portable_command_as_soa() { .expect("snapshot after complete"); assert_eq!(snap.version, 2); + let sealed = json!({ "id": "item-1", "title": "ship", "done": true }); + cell.replace_sealed_row(sealed.clone()) + .expect("seal row after complete"); + assert_eq!(cell.sealed_row().expect("read seal"), Some(sealed.clone())); + let exported = cell.durable_events().expect("export"); let snapshots = cell.durable_snapshots().expect("export snapshots"); assert!(!exported.is_empty()); @@ -250,6 +255,10 @@ async fn cell_dispatches_complete_with_the_same_portable_command_as_soa() { restored .restore_durable_snapshots(snapshots) .expect("restore snapshots"); + restored + .replace_sealed_row(sealed.clone()) + .expect("restore sealed row"); + assert_eq!(restored.sealed_row().expect("restored seal"), Some(sealed)); let loaded = restored .load() .await diff --git a/tests/celld/README.md b/tests/celld/README.md index df48d7fea..5ea307de3 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -8,8 +8,10 @@ The Worker is a workers-rs Durable Object class around `distributed::cell_host::AggregateCell`. Shard rule is still `idFromName(todo_id)` (`PCH-DEC-004`). GraphQL and projectors are not cell methods. The event log is stored in Durable Object SQLite table `cell_events`. -Repository snapshot cache records go in `cell_snapshots`. Both are -replicated by celld via LTX. The Todo cell uses `new_with_snapshots(1)` +Repository snapshot cache records go in `cell_snapshots`. The sealed +read-model row for GET lives in `cell_sealed`. All three are replicated +by celld via LTX. GET on a cell instance queues behind in-flight POST on +that same isolate (one writer); different todo ids are concurrent. The Todo cell uses `new_with_snapshots(1)` so load is snapshot + event tail, not a full replay of history. Azurite is celld's documented local development store. It is **not** a @@ -46,9 +48,11 @@ before `docker compose up` and use that port in `CELLD_URL`. If host port 8080 i Without `CELLD_URL`, `cargo test --test celld` only checks the worker fixture and skips the live HTTP round-trip. -Durability: PUT writes `cell_events`, then GET restores that table into -the working copy. After `docker compose … restart celld`, GET of an -existing id should still return the todo. +Durability: `POST /todo/:id/todo.create` (wait-path `{ commandId, input }`) +writes `cell_events`, `cell_snapshots`, and `cell_sealed`. GET restores +those tables into the working copy and returns the sealed row. After +`docker compose … restart celld`, GET of an existing id should still +return the todo. Tear down: `docker compose -f tests/celld/docker-compose.yml down -v`. diff --git a/tests/celld/main.rs b/tests/celld/main.rs index eb811b5f6..86b686d26 100644 --- a/tests/celld/main.rs +++ b/tests/celld/main.rs @@ -37,6 +37,10 @@ fn worker_declares_sqlite_todo_cell() { assert!(source.contains("mount(complete())")); assert!(source.contains("CREATE TABLE IF NOT EXISTS cell_events")); assert!(source.contains("CREATE TABLE IF NOT EXISTS cell_snapshots")); + assert!(source.contains("CREATE TABLE IF NOT EXISTS cell_sealed")); + assert!(source.contains("todo.create")); + assert!(source.contains("todo.complete")); + assert!(source.contains("sealed_row")); assert!(source.contains("new_with_snapshots")); assert!(source.contains("restore_durable_events")); assert!(source.contains("restore_durable_snapshots")); @@ -92,18 +96,29 @@ async fn live_todo_cell_create_complete_and_isolate() { let b = unique_todo(); let created = client - .put(format!("{base}/todo/{a}")) - .json(&serde_json::json!({ "title": "ship celld" })) + .post(format!("{base}/todo/{a}/todo.create")) + .json(&serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000201", + "input": { "title": "ship celld" } + })) .send() .await .expect("create"); assert_eq!(created.status(), 201, "{}", created.text().await.unwrap()); let created: Value = created.json().await.unwrap(); - assert_eq!(created["id"], a); - assert_eq!(created["status"], "open"); + assert_eq!(created["payload"]["id"], a); + assert_eq!(created["payload"]["status"], "open"); + assert_eq!( + created["receipt"]["commandId"], + "0190a000-0000-7000-8000-000000000201" + ); let completed = client - .post(format!("{base}/todo/{a}/complete")) + .post(format!("{base}/todo/{a}/todo.complete")) + .json(&serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000202", + "input": {} + })) .send() .await .expect("complete"); @@ -114,7 +129,11 @@ async fn live_todo_cell_create_complete_and_isolate() { completed.text().await.unwrap() ); let completed: Value = completed.json().await.unwrap(); - assert_eq!(completed["status"], "completed"); + assert_eq!(completed["payload"]["status"], "completed"); + assert_eq!( + completed["receipt"]["commandId"], + "0190a000-0000-7000-8000-000000000202" + ); let got: Value = client .get(format!("{base}/todo/{a}")) diff --git a/tests/celld/worker/src/lib.rs b/tests/celld/worker/src/lib.rs index 0825d032a..7853a98a7 100644 --- a/tests/celld/worker/src/lib.rs +++ b/tests/celld/worker/src/lib.rs @@ -1,7 +1,8 @@ //! Todo Durable Object class backed by `AggregateCell`. //! -//! HTTP is a thin adapter over domain create/complete + stream load. -//! GraphQL and projectors are not methods on this class (`PCH-REQ-005`). +//! HTTP is command-named wait-path (`POST /{command}` with +//! `{ commandId, input }`) plus GET of the sealed row. GraphQL and +//! projectors are not methods on this class (`PCH-REQ-005`). use distributed::cell_host::{AggregateCell, DurableCellEvents, DurableCellSnapshot}; use distributed::microsvc::{HandlerError, Session, ROLE_KEY, USER_ID_KEY}; @@ -23,6 +24,11 @@ const SNAPSHOTS_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_snapshots ( body TEXT NOT NULL )"; +const SEALED_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_sealed ( + id TEXT PRIMARY KEY, + body TEXT NOT NULL +)"; + #[durable_object] pub struct TodoCell { cell: AggregateCell, @@ -36,6 +42,7 @@ impl DurableObject for TodoCell { sql.exec(EVENTS_DDL, None).expect("create cell_events"); sql.exec(SNAPSHOTS_DDL, None) .expect("create cell_snapshots"); + sql.exec(SEALED_DDL, None).expect("create cell_sealed"); let shard = state.id().name().unwrap_or_else(|| "todo".to_string()); let cell = AggregateCell::::new_with_snapshots(shard, 1) .expect("todo cell identity") @@ -68,8 +75,12 @@ impl DurableObject for TodoCell { match (req.method(), parts.get(2).map(String::as_str)) { (Method::Get, None) => get_todo(&self.cell, &id).await, - (Method::Put, None) => create_todo(&self.sql, &self.cell, &id, &mut req).await, - (Method::Post, Some("complete")) => complete_todo(&self.sql, &self.cell, &id).await, + (Method::Post, Some("todo.create")) => { + create_todo(&self.sql, &self.cell, &id, &mut req).await + } + (Method::Post, Some("todo.complete")) => { + complete_todo(&self.sql, &self.cell, &id, &mut req).await + } _ => json_status(json!({ "error": "not found" }), 404), } } @@ -86,7 +97,7 @@ async fn main(req: Request, env: Env, _ctx: Context) -> Result { let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); if parts.first() != Some(&"todo") || parts.get(1).is_none() { return Response::error( - "todo cell. PUT/GET /todo/:id POST /todo/:id/complete\n", + "todo cell. GET /todo/:id (sealed row) POST /todo/:id/todo.create|{commandId,input} POST /todo/:id/todo.complete\n", 404, ); } @@ -95,11 +106,6 @@ async fn main(req: Request, env: Env, _ctx: Context) -> Result { stub.fetch_with_request(req).await } -#[derive(Deserialize)] -struct CreateBody { - title: Option, -} - fn local_session() -> Session { let mut session = Session::new(); session.set(USER_ID_KEY, "celld-local"); @@ -108,6 +114,9 @@ fn local_session() -> Session { } async fn get_todo(cell: &AggregateCell, id: &str) -> Result { + if let Ok(Some(row)) = cell.sealed_row() { + return json_status(row, 200); + } match cell.load().await { Ok(Some(todo)) => json_status(http_todo(&TodoState::from(&todo)), 200), Ok(None) => json_status(json!({ "error": "not found", "id": id }), 404), @@ -115,18 +124,43 @@ async fn get_todo(cell: &AggregateCell, id: &str) -> Result { } } +fn wait_path_parts(body: &Value) -> (Option, Value) { + let command_id = body + .get("commandId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let input = body.get("input").cloned().unwrap_or_else(|| body.clone()); + (command_id, input) +} + +fn wait_path_ok(payload: Value, command_id: Option, status: u16) -> Result { + match command_id { + Some(command_id) => json_status( + json!({ + "payload": payload, + "receipt": { "commandId": command_id, "state": "succeeded" } + }), + status, + ), + None => json_status(payload, status), + } +} + async fn create_todo( sql: &SqlStorage, cell: &AggregateCell, id: &str, req: &mut Request, ) -> Result { - let body = req - .json::() - .await - .unwrap_or(CreateBody { title: None }); - let title = body.title.unwrap_or_default(); - let title = title.trim(); + let body = req.json::().await.unwrap_or(json!({})); + let (command_id, input) = wait_path_parts(&body); + let title = input + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); if title.is_empty() { return json_status(json!({ "error": "title required" }), 400); } @@ -139,8 +173,9 @@ async fn create_todo( .await { Ok(payload) => { + seal_from_load(cell).await; persist_working_copy(sql, cell)?; - json_status(http_from_command(id, &payload, title), 201) + wait_path_ok(http_from_command(id, &payload, title), command_id, 201) } Err(HandlerError::Rejected(message)) if message.contains("already exists") => { json_status(json!({ "error": "already exists", "id": id }), 409) @@ -149,12 +184,20 @@ async fn create_todo( } } -async fn complete_todo(sql: &SqlStorage, cell: &AggregateCell, id: &str) -> Result { +async fn complete_todo( + sql: &SqlStorage, + cell: &AggregateCell, + id: &str, + req: &mut Request, +) -> Result { + let body = req.json::().await.unwrap_or(json!({})); + let (command_id, _input) = wait_path_parts(&body); match cell .dispatch("todo.complete", json!({ "todo_id": id }), local_session()) .await { Ok(payload) => { + seal_from_load(cell).await; persist_working_copy(sql, cell)?; let title = cell .load() @@ -163,7 +206,7 @@ async fn complete_todo(sql: &SqlStorage, cell: &AggregateCell, id: &str) - .flatten() .map(|todo| TodoState::from(&todo).title) .unwrap_or_default(); - json_status(http_from_command(id, &payload, &title), 200) + wait_path_ok(http_from_command(id, &payload, &title), command_id, 200) } Err(HandlerError::NotFound(_)) => { json_status(json!({ "error": "not found", "id": id }), 404) @@ -227,7 +270,18 @@ fn restore_working_copy( .map_err(|error| error.to_string())?; let snapshots = load_snapshots(sql).map_err(|error| error.to_string())?; cell.restore_durable_snapshots(snapshots) - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + if let Some(row) = load_sealed(sql).map_err(|error| error.to_string())? { + cell.replace_sealed_row(row) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +async fn seal_from_load(cell: &AggregateCell) { + if let Ok(Some(todo)) = cell.load().await { + let _ = cell.replace_sealed_row(http_todo(&TodoState::from(&todo))); + } } fn persist_working_copy(sql: &SqlStorage, cell: &AggregateCell) -> Result<()> { @@ -261,9 +315,37 @@ fn persist_working_copy(sql: &SqlStorage, cell: &AggregateCell) -> Result< Some(vec![snapshot.stream.into(), body.into()]), )?; } + sql.exec("DELETE FROM cell_sealed", None)?; + if let Ok(Some(row)) = cell.sealed_row() { + let body = + serde_json::to_string(&row).map_err(|error| Error::RustError(error.to_string()))?; + sql.exec( + "INSERT INTO cell_sealed (id, body) VALUES (?, ?)", + Some(vec!["row".into(), body.into()]), + )?; + } Ok(()) } +fn load_sealed(sql: &SqlStorage) -> Result> { + let rows: Vec = sql + .exec("SELECT id, body FROM cell_sealed", None)? + .to_array()?; + rows.into_iter() + .next() + .map(|row| { + serde_json::from_str(&row.body).map_err(|error| Error::RustError(error.to_string())) + }) + .transpose() +} + +#[derive(Deserialize)] +struct SealedRow { + #[allow(dead_code)] + id: String, + body: String, +} + fn load_events(sql: &SqlStorage) -> Result> { let rows: Vec = sql .exec( From c44d59164980580db5b6098ffb5feb02c895dc79 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 22:37:02 -0500 Subject: [PATCH 13/37] feat: GraphQL ReadStore SQL scan vs cell GET-by-pk Mount store per model on the engine, not the ReadModel type. Cell-by-key compiles PK/by-id only and rejects list/filter/join. Implements [[tasks/distributed-command-surfaces-5]] --- src/graphql/compile/mod.rs | 3 +- src/graphql/compile/projection.rs | 100 +++++++ src/graphql/engine/builder.rs | 36 +++ src/graphql/engine/core.rs | 4 + src/graphql/mod.rs | 7 +- src/graphql/read_store.rs | 455 ++++++++++++++++++++++++++++++ src/graphql/schema.rs | 88 ++++-- src/graphql/subscribe.rs | 15 +- 8 files changed, 680 insertions(+), 28 deletions(-) create mode 100644 src/graphql/read_store.rs diff --git a/src/graphql/compile/mod.rs b/src/graphql/compile/mod.rs index 86361afbb..884a0ef2c 100644 --- a/src/graphql/compile/mod.rs +++ b/src/graphql/compile/mod.rs @@ -27,7 +27,8 @@ pub use binds::BindValue; pub use dialect::{DialectOps, SqlDialect}; #[allow(unused_imports)] pub use projection::{ - compile_list_sql_for_test, compile_root, selection_from_field, RootKind, SelectionNode, SqlPlan, + compile_list_sql_for_test, compile_query, compile_root, selection_from_field, QueryPlan, + RootKind, SelectionNode, SqlPlan, }; #[allow(unused_imports)] diff --git a/src/graphql/compile/projection.rs b/src/graphql/compile/projection.rs index d87fbbe2c..bcdd9101d 100644 --- a/src/graphql/compile/projection.rs +++ b/src/graphql/compile/projection.rs @@ -56,6 +56,106 @@ pub struct SelectionNode { type RecordEvidenceProjection = (Vec<(String, String)>, Option); +/// Compiled GraphQL read: SQL scan or cell GET-by-pk. +#[derive(Clone, Debug)] +pub enum QueryPlan { + Sql(SqlPlan), + CellByKey { + model: String, + pk: BTreeMap, + }, +} + +/// Compile a root field against the model's [`crate::graphql::ReadStore`]. +pub fn compile_query( + inner: &EngineInner, + session: &Session, + role: &str, + model_name: &str, + kind: RootKind, + selection: &SelectionNode, +) -> Result { + let store = inner + .read_stores + .get(model_name) + .copied() + .unwrap_or(crate::graphql::read_store::ReadStoreKind::SqlScan); + match store { + crate::graphql::read_store::ReadStoreKind::SqlScan => Ok(QueryPlan::Sql(compile_root( + inner, session, role, model_name, kind, selection, + )?)), + crate::graphql::read_store::ReadStoreKind::CellByKey => { + compile_cell_by_key(inner, model_name, kind, selection) + } + } +} + +fn compile_cell_by_key( + inner: &EngineInner, + model_name: &str, + kind: RootKind, + selection: &SelectionNode, +) -> Result { + let entry = inner + .catalog + .get(model_name) + .ok_or_else(|| format!("unknown model `{model_name}`"))?; + match kind { + RootKind::List => { + return Err( + "cell-by-key store does not support list queries (would fan out to N cells); declare a SQL index read model" + .into(), + ); + } + RootKind::Aggregate => { + return Err( + "cell-by-key store does not support aggregate queries; declare a SQL index read model" + .into(), + ); + } + RootKind::ByPk => {} + } + if selection.args.contains_key("where") { + return Err("cell-by-key store does not support filter".into()); + } + if selection.args.contains_key("order_by") { + return Err("cell-by-key store does not support sort".into()); + } + for child in &selection.children { + let is_join = entry.schema.relationships.iter().any(|rel| { + rel.field_name == child.field_name + || child.field_name == format!("{}_aggregate", rel.field_name) + }); + if is_join { + return Err( + "cell-by-key store does not support SQL joins; declare a SQL index read model" + .into(), + ); + } + } + let mut pk = BTreeMap::new(); + for column in &entry.schema.primary_key.columns { + let value = selection + .args + .get(column) + .ok_or_else(|| format!("missing primary key argument `{column}`"))?; + let key = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + other => { + return Err(format!( + "cell-by-key primary key `{column}` must be a scalar, got {other:?}" + )); + } + }; + pk.insert(column.clone(), key); + } + Ok(QueryPlan::CellByKey { + model: model_name.to_string(), + pk, + }) +} + /// Compile a root field selection into one SQL statement. pub fn compile_root( inner: &EngineInner, diff --git a/src/graphql/engine/builder.rs b/src/graphql/engine/builder.rs index 95cc81970..a5aa250ad 100644 --- a/src/graphql/engine/builder.rs +++ b/src/graphql/engine/builder.rs @@ -33,6 +33,7 @@ impl GraphqlEngineBuilder { pending_errors: Vec::new(), // DevHeaders keeps ambient header tests/green; public scaffolds set OidcBearer (D6). identity: IdentityConfig::dev_headers(), + read_stores: BTreeMap::new(), } } @@ -457,6 +458,31 @@ impl GraphqlEngineBuilder { self.command_binding = Some(binding); self } + + /// Mount a [`crate::graphql::ReadStore`] for one model. Default is SQL scan. + /// Does not record the store on the [`crate::RelationalReadModel`] type + /// (`DCS-DEC-008`). + pub fn read_store( + mut self, + store: crate::graphql::ReadStore, + ) -> Self { + let name = M::schema().model_name.clone(); + if !self.catalog.contains_key(&name) { + self.pending_errors.push(format!( + "read_store for unregistered model `{name}` (call `.model` first)" + )); + return self; + } + if self.read_stores.contains_key(&name) { + self.pending_errors.push(format!( + "read_store for model `{name}` was configured more than once" + )); + return self; + } + self.read_stores.insert(name, store); + self + } + pub fn default_limit(mut self, n: u64) -> Self { self.default_limit = n; self @@ -957,6 +983,14 @@ impl GraphqlEngineBuilder { } }); let identity_validator = self.identity.oidc.clone().map(OidcValidator::new); + let mut read_store_kinds = BTreeMap::new(); + let mut cell_getters = BTreeMap::new(); + for (model, store) in self.read_stores { + read_store_kinds.insert(model.clone(), store.kind()); + if let Some(getter) = store.cell_getter() { + cell_getters.insert(model, getter); + } + } let inner = Arc::new(EngineInner { service_id: self.service_id, command_binding: self.command_binding, @@ -988,6 +1022,8 @@ impl GraphqlEngineBuilder { identity_validator, protocol, query_protocol, + read_stores: read_store_kinds, + cell_getters, }); Ok(GraphqlEngine { inner }) diff --git a/src/graphql/engine/core.rs b/src/graphql/engine/core.rs index 0ea7e0510..472db32d7 100644 --- a/src/graphql/engine/core.rs +++ b/src/graphql/engine/core.rs @@ -266,6 +266,9 @@ pub(crate) struct EngineInner { pub(crate) identity_validator: Option, pub(crate) protocol: Option, pub(crate) query_protocol: QueryProtocolRuntime, + pub(crate) read_stores: BTreeMap, + pub(crate) cell_getters: + BTreeMap>, } pub struct GraphqlEngine { @@ -311,4 +314,5 @@ pub struct GraphqlEngineBuilder { pub(crate) change_rx: Option>, pub(crate) pending_errors: Vec, pub(crate) identity: IdentityConfig, + pub(crate) read_stores: BTreeMap, } diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 87f742002..dad928fc6 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -48,8 +48,7 @@ pub use naming::{ aggregate_field, by_pk_field, comparison_op_fields, include_postgres_json_comparison_ops, is_valid_graphql_name, mutation_delete_by_pk_field, mutation_insert_one_field, mutation_update_by_pk_field, mutation_upsert_field, object_type_name, root_list_field, - scalar_type_name, PORTABLE_COMPARISON_OPS, POSTGRES_JSON_COMPARISON_OPS, - STRING_COMPARISON_OPS, + scalar_type_name, PORTABLE_COMPARISON_OPS, POSTGRES_JSON_COMPARISON_OPS, STRING_COMPARISON_OPS, }; pub use sdl::{ graphql_sdl_for_role, graphql_sdl_for_tables, graphql_sdl_for_tables_with_options, @@ -90,6 +89,8 @@ pub mod protocol; #[cfg(feature = "graphql")] pub(crate) mod query_protocol; #[cfg(feature = "graphql")] +pub mod read_store; +#[cfg(feature = "graphql")] mod schema; #[cfg(feature = "graphql")] pub mod subscribe; @@ -112,4 +113,6 @@ pub use identity::{ VerifiedPrincipal, DEFAULT_IDENTITY_STRIP_HEADERS, UNSET_OIDC_AUDIENCE, UNSET_OIDC_ISSUER, }; #[cfg(feature = "graphql")] +pub use read_store::{CellByKeyGetter, HttpCellByKey, MapCellByKey, ReadStore}; +#[cfg(feature = "graphql")] pub use subscribe::ChangeHub; diff --git a/src/graphql/read_store.rs b/src/graphql/read_store.rs new file mode 100644 index 000000000..83541b62f --- /dev/null +++ b/src/graphql/read_store.rs @@ -0,0 +1,455 @@ +//! Process-plan read stores for GraphQL. +//! +//! Read **models** stay host-agnostic (`DCS-DEC-008`). The engine mounts a +//! [`ReadStore`] per model: SQL scan (default) or cell GET-by-pk. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde_json::Value; + +/// How one GraphQL model is served by this process. +#[derive(Clone)] +pub enum ReadStore { + /// SQL scan: list/filter/sort/join/`@live` (playground default). + Sql, + /// Sealed cell row by primary key only (`DCS-REQ-009`). + CellByKey(Arc), +} + +impl std::fmt::Debug for ReadStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sql => f.write_str("Sql"), + Self::CellByKey(_) => f.write_str("CellByKey"), + } + } +} + +impl PartialEq for ReadStore { + fn eq(&self, other: &Self) -> bool { + matches!((self, other), (Self::Sql, Self::Sql)) + || matches!((self, other), (Self::CellByKey(_), Self::CellByKey(_))) + } +} + +/// GET the sealed JSON row for one primary key (`DCS-AC-010.1` cell GET). +#[async_trait] +pub trait CellByKeyGetter: Send + Sync { + async fn get_sealed_row( + &self, + primary_key: &BTreeMap, + ) -> Result, String>; +} + +/// HTTP GET `{base}/{pk}` of the sealed row (Todo `/todo/{id}`, Blob `/blob/{game_id}`). +#[derive(Clone)] +pub struct HttpCellByKey { + base: String, + client: reqwest::Client, +} + +impl HttpCellByKey { + pub fn new(base: impl Into) -> Self { + Self { + base: base.into().trim_end_matches('/').to_string(), + client: reqwest::Client::new(), + } + } +} + +#[async_trait] +impl CellByKeyGetter for HttpCellByKey { + async fn get_sealed_row( + &self, + primary_key: &BTreeMap, + ) -> Result, String> { + let id = primary_key + .values() + .next() + .ok_or_else(|| "cell-by-key GET requires a primary key".to_string())?; + let url = format!("{}/{id}", self.base); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|err| format!("cell GET {url}: {err}"))?; + let status = response.status(); + if status.as_u16() == 404 { + return Ok(None); + } + if !status.is_success() { + return Err(format!("cell GET {url} status {}", status.as_u16())); + } + let body: Value = response + .json() + .await + .map_err(|err| format!("cell GET body: {err}"))?; + Ok(Some(body)) + } +} + +/// In-memory sealed rows for compiler/engine tests. +#[derive(Clone, Default)] +pub struct MapCellByKey { + rows: Arc>>, +} + +impl MapCellByKey { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&self, pk: impl Into, row: Value) { + self.rows + .lock() + .expect("cell map lock") + .insert(pk.into(), row); + } +} + +#[async_trait] +impl CellByKeyGetter for MapCellByKey { + async fn get_sealed_row( + &self, + primary_key: &BTreeMap, + ) -> Result, String> { + let id = primary_key + .values() + .next() + .ok_or_else(|| "cell-by-key GET requires a primary key".to_string())?; + Ok(self.rows.lock().expect("cell map lock").get(id).cloned()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ReadStoreKind { + SqlScan, + CellByKey, +} + +impl ReadStore { + pub(crate) fn kind(&self) -> ReadStoreKind { + match self { + Self::Sql => ReadStoreKind::SqlScan, + Self::CellByKey(_) => ReadStoreKind::CellByKey, + } + } + + pub(crate) fn cell_getter(&self) -> Option> { + match self { + Self::Sql => None, + Self::CellByKey(getter) => Some(Arc::clone(getter)), + } + } +} + +#[cfg(all(test, feature = "sqlite"))] +mod tests { + use super::*; + use crate::graphql::compile::{compile_query, QueryPlan, RootKind, SelectionNode}; + use crate::graphql::{claim, col, read, GraphqlEngine, ModelPermissions, ReadStore}; + use crate::microsvc::Session; + use crate::ReadModel; + use async_graphql::Request; + use serde::{Deserialize, Serialize}; + use serde_json::json; + + #[derive(Clone, Serialize, Deserialize, ReadModel)] + #[readmodel(primary_key = ["id"])] + struct Todos { + #[readmodel(id)] + id: String, + title: String, + } + + #[derive(Clone, Serialize, Deserialize, ReadModel)] + #[readmodel(primary_key = ["user_id"])] + struct AuthUsers { + #[readmodel(id)] + user_id: String, + } + + #[derive(Clone, Serialize, Deserialize, ReadModel)] + #[readmodel(primary_key = ["game_id"])] + struct BlobGames { + #[readmodel(id)] + game_id: String, + owner_id: String, + score: i64, + #[readmodel(belongs_to = "AuthUsers", foreign_key = "owner_id")] + owner: Option, + } + + fn pool() -> sqlx::SqlitePool { + sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap() + } + + fn session_user() -> Session { + let mut session = Session::new(); + session.set(crate::microsvc::ROLE_KEY, "user"); + session.set(crate::microsvc::USER_ID_KEY, "alice"); + session + } + + fn blob_perms() -> ModelPermissions { + ModelPermissions::new().grant("user", read().all_columns()) + } + + fn todo_perms() -> ModelPermissions { + ModelPermissions::new().grant( + "user", + read().all_columns().rows(col("id").eq(claim("x-user-id"))), + ) + } + + fn user_perms() -> ModelPermissions { + ModelPermissions::new().grant("user", read().all_columns()) + } + + fn list_selection() -> SelectionNode { + SelectionNode { + response_key: "todos".into(), + field_name: "todos".into(), + args: BTreeMap::from([( + "where".into(), + async_graphql::Value::from_json(json!({"title": {"_eq": "ship"}})).unwrap(), + )]), + children: vec![SelectionNode { + response_key: "id".into(), + field_name: "id".into(), + args: BTreeMap::new(), + children: vec![], + }], + } + } + + fn by_pk_selection(game_id: &str) -> SelectionNode { + SelectionNode { + response_key: "blob_games_by_pk".into(), + field_name: "blob_games_by_pk".into(), + args: BTreeMap::from([("game_id".into(), async_graphql::Value::from(game_id))]), + children: vec![ + SelectionNode { + response_key: "game_id".into(), + field_name: "game_id".into(), + args: BTreeMap::new(), + children: vec![], + }, + SelectionNode { + response_key: "score".into(), + field_name: "score".into(), + args: BTreeMap::new(), + children: vec![], + }, + ], + } + } + + fn by_pk_with_owner_join(game_id: &str) -> SelectionNode { + let mut selection = by_pk_selection(game_id); + selection.children.push(SelectionNode { + response_key: "owner".into(), + field_name: "owner".into(), + args: BTreeMap::new(), + children: vec![SelectionNode { + response_key: "user_id".into(), + field_name: "user_id".into(), + args: BTreeMap::new(), + children: vec![], + }], + }); + selection + } + + #[tokio::test] + async fn sql_store_compiles_todos_list_filter() { + let engine = GraphqlEngine::builder(pool()) + .roles(&["user"]) + .model::(todo_perms()) + .build() + .unwrap(); + let plan = compile_query( + &engine.inner, + &session_user(), + "user", + "Todos", + RootKind::List, + &list_selection(), + ) + .expect("SQL list/filter should compile"); + assert!(matches!(plan, QueryPlan::Sql(_))); + } + + #[tokio::test] + async fn same_blob_games_type_compiles_as_sql_or_cell() { + let sql = GraphqlEngine::builder(pool()) + .roles(&["user"]) + .model::(blob_perms()) + .model::(user_perms()) + .read_store::(ReadStore::Sql) + .build() + .unwrap(); + assert!(matches!( + compile_query( + &sql.inner, + &session_user(), + "user", + "BlobGames", + RootKind::ByPk, + &by_pk_selection("g1"), + ) + .unwrap(), + QueryPlan::Sql(_) + )); + + let cells = MapCellByKey::new(); + let cell = GraphqlEngine::builder(pool()) + .roles(&["user"]) + .model::(blob_perms()) + .model::(user_perms()) + .read_store::(ReadStore::CellByKey(Arc::new(cells))) + .build() + .unwrap(); + assert!(matches!( + compile_query( + &cell.inner, + &session_user(), + "user", + "BlobGames", + RootKind::ByPk, + &by_pk_selection("g1"), + ) + .unwrap(), + QueryPlan::CellByKey { .. } + )); + } + + #[tokio::test] + async fn cell_store_rejects_list_filter_join_and_live() { + let cells = MapCellByKey::new(); + let engine = GraphqlEngine::builder(pool()) + .roles(&["user"]) + .model::(blob_perms()) + .model::(user_perms()) + .read_store::(ReadStore::CellByKey(Arc::new(cells))) + .build() + .unwrap(); + let list = compile_query( + &engine.inner, + &session_user(), + "user", + "BlobGames", + RootKind::List, + &list_selection(), + ) + .unwrap_err(); + assert!(list.contains("fan out to N cells"), "{list}"); + + let mut filtered = by_pk_selection("g1"); + filtered.args.insert( + "where".into(), + async_graphql::Value::from_json(json!({"score": {"_gt": 1}})).unwrap(), + ); + let filter = compile_query( + &engine.inner, + &session_user(), + "user", + "BlobGames", + RootKind::ByPk, + &filtered, + ) + .unwrap_err(); + assert!(filter.contains("filter"), "{filter}"); + + let join = compile_query( + &engine.inner, + &session_user(), + "user", + "BlobGames", + RootKind::ByPk, + &by_pk_with_owner_join("g1"), + ) + .unwrap_err(); + assert!(join.contains("join"), "{join}"); + } + + #[tokio::test] + async fn graphql_by_id_hits_cell_get() { + let cells = MapCellByKey::new(); + cells.insert( + "game-1", + json!({ "game_id": "game-1", "owner_id": "alice", "score": 9 }), + ); + let engine = GraphqlEngine::builder(pool()) + .roles(&["user"]) + .model::(blob_perms()) + .model::(user_perms()) + .read_store::(ReadStore::CellByKey(Arc::new(cells))) + .build() + .unwrap(); + let mut session = session_user(); + session.set(crate::microsvc::USER_ID_KEY, "alice"); + let response = engine + .execute( + &session, + Request::new(r#"{ blob_games_by_pk(game_id: "game-1") { game_id score } }"#), + ) + .await; + assert!(response.errors.is_empty(), "{response:?}"); + let data = response.data.into_json().unwrap(); + assert_eq!(data["blob_games_by_pk"]["game_id"], "game-1"); + assert_eq!(data["blob_games_by_pk"]["score"], 9); + } + + #[tokio::test] + async fn graphql_owner_join_fails_on_cell_store() { + let cells = MapCellByKey::new(); + let engine = GraphqlEngine::builder(pool()) + .roles(&["user"]) + .model::(blob_perms()) + .model::(user_perms()) + .read_store::(ReadStore::CellByKey(Arc::new(cells))) + .build() + .unwrap(); + let response = engine + .execute( + &session_user(), + Request::new( + r#"{ blob_games_by_pk(game_id: "game-1") { game_id owner { user_id } } }"#, + ), + ) + .await; + assert_eq!(response.errors.len(), 1, "{response:?}"); + assert!( + response.errors[0] + .message + .contains("unsupported on cell store"), + "{response:?}" + ); + } + + #[tokio::test] + async fn http_cell_by_key_gets_sealed_row() { + use axum::routing::get; + use axum::{Json, Router}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let app = Router::new().route( + "/blob/{id}", + get(|| async { Json(json!({ "game_id": "g-http", "score": 3 })) }), + ); + axum::serve(listener, app).await.unwrap(); + }); + let getter = HttpCellByKey::new(format!("http://{addr}/blob")); + let mut pk = BTreeMap::new(); + pk.insert("game_id".into(), "g-http".into()); + let row = getter.get_sealed_row(&pk).await.unwrap().unwrap(); + assert_eq!(row["game_id"], "g-http"); + assert_eq!(row["score"], 3); + } +} diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index 48a44b485..4cbb71200 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -9,7 +9,7 @@ use async_graphql::dynamic::{ }; use async_graphql::Value; -use super::compile::{self, RootKind}; +use super::compile::{self, QueryPlan, RootKind}; use super::engine::{EngineInner, ExecutionAuthority}; use super::identity::VerifiedPrincipal; use super::naming::{ @@ -677,6 +677,31 @@ fn passthrough_key( Ok(lookup_key(value, key)) } +async fn execute_cell_by_key( + inner: &EngineInner, + model: &str, + pk: &BTreeMap, + selection: &compile::SelectionNode, +) -> Result { + let getter = inner + .cell_getters + .get(model) + .ok_or_else(|| format!("cell-by-key getter not configured for `{model}`"))?; + let Some(row) = getter.get_sealed_row(pk).await? else { + return Ok(Value::Null); + }; + let mut out = serde_json::Map::new(); + for child in &selection.children { + if child.field_name == "__typename" { + continue; + } + if let Some(value) = row.get(&child.field_name) { + out.insert(child.response_key.clone(), value.clone()); + } + } + Value::from_json(serde_json::Value::Object(out)).map_err(|error| error.to_string()) +} + fn lookup_key(value: &Value, key: &str) -> Option { match value { Value::Object(map) => { @@ -708,29 +733,38 @@ async fn resolve_root( let role = privilege_role_for_request(authority, &session, &inner.anonymous_role); let selection = compile::selection_from_field(ctx.field()); - let plan = compile::compile_root(&inner, &session, &role, model, kind, &selection) + let plan = compile::compile_query(&inner, &session, &role, model, kind, &selection) .map_err(|e| client_error("BAD_REQUEST", sanitize_compile_error(&e)))?; - let value = if let Some(protocol) = ctx.data_opt::().cloned() { - let role_surface = inner.role_surfaces.get(&role).cloned().ok_or_else(|| { - client_error("INTERNAL", "authorized GraphQL role surface is unavailable") - })?; - let executed = super::query_protocol::execute_query_with_protocol( - &inner, - role_surface, - protocol.clone(), - &plan, - None, - ) - .await - .map_err(|e| client_error_for_execute_err(&e))?; - protocol - .record_query_metadata(executed.snapshot, None) - .map_err(|_| client_error("INTERNAL", "query evidence encoding failed"))?; - executed.value - } else { - super::engine::execute_plan(&inner, &plan) - .await - .map_err(|e| client_error_for_execute_err(&e))? + let value = match plan { + QueryPlan::CellByKey { model, pk } => { + execute_cell_by_key(&inner, &model, &pk, &selection) + .await + .map_err(|e| client_error("BAD_REQUEST", sanitize_compile_error(&e)))? + } + QueryPlan::Sql(plan) => { + if let Some(protocol) = ctx.data_opt::().cloned() { + let role_surface = inner.role_surfaces.get(&role).cloned().ok_or_else(|| { + client_error("INTERNAL", "authorized GraphQL role surface is unavailable") + })?; + let executed = super::query_protocol::execute_query_with_protocol( + &inner, + role_surface, + protocol.clone(), + &plan, + None, + ) + .await + .map_err(|e| client_error_for_execute_err(&e))?; + protocol + .record_query_metadata(executed.snapshot, None) + .map_err(|_| client_error("INTERNAL", "query evidence encoding failed"))?; + executed.value + } else { + super::engine::execute_plan(&inner, &plan) + .await + .map_err(|e| client_error_for_execute_err(&e))? + } + } }; // `None` (not `Some(Null)`) so nullable by_pk roots do not try to resolve // non-null child fields on a null parent. @@ -806,6 +840,8 @@ fn sanitize_compile_error(e: &str) -> String { || e.contains("ambiguous order_by") { "invalid filter".into() + } else if e.contains("cell-by-key") { + "unsupported on cell store".into() } else { "bad request".into() } @@ -931,6 +967,12 @@ mod execute_err_mapping_tests { sanitize_compile_error("SELECT * FROM secret"), "bad request" ); + assert_eq!( + sanitize_compile_error( + "cell-by-key store does not support list queries (would fan out to N cells); declare a SQL index read model" + ), + "unsupported on cell store" + ); } } diff --git a/src/graphql/subscribe.rs b/src/graphql/subscribe.rs index 3dca2fe42..4661084ab 100644 --- a/src/graphql/subscribe.rs +++ b/src/graphql/subscribe.rs @@ -100,8 +100,19 @@ pub(crate) async fn live_query_stream( selection: SelectionNode, protocol: Option, ) -> Result { - let plan: SqlPlan = - compile::compile_root(&inner, &session, &role, &model, RootKind::List, &selection)?; + let plan: SqlPlan = match compile::compile_query( + &inner, + &session, + &role, + &model, + RootKind::List, + &selection, + )? { + compile::QueryPlan::Sql(plan) => plan, + compile::QueryPlan::CellByKey { .. } => { + return Err("cell-by-key store does not support @live".into()); + } + }; let footprint = footprint_from_tables(&plan.tables_touched); let mut change_rx = inner.change_hub.subscribe(); let (tx, rx) = mpsc::channel::(8); From 706a2e911b8e4445111b6acb8374eac8a2b78ba5 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 22:47:39 -0500 Subject: [PATCH 14/37] feat: optional celld+NATS e2e-ui profile Named profile under tests/e2e-ui/celld-nats-profile. Default one-process host.rs / make run is unchanged. Implements [[tasks/distributed-command-surfaces-6]] --- tests/celld/README.md | 4 + tests/e2e-ui/README.md | 3 + tests/e2e-ui/celld-nats-profile/README.md | 61 ++++ .../celld-nats-profile/docker-compose.yml | 25 ++ tests/e2e-ui/crates/service/src/host.rs | 7 +- tests/e2e_ui_celld_nats_profile/main.rs | 285 ++++++++++++++++++ 6 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 tests/e2e-ui/celld-nats-profile/README.md create mode 100644 tests/e2e-ui/celld-nats-profile/docker-compose.yml create mode 100644 tests/e2e_ui_celld_nats_profile/main.rs diff --git a/tests/celld/README.md b/tests/celld/README.md index 5ea307de3..38d49ce41 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -56,6 +56,10 @@ return the todo. Tear down: `docker compose -f tests/celld/docker-compose.yml down -v`. +Optional e2e-ui split (same Svelte app, not the default playground): +`tests/e2e-ui/celld-nats-profile/`. GraphQL wait-path → this cell HTTP; +NATS for Eventual events; SQL lists stay SQL. + ## Ports | Host | Inside compose | What | diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index 3933c013d..b271a0ff9 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -41,6 +41,9 @@ The UI is at `http://localhost:5180`; GraphQL is at `http://127.0.0.1:8791/graphql`. Demo users are `alice`, `bob`, and `admin` with password `Password1!`. +This is the **default one-process playground**. An optional celld+NATS +profile of the same UI lives in `celld-nats-profile/` and is not `make run`. + ## The developer experience The page code stays ordinary: diff --git a/tests/e2e-ui/celld-nats-profile/README.md b/tests/e2e-ui/celld-nats-profile/README.md new file mode 100644 index 000000000..3a161993b --- /dev/null +++ b/tests/e2e-ui/celld-nats-profile/README.md @@ -0,0 +1,61 @@ +# Optional celld + NATS profile (not the default playground) + +This directory is an **optional** split-process profile of the same e2e-ui +Svelte app. It is **not** `make run` and **not** a replacement for the +one-process playground (`DCS-DEC-001`, `ESM-REQ-009`). + +Default remains: + +```sh +cd tests/e2e-ui +make up # Postgres + Zitadel +make run # one backend + UI +``` + +`tests/e2e-ui/crates/service/src/host.rs` stays a single backend process. +Do not add this topology there. + +## What this profile is + +| Path | Where | +|---|---| +| GraphQL wait-path mutations | `HttpCommandHost` → celld `POST /todo/{id}/todo.create` (`{ commandId, input }`) | +| Fire-and-forget / events | NATS JetStream `publish` / `subscribe` | +| Todo / Chat lists | SQL read models (projectors subscribe on NATS, **not** in cells) | +| BlobGames by-id | `ReadStore::CellByKey` GET of the sealed row | +| `@live` / SQL joins on Blob | rejected by the cell-by-key compiler (`DCS-5`) | + +GraphQL, `@live`, and Eventual projectors are **not** cell class methods +(`DCS-AC-008.1`, `PCH-REQ-005`). + +## Bring-up (local only) + +Azurite + celld already live under `tests/celld/docker-compose.yml`. NATS +is extra and named so it cannot be confused with `tests/e2e-ui/docker`. + +```sh +# 1) celld + Azurite (no MinIO) +docker compose -f tests/celld/docker-compose.yml up -d --build azurite +# deploy worker, then: +docker compose -f tests/celld/docker-compose.yml up -d celld + +# 2) NATS for this optional profile only +docker compose -f tests/e2e-ui/celld-nats-profile/docker-compose.yml up -d + +export CELLD_URL=http://127.0.0.1:${CELLD_HTTP_PORT:-18080} +export NATS_URL=nats://127.0.0.1:${NATS_PORT:-14222} + +cargo test --test e2e_ui_celld_nats_profile --features graphql,http,sqlite +``` + +Without `CELLD_URL` **and** `NATS_URL`, the test still checks that the +default host is one-process and this profile is documented; live smoke +is skipped (`PCH-AC-006.1`). + +Tear down NATS only: `docker compose -f tests/e2e-ui/celld-nats-profile/docker-compose.yml down`. +Do not use that as `make down` for the playground. + +## Identity + +Reuse e2e-ui OIDC / DevHeaders. No new secret files. Azurite uses the +public emulator account already documented in `tests/celld`. diff --git a/tests/e2e-ui/celld-nats-profile/docker-compose.yml b/tests/e2e-ui/celld-nats-profile/docker-compose.yml new file mode 100644 index 000000000..022e4db09 --- /dev/null +++ b/tests/e2e-ui/celld-nats-profile/docker-compose.yml @@ -0,0 +1,25 @@ +# OPTIONAL — e2e-ui celld+NATS profile. +# Not the default playground (`tests/e2e-ui/docker/docker-compose.yml` + make run). +# Not a three-process GraphQL/commands/projectors matrix. +# +# NATS only. celld + Azurite stay in tests/celld/docker-compose.yml (no MinIO). +# +# docker compose -f tests/e2e-ui/celld-nats-profile/docker-compose.yml up -d +# NATS_URL=nats://127.0.0.1:14222 +# +# Project name is explicit so `docker compose ls` cannot confuse this with e2e-ui. + +name: e2e-ui-celld-nats-optional + +services: + nats: + image: nats:2-alpine + command: ["-js", "-m", "8222"] + ports: + - "${NATS_PORT:-14222}:4222" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8222/healthz >/dev/null || exit 1"] + interval: 2s + timeout: 2s + retries: 10 + start_period: 2s diff --git a/tests/e2e-ui/crates/service/src/host.rs b/tests/e2e-ui/crates/service/src/host.rs index 6561cfc2c..6f8525c03 100644 --- a/tests/e2e-ui/crates/service/src/host.rs +++ b/tests/e2e-ui/crates/service/src/host.rs @@ -1,7 +1,8 @@ //! One-screen host bootstrap for the e2e-ui application. //! //! This playground is a single backend process plus the SvelteKit UI. Do not -//! add extra e2e-ui process topologies here. +//! add extra e2e-ui process topologies here. Optional celld+NATS is +//! `tests/e2e-ui/celld-nats-profile/` (`DCS-DEC-001`). //! //! Dialect selection and identity remain here. Outbox/consumer loops use //! framework worker helpers. @@ -13,9 +14,7 @@ use distributed::bus::{PostgresBus, SqliteBus}; use distributed::command_dispatch::LocalCommandDispatcher; use distributed::graphql::IdentityConfig; use distributed::microsvc::{spawn_outbox_publish_loop, spawn_service_consumer_loop}; -use distributed::{ - PostgresLockManager, PostgresRepository, SqliteLockManager, SqliteRepository, -}; +use distributed::{PostgresLockManager, PostgresRepository, SqliteLockManager, SqliteRepository}; use crate::{ build_graphql_engine, build_service, distributed_manifest, serve_with_oidc, spawn_scrape_loop, diff --git a/tests/e2e_ui_celld_nats_profile/main.rs b/tests/e2e_ui_celld_nats_profile/main.rs new file mode 100644 index 000000000..57d247ba2 --- /dev/null +++ b/tests/e2e_ui_celld_nats_profile/main.rs @@ -0,0 +1,285 @@ +//! Optional celld+NATS e2e-ui profile. +//! +//! Fixture checks always run (default host stays one-process). Live smoke +//! runs only when `CELLD_URL` and `NATS_URL` are set. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +#[path = "../support/env.rs"] +mod env_support; + +fn repo_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +#[test] +fn default_e2e_ui_host_stays_one_process() { + let host = std::fs::read_to_string(repo_root().join("tests/e2e-ui/crates/service/src/host.rs")) + .expect("host.rs"); + assert!( + host.contains("single backend process"), + "default host.rs must remain the one-process playground" + ); + assert!( + host.contains("celld-nats-profile"), + "host.rs should point at the optional profile, not implement it" + ); + assert!( + !host.contains("NatsBus"), + "optional NATS profile must not replace SqliteBus/PostgresBus in host.rs" + ); +} + +#[test] +fn optional_profile_is_named_and_not_the_playground() { + let readme = + std::fs::read_to_string(repo_root().join("tests/e2e-ui/celld-nats-profile/README.md")) + .expect("profile README"); + assert!(readme.contains("optional"), "{readme}"); + assert!(readme.contains("make run"), "{readme}"); + assert!(readme.contains("CELLD_URL"), "{readme}"); + assert!(readme.contains("NATS_URL"), "{readme}"); + assert!( + readme.contains("not") && readme.contains("cell class"), + "projectors must stay off cells" + ); + + let compose = std::fs::read_to_string( + repo_root().join("tests/e2e-ui/celld-nats-profile/docker-compose.yml"), + ) + .expect("profile compose"); + assert!(compose.contains("e2e-ui-celld-nats-optional"), "{compose}"); + assert!(compose.contains("nats:2-alpine"), "{compose}"); + assert!( + !compose + .lines() + .any(|line| line.trim_start().starts_with("image:") && line.contains("minio")), + "do not run MinIO" + ); + + let worker = std::fs::read_to_string(repo_root().join("tests/celld/worker/src/lib.rs")) + .expect("todo cell worker"); + assert!( + worker.contains("projectors are not methods on this class"), + "cells stay command-only" + ); +} + +#[cfg(all(feature = "graphql", feature = "http", feature = "sqlite"))] +mod live { + use super::*; + use async_graphql::Request; + use distributed::command_dispatch::{HttpCommandHost, SharedCommandHost}; + use distributed::graphql::{ + read, typed_command, GraphqlEngine, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, + GraphqlTypeField, ModelPermissions, Succeeded, VerifiedPrincipal, + }; + use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; + use distributed::{ + Aggregate, AggregateBuilder, Entity, InMemoryRepository, ReadModel, Snapshot, + }; + use serde::{Deserialize, Serialize}; + + #[derive(Default, Snapshot)] + struct SchemaAgg { + entity: Entity, + } + + impl SchemaAgg { + fn record(&mut self, id: String) -> distributed::SourcedResult { + self.entity.set_id(id); + self.entity.digest_empty("todo.recorded") + } + } + + impl Aggregate for SchemaAgg { + type ReplayError = std::convert::Infallible; + fn aggregate_type() -> &'static str { + "optional-profile-todo" + } + fn entity(&self) -> &Entity { + &self.entity + } + fn entity_mut(&mut self) -> &mut Entity { + &mut self.entity + } + fn replay_event( + &mut self, + _event: &distributed::EventRecord, + ) -> Result<(), Self::ReplayError> { + Ok(()) + } + } + + #[derive(Clone, Deserialize, Serialize, ReadModel)] + #[readmodel(primary_key = ["id"])] + struct Todos { + #[readmodel(id)] + id: String, + title: String, + } + + #[derive(Deserialize)] + #[allow(dead_code)] + struct CreateInput { + id: String, + title: String, + } + + impl GraphqlInputType for CreateInput { + fn graphql_type() -> GraphqlTypeDef { + GraphqlTypeDef::new( + "CreateInput", + vec![ + GraphqlTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }, + GraphqlTypeField { + name: "title".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }, + ], + ) + .with_type_id(std::any::TypeId::of::()) + } + } + + #[derive(Serialize)] + struct IdPayload { + id: String, + } + + impl GraphqlOutputType for IdPayload { + fn graphql_type() -> GraphqlTypeDef { + GraphqlTypeDef::new( + "IdPayload", + vec![GraphqlTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + ) + .with_type_id(std::any::TypeId::of::()) + } + } + + fn schema_service() -> distributed::microsvc::Service { + distributed::microsvc::Service::new() + .named("optional-profile") + .routes( + distributed::microsvc::Routes::new() + .with_repo(InMemoryRepository::new().aggregate::()) + .typed_command( + typed_command::>("todo.create") + .roles(["user"]), + ) + .create() + .invoke(|aggregate, input, _owner| { + aggregate.record(input.id.clone())?; + Ok::<_, distributed::EventRecordError>(()) + }) + .succeeded(|aggregate| IdPayload { + id: aggregate.entity().id().to_string(), + }), + ) + } + + #[tokio::test] + async fn optional_profile_smoke_graphql_wait_path_and_sql_list() { + let Some(celld) = env_support::broker_env("CELLD_URL", "optional celld+NATS smoke") else { + return; + }; + let Some(nats) = env_support::broker_env("NATS_URL", "optional celld+NATS smoke") else { + return; + }; + + let nats_addr = nats + .trim() + .trim_start_matches("nats://") + .split('/') + .next() + .unwrap_or(nats.trim()); + let (nats_host, nats_port) = nats_addr.split_once(':').unwrap_or((nats_addr, "4222")); + let _ = tokio::net::TcpStream::connect((nats_host, nats_port.parse::().unwrap())) + .await + .expect("NATS TCP"); + + let todo_id = format!( + "dcs6-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let celld = celld.trim_end_matches('/'); + let host: SharedCommandHost = + Arc::new(HttpCommandHost::new(format!("{celld}/todo/{todo_id}"))); + + let pool = sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(); + sqlx::query("CREATE TABLE IF NOT EXISTS todos (id TEXT PRIMARY KEY, title TEXT)") + .execute(&pool) + .await + .ok(); + let schema = schema_service(); + let engine = GraphqlEngine::builder(pool) + .protocol_token_key([0x5a; 32]) + .roles(&["user"]) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .service(&schema) + .build() + .expect("optional-profile GraphQL engine"); + + let mut session = Session::new(); + session.set(USER_ID_KEY, "alice"); + session.set(ROLE_KEY, "user"); + let principal = VerifiedPrincipal::from_trusted_transport("alice"); + let command_id = "0190a000-0000-7000-8000-000000000310"; + let mutation = engine + .execute( + &session, + Request::new(format!( + r#"mutation {{ todo_create(commandId: "{command_id}", input: {{ id: "{todo_id}", title: "dcs6" }}) {{ id }} }}"# + )) + .data(Arc::clone(&host)) + .data(principal), + ) + .await; + assert!( + mutation.errors.is_empty(), + "GraphQL wait-path to cell: {mutation:?}" + ); + + let list = engine + .execute(&session, Request::new("{ todos { id title } }")) + .await; + assert!(list.errors.is_empty(), "SQL list query: {list:?}"); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .unwrap(); + let got: serde_json::Value = client + .get(format!("{celld}/todo/{todo_id}")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(got["title"], "dcs6", "sealed GET after wait-path: {got}"); + } +} From 316945827daafcd3db9c6e86f34747628e99bb2a Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 22:59:41 -0500 Subject: [PATCH 15/37] fix: GraphQL command status test injects CommandHost authorized_unknown_status_returns_only_public_state no longer puts Arc in request data. Implements [[tasks/distributed-command-surfaces-3]] --- src/graphql/schema.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index 4cbb71200..c94de546a 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -736,11 +736,9 @@ async fn resolve_root( let plan = compile::compile_query(&inner, &session, &role, model, kind, &selection) .map_err(|e| client_error("BAD_REQUEST", sanitize_compile_error(&e)))?; let value = match plan { - QueryPlan::CellByKey { model, pk } => { - execute_cell_by_key(&inner, &model, &pk, &selection) - .await - .map_err(|e| client_error("BAD_REQUEST", sanitize_compile_error(&e)))? - } + QueryPlan::CellByKey { model, pk } => execute_cell_by_key(&inner, &model, &pk, &selection) + .await + .map_err(|e| client_error("BAD_REQUEST", sanitize_compile_error(&e)))?, QueryPlan::Sql(plan) => { if let Some(protocol) = ctx.data_opt::().cloned() { let role_surface = inner.role_surfaces.get(&role).cloned().ok_or_else(|| { @@ -1116,6 +1114,7 @@ mod causal_command_schema_tests { use std::sync::Arc; use super::*; + use crate::command_dispatch::{LocalCommandHost, SharedCommandHost}; use crate::graphql::command_contract::{CommandConsistency, CommandEffects}; use crate::graphql::protocol::{ DistributedEnvelopeV1, ProtocolResponseAccumulator, ProtocolTokenCodec, @@ -1310,7 +1309,9 @@ mod causal_command_schema_tests { "{{ {COMMAND_STATUS_ROOT_FIELD}(commandId: \"{}\") {{ s: state }} }}", uuid::Uuid::now_v7() )) - .data(Arc::new(Service::new().named("status-test"))) + .data(Arc::new(LocalCommandHost::new(Arc::new( + Service::new().named("status-test"), + ))) as SharedCommandHost) .data(VerifiedPrincipal::test_oidc( "https://issuer.example/", "status-test-subject", From 47697e0a404d9c470ebd77efecda11a67695fc76 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 23:17:38 -0500 Subject: [PATCH 16/37] chore: add make up-celld-nats for the optional profile make run stays the one-process playground. Bring-up, smoke, and teardown of celld+NATS are named targets. Implements [[tasks/distributed-command-surfaces-6]] --- tests/celld/README.md | 4 +- tests/e2e-ui/Makefile | 76 ++++++++++++++++++++++- tests/e2e-ui/README.md | 3 +- tests/e2e-ui/celld-nats-profile/README.md | 20 ++++++ tests/e2e_ui_celld_nats_profile/main.rs | 2 + 5 files changed, 100 insertions(+), 5 deletions(-) diff --git a/tests/celld/README.md b/tests/celld/README.md index 38d49ce41..8618d4b75 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -57,8 +57,8 @@ return the todo. Tear down: `docker compose -f tests/celld/docker-compose.yml down -v`. Optional e2e-ui split (same Svelte app, not the default playground): -`tests/e2e-ui/celld-nats-profile/`. GraphQL wait-path → this cell HTTP; -NATS for Eventual events; SQL lists stay SQL. +`cd tests/e2e-ui && make up-celld-nats` then `make test-celld-nats`. +GraphQL wait-path → this cell HTTP; NATS for Eventual events; SQL lists stay SQL. ## Ports diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 0427d580c..b0a252194 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -4,10 +4,13 @@ # make run # API + UI (uses e2e-ui.env if present) # make test # offline unit/suite/UI structural # make test-browser # Playwright UI e2e (needs make up + make run) +# make up-celld-nats / test-celld-nats / down-celld-nats +# # optional celld+NATS profile (not make run) .PHONY: all up down run run-api stop test ci-offline test-domain test-suite \ test-browser test-browser-install js-install js-build wasm ui-install ui-build ui-check ui-test \ - gen-client check-client contracts-check check clean help + gen-client check-client contracts-check check clean help \ + up-celld-nats down-celld-nats down-celld test-celld-nats # Defaults only — do NOT `include e2e-ui.env` (shell-quoted dotenv breaks Make). # Recipes `source` the env file so values stay clean. @@ -25,6 +28,19 @@ CARGO_TEST_FLAGS ?= -- --nocapture DATABASE_URL ?= sqlite:./e2e-ui.db?mode=rwc +# Optional celld+NATS profile (same UI, not the one-process playground). +REPO_ROOT := $(abspath ../..) +CELLD_COMPOSE := ../celld/docker-compose.yml +PROFILE_COMPOSE := celld-nats-profile/docker-compose.yml +CELLD_HTTP_PORT ?= 18080 +NATS_PORT ?= 14222 +CELLD_URL ?= http://127.0.0.1:$(CELLD_HTTP_PORT) +NATS_URL ?= nats://127.0.0.1:$(NATS_PORT) +# Public Azurite emulator account (already in tests/celld compose). Not a secret. +AZURE_STORAGE_USE_EMULATOR ?= true +AZURE_STORAGE_ACCOUNT_NAME ?= devstoreaccount1 +AZURE_STORAGE_ACCOUNT_KEY ?= Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + all: run ## Docker stack + OIDC bootstrap → e2e-ui.env @@ -97,6 +113,58 @@ stop: @rm -f .make-runner.pid .make-ui.pid @echo stopped +## Optional celld + NATS profile of the same UI. Does NOT replace make run. +## Brings up tests/celld (Azurite + celld) and celld-nats-profile (NATS only). +up-celld-nats: + @set -e; \ + command -v docker >/dev/null || { echo "docker required"; exit 1; }; \ + command -v celld >/dev/null || { echo "celld CLI required: curl -fsSL https://celld.dev/install.sh | sh"; exit 1; }; \ + command -v worker-build >/dev/null || { echo "worker-build required: cargo install worker-build"; exit 1; }; \ + echo "optional profile — not make run"; \ + echo "NATS: $(PROFILE_COMPOSE)"; \ + docker compose -f $(PROFILE_COMPOSE) up -d; \ + echo "celld + Azurite: $(CELLD_COMPOSE)"; \ + docker compose -f $(CELLD_COMPOSE) up -d azurite; \ + docker compose -f $(CELLD_COMPOSE) up --exit-code-from azurite-init azurite-init; \ + export AZURE_STORAGE_USE_EMULATOR="$(AZURE_STORAGE_USE_EMULATOR)"; \ + export AZURE_STORAGE_ACCOUNT_NAME="$(AZURE_STORAGE_ACCOUNT_NAME)"; \ + export AZURE_STORAGE_ACCOUNT_KEY="$(AZURE_STORAGE_ACCOUNT_KEY)"; \ + echo "building Todo cell worker…"; \ + ( cd ../celld/worker && worker-build --release ); \ + echo "deploying worker to az://celld…"; \ + ( cd $(REPO_ROOT) && celld deploy tests/celld/worker --bucket az://celld ); \ + docker compose -f $(CELLD_COMPOSE) up -d celld; \ + docker compose -f $(CELLD_COMPOSE) restart celld; \ + ok=0; \ + for i in $$(seq 1 40); do \ + code=$$(curl -s -o /dev/null -w '%{http_code}' "$(CELLD_URL)/health" 2>/dev/null || echo 000); \ + if [ "$$code" = "200" ]; then ok=1; break; fi; \ + sleep 0.5; \ + done; \ + if [ "$$ok" != "1" ]; then echo "celld not healthy at $(CELLD_URL)/health"; exit 1; fi; \ + echo ""; \ + echo " CELLD_URL $(CELLD_URL)"; \ + echo " NATS_URL $(NATS_URL)"; \ + echo " smoke: make test-celld-nats"; \ + echo " stop: make down-celld-nats (NATS only)"; \ + echo " make down-celld (Azurite + celld)"; \ + echo " playground remains: make run"; \ + echo "" + +down-celld-nats: + docker compose -f $(PROFILE_COMPOSE) down + @echo "NATS profile stopped. celld/Azurite untouched (make down-celld). playground untouched (make down)." + +down-celld: + docker compose -f $(CELLD_COMPOSE) down + @echo "celld + Azurite stopped. NATS: make down-celld-nats. playground: make down." + +test-celld-nats: + @echo "optional profile smoke — default make test / make run unchanged" + cd $(REPO_ROOT) && \ + CELLD_URL="$(CELLD_URL)" NATS_URL="$(NATS_URL)" \ + cargo test --test e2e_ui_celld_nats_profile --features graphql,http,sqlite -- --nocapture + test: test-domain test-suite ui-install ui-build ui-check ui-test @echo "OK — offline domain + suite + UI build + typecheck + structural tests" @@ -196,5 +264,9 @@ help: @echo " make wasm blob-domain core → ui/src/lib/blob/pkg (wasm-pack)" @echo " make gen-client typed Service → generated user/admin clients" @echo " make check-client verify generated artifacts byte-for-byte" - @echo " make down docker compose down" + @echo " make down playground docker compose down (not celld/NATS)" + @echo " make up-celld-nats optional celld+NATS profile (not make run)" + @echo " make test-celld-nats cargo test --test e2e_ui_celld_nats_profile" + @echo " make down-celld-nats stop NATS profile only" + @echo " make down-celld stop tests/celld Azurite + celld" @echo " GRAPHIQL=0 disable GraphiQL when running the API" diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index b271a0ff9..b41b92099 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -42,7 +42,8 @@ The UI is at `http://localhost:5180`; GraphQL is at with password `Password1!`. This is the **default one-process playground**. An optional celld+NATS -profile of the same UI lives in `celld-nats-profile/` and is not `make run`. +profile of the same UI is `make up-celld-nats` / `make test-celld-nats` +(`celld-nats-profile/`); it is not `make run`. ## The developer experience diff --git a/tests/e2e-ui/celld-nats-profile/README.md b/tests/e2e-ui/celld-nats-profile/README.md index 3a161993b..52dd119fa 100644 --- a/tests/e2e-ui/celld-nats-profile/README.md +++ b/tests/e2e-ui/celld-nats-profile/README.md @@ -12,6 +12,16 @@ make up # Postgres + Zitadel make run # one backend + UI ``` +Optional profile: + +```sh +cd tests/e2e-ui +make up-celld-nats # Azurite + celld + NATS (not make run) +make test-celld-nats # GraphQL wait-path smoke + SQL list +make down-celld-nats # NATS only +make down-celld # Azurite + celld +``` + `tests/e2e-ui/crates/service/src/host.rs` stays a single backend process. Do not add this topology there. @@ -33,6 +43,16 @@ GraphQL, `@live`, and Eventual projectors are **not** cell class methods Azurite + celld already live under `tests/celld/docker-compose.yml`. NATS is extra and named so it cannot be confused with `tests/e2e-ui/docker`. +```sh +cd tests/e2e-ui +make up-celld-nats +make test-celld-nats +``` + +Override ports if busy: `CELLD_HTTP_PORT=18880 NATS_PORT=14222 make up-celld-nats`. + +Manual equivalent (same as the Make recipes): + ```sh # 1) celld + Azurite (no MinIO) docker compose -f tests/celld/docker-compose.yml up -d --build azurite diff --git a/tests/e2e_ui_celld_nats_profile/main.rs b/tests/e2e_ui_celld_nats_profile/main.rs index 57d247ba2..fd392d3a8 100644 --- a/tests/e2e_ui_celld_nats_profile/main.rs +++ b/tests/e2e_ui_celld_nats_profile/main.rs @@ -39,6 +39,8 @@ fn optional_profile_is_named_and_not_the_playground() { .expect("profile README"); assert!(readme.contains("optional"), "{readme}"); assert!(readme.contains("make run"), "{readme}"); + assert!(readme.contains("make up-celld-nats"), "{readme}"); + assert!(readme.contains("make test-celld-nats"), "{readme}"); assert!(readme.contains("CELLD_URL"), "{readme}"); assert!(readme.contains("NATS_URL"), "{readme}"); assert!( From ae8d8a67eb43bb0b088303f565873e210211b60c Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 23:20:31 -0500 Subject: [PATCH 17/37] fix: make up-celld-nats tolerate an occupied NATS port Reuse a running compose NATS; if 14222 is taken by something else, print the listener and how to override NATS_PORT. down-celld-nats also removes a stray docker-run container. Implements [[tasks/distributed-command-surfaces-6]] --- tests/e2e-ui/Makefile | 12 +++++++++++- tests/e2e-ui/celld-nats-profile/README.md | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index b0a252194..e24c46b93 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -122,7 +122,16 @@ up-celld-nats: command -v worker-build >/dev/null || { echo "worker-build required: cargo install worker-build"; exit 1; }; \ echo "optional profile — not make run"; \ echo "NATS: $(PROFILE_COMPOSE)"; \ - docker compose -f $(PROFILE_COMPOSE) up -d; \ + if docker compose -f $(PROFILE_COMPOSE) ps --status running --services 2>/dev/null | grep -qx nats; then \ + echo "NATS compose already running on $(NATS_URL)"; \ + elif nc -z 127.0.0.1 $(NATS_PORT) 2>/dev/null; then \ + echo "port $(NATS_PORT) is already in use (not this compose project)."; \ + echo "stop the other listener, or: NATS_PORT=14223 make up-celld-nats"; \ + docker ps --format 'table {{.Names}}\t{{.Ports}}' | grep -E '14222|nats' || true; \ + exit 1; \ + else \ + docker compose -f $(PROFILE_COMPOSE) up -d; \ + fi; \ echo "celld + Azurite: $(CELLD_COMPOSE)"; \ docker compose -f $(CELLD_COMPOSE) up -d azurite; \ docker compose -f $(CELLD_COMPOSE) up --exit-code-from azurite-init azurite-init; \ @@ -153,6 +162,7 @@ up-celld-nats: down-celld-nats: docker compose -f $(PROFILE_COMPOSE) down + -@docker rm -f e2e-ui-celld-nats-optional >/dev/null 2>&1 || true @echo "NATS profile stopped. celld/Azurite untouched (make down-celld). playground untouched (make down)." down-celld: diff --git a/tests/e2e-ui/celld-nats-profile/README.md b/tests/e2e-ui/celld-nats-profile/README.md index 52dd119fa..378ce2da3 100644 --- a/tests/e2e-ui/celld-nats-profile/README.md +++ b/tests/e2e-ui/celld-nats-profile/README.md @@ -49,7 +49,8 @@ make up-celld-nats make test-celld-nats ``` -Override ports if busy: `CELLD_HTTP_PORT=18880 NATS_PORT=14222 make up-celld-nats`. +Override ports if busy: `CELLD_HTTP_PORT=18880 NATS_PORT=14223 make up-celld-nats`. +If `14222` is already taken by a leftover `docker run` NATS, `make down-celld-nats` removes that container too. Manual equivalent (same as the Make recipes): From 50f7d3f1fc5b8923805b717fc0cc3351c8947cdd Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 23:58:56 -0500 Subject: [PATCH 18/37] feat: serve GraphQL WS through graphql_router_with_host CommandHost routers need /graphql/ws for live chat. Export ProtocolResponseAccumulator so out-of-crate hosts can implement CommandHost, and let wait-path clients remap payload JSON. Implements [[tasks/distributed-command-surfaces-7]] --- src/graphql/http.rs | 47 +++++++++++++++++++++++++--------- src/graphql/protocol/mod.rs | 3 ++- src/microsvc/service/causal.rs | 6 +++++ 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/graphql/http.rs b/src/graphql/http.rs index fd083b2b0..4a5e1ea04 100644 --- a/src/graphql/http.rs +++ b/src/graphql/http.rs @@ -10,7 +10,7 @@ use axum::extract::ws::WebSocketUpgrade; use axum::extract::{DefaultBodyLimit, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{Html, IntoResponse, Response}; -use axum::routing::post; +use axum::routing::{get, post}; use axum::Router; use futures_util::stream::BoxStream; @@ -236,16 +236,18 @@ pub fn graphql_router_with_host(engine: Arc, host: SharedCommandH engine, host: Some(host), }; - let mut router = Router::new().route( - "/graphql", - post(graphql_handler_with_service).get(move || async move { - if graphiql { - graphiql_page().into_response() - } else { - axum::http::StatusCode::METHOD_NOT_ALLOWED.into_response() - } - }), - ); + let mut router = Router::new() + .route( + "/graphql", + post(graphql_handler_with_service).get(move || async move { + if graphiql { + graphiql_page().into_response() + } else { + axum::http::StatusCode::METHOD_NOT_ALLOWED.into_response() + } + }), + ) + .route("/graphql/ws", get(graphql_ws_with_host)); router = router.layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES)); router.with_state(state) } @@ -372,7 +374,28 @@ pub async fn microsvc_graphql_ws( Some(e) => e, None => return StatusCode::NOT_FOUND.into_response(), }; + let host: SharedCommandHost = Arc::new(LocalCommandHost::new(Arc::clone(&service))); + graphql_ws_upgrade(engine, Some(host), headers, uri, protocol, upgrade).await +} +async fn graphql_ws_with_host( + State(state): State, + headers: HeaderMap, + uri: axum::http::Uri, + protocol: GraphQLProtocol, + upgrade: WebSocketUpgrade, +) -> Response { + graphql_ws_upgrade(state.engine, state.host, headers, uri, protocol, upgrade).await +} + +async fn graphql_ws_upgrade( + engine: Arc, + host: Option, + headers: HeaderMap, + uri: axum::http::Uri, + protocol: GraphQLProtocol, + upgrade: WebSocketUpgrade, +) -> Response { let mut upgrade_headers = headers; merge_identity_query_params(&mut upgrade_headers, uri.query()); let mode = engine.identity_config().mode; @@ -399,7 +422,7 @@ pub async fn microsvc_graphql_ws( Arc::clone(&engine), upgrade_session.clone(), upgrade_principal, - Some(Arc::new(LocalCommandHost::new(Arc::clone(&service))) as SharedCommandHost), + host, ); let engine_for_init = Arc::clone(&engine); upgrade diff --git a/src/graphql/protocol/mod.rs b/src/graphql/protocol/mod.rs index 7bb04f9ff..d70b8325d 100644 --- a/src/graphql/protocol/mod.rs +++ b/src/graphql/protocol/mod.rs @@ -12,7 +12,8 @@ mod tests; mod token; mod types; -pub(crate) use accumulator::{issue_projection_obligation_token, ProtocolResponseAccumulator}; +pub use accumulator::ProtocolResponseAccumulator; +pub(crate) use accumulator::issue_projection_obligation_token; pub(crate) use projection_metadata::{ CommandProjectionLifecycleProofV1, CommandProjectionMetadataError, CommandProjectionMetadataV1, CommandProjectionObligationV1, MAX_COMMAND_PROJECTION_OBLIGATIONS, diff --git a/src/microsvc/service/causal.rs b/src/microsvc/service/causal.rs index 8a556671a..ca2555837 100644 --- a/src/microsvc/service/causal.rs +++ b/src/microsvc/service/causal.rs @@ -226,6 +226,12 @@ impl CausalDispatchResult { &self.payload } + /// Replace the handler payload (wait-path clients remapping wire JSON). + pub fn with_payload(mut self, payload: Value) -> Self { + self.payload = payload; + self + } + /// Client-supplied durable command id. pub fn command_id(&self) -> &str { &self.receipt.command_id From 9a2265872e4688feda173413316b68b15e154baa Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 23:59:01 -0500 Subject: [PATCH 19/37] feat: add e2e-celld GraphQL host wait-dispatching Todo to celld Sibling example of e2e-ui (not make run). New todo/chat/blob/graphql service crates reuse the e2e-ui domain crates. Todo create/complete go through HttpCommandHost to {CELLD_URL}/todo/{id}/{command}; SQL lists dual-write locally so the playground UI can render. Implements [[tasks/distributed-command-surfaces-7]] --- tests/e2e-celld/.gitignore | 5 + tests/e2e-celld/Cargo.toml | 28 + tests/e2e-celld/Makefile | 102 +++ tests/e2e-celld/README.md | 32 + .../e2e-celld/crates/blob-service/Cargo.toml | 10 + .../crates/blob-service/src/bounds.rs | 40 + .../e2e-celld/crates/blob-service/src/lib.rs | 6 + .../crates/blob-service/src/routes.rs | 44 ++ .../e2e-celld/crates/chat-service/Cargo.toml | 16 + .../crates/chat-service/src/bounds.rs | 40 + .../e2e-celld/crates/chat-service/src/deps.rs | 12 + .../chat-service/src/handlers/events/mod.rs | 2 + .../src/handlers/events/project_auth_user.rs | 53 ++ .../handlers/events/project_chat_messages.rs | 11 + .../src/handlers/ingestors/mod.rs | 6 + .../src/handlers/ingestors/zitadel/auth.rs | 148 ++++ .../src/handlers/ingestors/zitadel/handle.rs | 59 ++ .../src/handlers/ingestors/zitadel/map.rs | 424 ++++++++++ .../src/handlers/ingestors/zitadel/mod.rs | 54 ++ .../src/handlers/ingestors/zitadel/publish.rs | 22 + .../src/handlers/ingestors/zitadel/scrape.rs | 510 ++++++++++++ .../src/handlers/ingestors/zitadel_scrape.rs | 52 ++ .../crates/chat-service/src/handlers/mod.rs | 3 + .../crates/chat-service/src/handlers/util.rs | 151 ++++ .../e2e-celld/crates/chat-service/src/lib.rs | 11 + .../crates/chat-service/src/routes.rs | 61 ++ .../crates/graphql-service/Cargo.toml | 25 + .../crates/graphql-service/src/application.rs | 32 + .../crates/graphql-service/src/bounds.rs | 40 + .../crates/graphql-service/src/host.rs | 164 ++++ .../crates/graphql-service/src/lib.rs | 26 + .../graphql-service/src/modules/compose.rs | 66 ++ .../graphql-service/src/modules/graphql.rs | 728 ++++++++++++++++++ .../crates/graphql-service/src/modules/mod.rs | 8 + .../src/modules/projections.rs | 43 ++ .../crates/graphql-service/src/oidc_layer.rs | 312 ++++++++ tests/e2e-celld/crates/runner/Cargo.toml | 14 + tests/e2e-celld/crates/runner/src/main.rs | 31 + .../e2e-celld/crates/todo-service/Cargo.toml | 13 + .../crates/todo-service/src/bounds.rs | 40 + .../crates/todo-service/src/handlers/mod.rs | 1 + .../src/handlers/project_todos.rs | 11 + .../e2e-celld/crates/todo-service/src/host.rs | 128 +++ .../e2e-celld/crates/todo-service/src/lib.rs | 13 + .../crates/todo-service/src/routes.rs | 48 ++ 45 files changed, 3645 insertions(+) create mode 100644 tests/e2e-celld/.gitignore create mode 100644 tests/e2e-celld/Cargo.toml create mode 100644 tests/e2e-celld/Makefile create mode 100644 tests/e2e-celld/README.md create mode 100644 tests/e2e-celld/crates/blob-service/Cargo.toml create mode 100644 tests/e2e-celld/crates/blob-service/src/bounds.rs create mode 100644 tests/e2e-celld/crates/blob-service/src/lib.rs create mode 100644 tests/e2e-celld/crates/blob-service/src/routes.rs create mode 100644 tests/e2e-celld/crates/chat-service/Cargo.toml create mode 100644 tests/e2e-celld/crates/chat-service/src/bounds.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/deps.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/events/mod.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/events/project_auth_user.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/events/project_chat_messages.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/ingestors/mod.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/auth.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/handle.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/map.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/mod.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/publish.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/scrape.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel_scrape.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/mod.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/handlers/util.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/lib.rs create mode 100644 tests/e2e-celld/crates/chat-service/src/routes.rs create mode 100644 tests/e2e-celld/crates/graphql-service/Cargo.toml create mode 100644 tests/e2e-celld/crates/graphql-service/src/application.rs create mode 100644 tests/e2e-celld/crates/graphql-service/src/bounds.rs create mode 100644 tests/e2e-celld/crates/graphql-service/src/host.rs create mode 100644 tests/e2e-celld/crates/graphql-service/src/lib.rs create mode 100644 tests/e2e-celld/crates/graphql-service/src/modules/compose.rs create mode 100644 tests/e2e-celld/crates/graphql-service/src/modules/graphql.rs create mode 100644 tests/e2e-celld/crates/graphql-service/src/modules/mod.rs create mode 100644 tests/e2e-celld/crates/graphql-service/src/modules/projections.rs create mode 100644 tests/e2e-celld/crates/graphql-service/src/oidc_layer.rs create mode 100644 tests/e2e-celld/crates/runner/Cargo.toml create mode 100644 tests/e2e-celld/crates/runner/src/main.rs create mode 100644 tests/e2e-celld/crates/todo-service/Cargo.toml create mode 100644 tests/e2e-celld/crates/todo-service/src/bounds.rs create mode 100644 tests/e2e-celld/crates/todo-service/src/handlers/mod.rs create mode 100644 tests/e2e-celld/crates/todo-service/src/handlers/project_todos.rs create mode 100644 tests/e2e-celld/crates/todo-service/src/host.rs create mode 100644 tests/e2e-celld/crates/todo-service/src/lib.rs create mode 100644 tests/e2e-celld/crates/todo-service/src/routes.rs diff --git a/tests/e2e-celld/.gitignore b/tests/e2e-celld/.gitignore new file mode 100644 index 000000000..484b142dd --- /dev/null +++ b/tests/e2e-celld/.gitignore @@ -0,0 +1,5 @@ +/target +e2e-celld.db +.make-runner.pid +.make-ui.pid +.make-runner.log diff --git a/tests/e2e-celld/Cargo.toml b/tests/e2e-celld/Cargo.toml new file mode 100644 index 000000000..2a1a72a02 --- /dev/null +++ b/tests/e2e-celld/Cargo.toml @@ -0,0 +1,28 @@ +# Sibling of tests/e2e-ui. Same domain crates; new service crates. +# GraphQL wait-dispatches Todo create/complete to celld. +[workspace] +resolver = "2" +members = [ + "crates/todo-service", + "crates/chat-service", + "crates/blob-service", + "crates/graphql-service", + "crates/runner", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[workspace.dependencies] +distributed = { path = "../..", features = ["sqlite", "postgres", "http", "graphql", "metrics"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } +thiserror = "1" +axum = "0.8" +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite", "postgres"] } +async-trait = "0.1" diff --git a/tests/e2e-celld/Makefile b/tests/e2e-celld/Makefile new file mode 100644 index 000000000..a88c9df5f --- /dev/null +++ b/tests/e2e-celld/Makefile @@ -0,0 +1,102 @@ +# Celld GraphQL example — sibling of tests/e2e-ui, not make run there. +# +# make -C tests/e2e-ui up-celld-nats # Azurite + celld + NATS +# make run # this GraphQL host + the e2e-ui Svelte app + +.PHONY: run stop help wasm + +BIND ?= 127.0.0.1:8791 +API_PORT ?= 8791 +UI_PORT ?= 5180 +UI_HOST ?= localhost +UI_URL ?= http://localhost:5180 +ENV_FILE ?= ../e2e-ui/e2e-ui.env +UI_DIR ?= ../e2e-ui/ui +CELLD_HTTP_PORT ?= 18080 +CELLD_URL ?= http://127.0.0.1:$(CELLD_HTTP_PORT) +NPM ?= npm + +wasm: + $(MAKE) -C ../e2e-ui wasm + +run: wasm + @set -e; \ + if [ -f $(ENV_FILE) ]; then set -a; . ./$(ENV_FILE); set +a; fi; \ + export DATABASE_URL="$${E2E_CELLD_DATABASE_URL:-sqlite:./e2e-celld.db?mode=rwc}"; \ + if [ -n "$${OIDC_ISSUER:-}" ] && ! curl -sf "$${OIDC_JWKS_URI:-$$OIDC_ISSUER/oauth/v2/keys}" >/dev/null 2>&1; then \ + echo "OIDC issuer not reachable ($$OIDC_ISSUER) — DevHeaders until: make -C ../e2e-ui up"; \ + unset OIDC_ISSUER OIDC_AUDIENCE OIDC_JWKS_URI; \ + fi; \ + _celld="$(CELLD_URL)"; \ + code=$$(curl -s -o /dev/null -w '%{http_code}' "$${_celld}/health" 2>/dev/null || echo 000); \ + if [ "$$code" != "200" ]; then \ + if curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:18880/health" 2>/dev/null | grep -q 200; then \ + _celld="http://127.0.0.1:18880"; \ + else \ + echo "celld not healthy at $$_celld — start: make -C ../e2e-ui up-celld-nats"; \ + echo "or: CELLD_HTTP_PORT=18880 make run"; \ + exit 1; \ + fi; \ + fi; \ + export CELLD_URL="$$_celld"; \ + export PUBLIC_E2E_PROFILE="celld-nats"; \ + _bind="$${BIND:-127.0.0.1:8791}"; \ + _api_port="$${_bind##*:}"; \ + _base="$${E2E_API_ORIGIN:-http://$${_bind}}"; \ + _ui_port="$(UI_PORT)"; \ + _ui_host="$(UI_HOST)"; \ + _ui="$${E2E_UI_ORIGIN:-http://$${_ui_host}:$${_ui_port}}"; \ + export AUTH_URL="$${_ui}"; \ + export AUTH_USE_SECURE_COOKIES="false"; \ + lsof -ti:$${_api_port} 2>/dev/null | xargs kill -9 2>/dev/null || true; \ + lsof -ti:$${_ui_port} 2>/dev/null | xargs kill -9 2>/dev/null || true; \ + rm -f .make-runner.pid .make-ui.pid .make-runner.log; \ + echo "starting e2e-celld API on $$_base (CELLD_URL=$$CELLD_URL) …"; \ + cargo run -p e2e-celld-runner --bin e2e-celld > .make-runner.log 2>&1 & \ + echo $$! > .make-runner.pid; \ + ok=0; \ + for i in $$(seq 1 240); do \ + code=$$(curl -s -o /dev/null -w '%{http_code}' -X POST "$${_base}/graphql" \ + -H 'content-type: application/json' \ + -d '{"query":"{ __typename }"}' 2>/dev/null || echo 000); \ + if [ "$$code" = "200" ] || [ "$$code" = "401" ]; then ok=1; break; fi; \ + sleep 0.5; \ + done; \ + if [ "$$ok" != "1" ]; then \ + echo "API failed:"; tail -80 .make-runner.log; exit 1; \ + fi; \ + echo "API ready (HTTP probe $$code). starting UI from tests/e2e-ui/ui …"; \ + cd $(UI_DIR) && PUBLIC_E2E_PROFILE=celld-nats E2E_API_ORIGIN="$$_base" $(NPM) run dev -- --host $$_ui_host --port $$_ui_port & \ + echo $$! > $(CURDIR)/.make-ui.pid; \ + cd $(CURDIR); \ + cleanup() { \ + [ -f .make-ui.pid ] && kill $$(cat .make-ui.pid) 2>/dev/null || true; \ + [ -f .make-runner.pid ] && kill $$(cat .make-runner.pid) 2>/dev/null || true; \ + lsof -ti:$${_api_port} 2>/dev/null | xargs kill -9 2>/dev/null || true; \ + lsof -ti:$${_ui_port} 2>/dev/null | xargs kill -9 2>/dev/null || true; \ + rm -f .make-runner.pid .make-ui.pid; \ + }; \ + trap cleanup EXIT INT TERM; \ + echo ""; \ + echo " UI $$_ui (celld badge in the navbar)"; \ + echo " API $$_base"; \ + echo " CELLD $$CELLD_URL"; \ + echo " GraphiQL $$_base/graphql"; \ + echo " this is tests/e2e-celld — not tests/e2e-ui make run"; \ + echo " Ctrl-C stops both"; \ + echo ""; \ + wait $$(cat .make-ui.pid) 2>/dev/null || wait + +stop: + @if [ -f .make-ui.pid ]; then kill $$(cat .make-ui.pid) 2>/dev/null || true; fi + @if [ -f .make-runner.pid ]; then kill $$(cat .make-runner.pid) 2>/dev/null || true; fi + @lsof -ti:$(API_PORT) 2>/dev/null | xargs kill -9 2>/dev/null || true + @lsof -ti:$(UI_PORT) 2>/dev/null | xargs kill -9 2>/dev/null || true + @rm -f .make-runner.pid .make-ui.pid + @echo stopped + +help: + @echo "e2e-celld (new example — not tests/e2e-ui)" + @echo " make run GraphQL host + e2e-ui Svelte app (Todo create/complete → celld)" + @echo " make stop stop API + UI" + @echo " infra: make -C ../e2e-ui up-celld-nats" diff --git a/tests/e2e-celld/README.md b/tests/e2e-celld/README.md new file mode 100644 index 000000000..cfca04c83 --- /dev/null +++ b/tests/e2e-celld/README.md @@ -0,0 +1,32 @@ +# e2e-celld + +New example, sibling of `tests/e2e-ui`. It is **not** `make run` in e2e-ui. + +| Crate | Role | +|---|---| +| `todo-domain` / `chat-domain` / `blob-domain` | **same** domain crates as e2e-ui (path deps) | +| `e2e-celld-todo` | Todo mounts + `HttpCommandHost` to celld | +| `e2e-celld-chat` | Chat + Zitadel ingestor (in-process) | +| `e2e-celld-blob` | Blob Atomic commands (in-process) | +| `e2e-celld-graphql` | GraphQL process (`graphql_router_with_host`) | +| `tests/celld/worker` | Todo cell (already existed) | + +GraphQL mutations `todo.create` / `todo.complete` wait-dispatch to +`POST {CELLD_URL}/todo/{id}/{command}`. SQL lists fill by dual-writing the +local Todo service after the cell wait-path succeeds. Chat and Blob stay +in-process. GraphQL and projectors are not cell class methods. + +```sh +cd tests/e2e-ui +make up # Zitadel + Postgres for the Svelte login (optional) +make up-celld-nats # Azurite + celld + NATS + +cd ../e2e-celld +make run # GraphQL :8791 + UI :5180 +``` + +Open `http://localhost:5180`. The navbar shows a **celld** badge. Sign in +(`alice` / `Password1!` when Zitadel is up) and use Todos — create/complete +go to celld. + +Override a busy celld port: `CELLD_HTTP_PORT=18880 make run`. diff --git a/tests/e2e-celld/crates/blob-service/Cargo.toml b/tests/e2e-celld/crates/blob-service/Cargo.toml new file mode 100644 index 000000000..a6bfe3e76 --- /dev/null +++ b/tests/e2e-celld/crates/blob-service/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "e2e-celld-blob" +version.workspace = true +edition.workspace = true +publish = false +description = "Blob Atomic command service crate (in-process; not a cell)" + +[dependencies] +distributed = { workspace = true } +blob-domain = { path = "../../../e2e-ui/crates/blob-domain" } diff --git a/tests/e2e-celld/crates/blob-service/src/bounds.rs b/tests/e2e-celld/crates/blob-service/src/bounds.rs new file mode 100644 index 000000000..dbaf42fe0 --- /dev/null +++ b/tests/e2e-celld/crates/blob-service/src/bounds.rs @@ -0,0 +1,40 @@ +//! Trait aliases for generic handler storage parameters. + +use distributed::microsvc::{CausalProjectionStore, CausalRepositoryBackend}; +use distributed::{ + GetStream, LockManager, ReadModelWritePlanStore, RelationalReadModelQueryStore, + TransactionalCommit, +}; + +pub trait EventStore: + CausalRepositoryBackend + GetStream + TransactionalCommit + Clone + Send + Sync + 'static +{ +} +impl EventStore for T where + T: CausalRepositoryBackend + GetStream + TransactionalCommit + Clone + Send + Sync + 'static +{ +} + +pub trait Locks: LockManager + Clone + 'static {} +impl Locks for T where T: LockManager + Clone + 'static {} + +pub trait ReadStore: + CausalProjectionStore + + ReadModelWritePlanStore + + RelationalReadModelQueryStore + + Clone + + Send + + Sync + + 'static +{ +} +impl ReadStore for T where + T: CausalProjectionStore + + ReadModelWritePlanStore + + RelationalReadModelQueryStore + + Clone + + Send + + Sync + + 'static +{ +} diff --git a/tests/e2e-celld/crates/blob-service/src/lib.rs b/tests/e2e-celld/crates/blob-service/src/lib.rs new file mode 100644 index 000000000..8da26b142 --- /dev/null +++ b/tests/e2e-celld/crates/blob-service/src/lib.rs @@ -0,0 +1,6 @@ +//! Blob Atomic command service crate (in-process; not a cell). + +mod bounds; +mod routes; + +pub use routes::{routes, MODULE_ID}; diff --git a/tests/e2e-celld/crates/blob-service/src/routes.rs b/tests/e2e-celld/crates/blob-service/src/routes.rs new file mode 100644 index 000000000..ed2c5d85b --- /dev/null +++ b/tests/e2e-celld/crates/blob-service/src/routes.rs @@ -0,0 +1,44 @@ +//! Blob game module: Atomic command mounts (direct projection seal). + +use blob_domain::BlobGame; +use distributed::graphql::SurfaceDirectProjection; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; + +use crate::bounds::{EventStore, Locks, ReadStore}; + +/// Logical module id for composition inventories. +pub const MODULE_ID: &str = "blob"; + +type BlobRoutes = + Routes, BlobGame>, S>>; + +/// Mount blob Atomic commands from blob-domain. +pub fn routes( + repo: R, + locks: L, + read_models: S, + _blob_direct: SurfaceDirectProjection, +) -> BlobRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, BlobGame>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + let _ = _blob_direct; + Routes::for_aggregate::(repo, locks, read_models) + .mount(blob_domain::commands::start()) + .mount(blob_domain::commands::move_dir()) + .mount(blob_domain::commands::start_level()) +} diff --git a/tests/e2e-celld/crates/chat-service/Cargo.toml b/tests/e2e-celld/crates/chat-service/Cargo.toml new file mode 100644 index 000000000..8a4a8dd7b --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "e2e-celld-chat" +version.workspace = true +edition.workspace = true +publish = false +description = "Chat + identity-ingestor service crate (in-process; not a cell)" + +[dependencies] +distributed = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +reqwest = { workspace = true } +chat-domain = { path = "../../../e2e-ui/crates/chat-domain" } +e2e-projections = { path = "../../../e2e-ui/crates/projections" } +e2e-readmodels = { path = "../../../e2e-ui/crates/readmodels" } diff --git a/tests/e2e-celld/crates/chat-service/src/bounds.rs b/tests/e2e-celld/crates/chat-service/src/bounds.rs new file mode 100644 index 000000000..dbaf42fe0 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/bounds.rs @@ -0,0 +1,40 @@ +//! Trait aliases for generic handler storage parameters. + +use distributed::microsvc::{CausalProjectionStore, CausalRepositoryBackend}; +use distributed::{ + GetStream, LockManager, ReadModelWritePlanStore, RelationalReadModelQueryStore, + TransactionalCommit, +}; + +pub trait EventStore: + CausalRepositoryBackend + GetStream + TransactionalCommit + Clone + Send + Sync + 'static +{ +} +impl EventStore for T where + T: CausalRepositoryBackend + GetStream + TransactionalCommit + Clone + Send + Sync + 'static +{ +} + +pub trait Locks: LockManager + Clone + 'static {} +impl Locks for T where T: LockManager + Clone + 'static {} + +pub trait ReadStore: + CausalProjectionStore + + ReadModelWritePlanStore + + RelationalReadModelQueryStore + + Clone + + Send + + Sync + + 'static +{ +} +impl ReadStore for T where + T: CausalProjectionStore + + ReadModelWritePlanStore + + RelationalReadModelQueryStore + + Clone + + Send + + Sync + + 'static +{ +} diff --git a/tests/e2e-celld/crates/chat-service/src/deps.rs b/tests/e2e-celld/crates/chat-service/src/deps.rs new file mode 100644 index 000000000..64beeedc8 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/deps.rs @@ -0,0 +1,12 @@ +use chat_domain::ChatMessage; +use distributed::microsvc::RepoReadModelDependencies; +use distributed::{AggregateRepository, QueuedRepository}; + +pub type QueuedStore = QueuedRepository; + +pub type ChatRepo = AggregateRepository, ChatMessage>; +pub type ChatDeps = RepoReadModelDependencies, S>; + +/// Zitadel ingress + auth_users projector share the chat aggregate repo for outbox/leaf access +/// (ingestor is leaf-only; no chat stream is written on ingress). +pub type AuthDeps = ChatDeps; diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/events/mod.rs b/tests/e2e-celld/crates/chat-service/src/handlers/events/mod.rs new file mode 100644 index 000000000..845f2da9f --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/events/mod.rs @@ -0,0 +1,2 @@ +pub mod project_auth_user; +pub mod project_chat_messages; diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/events/project_auth_user.rs b/tests/e2e-celld/crates/chat-service/src/handlers/events/project_auth_user.rs new file mode 100644 index 000000000..fa10caeaa --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/events/project_auth_user.rs @@ -0,0 +1,53 @@ +//! Project `zitadel.user.*.v1` → `auth_users` (join target for chat + blob games). + +use distributed::microsvc::{Context, HandlerError}; +use distributed::read_model::ReadModelWritePlanBuilder; +use e2e_projections::{map_zitadel_user_status, map_zitadel_user_upsert, ZitadelUserPayload}; +use serde_json::{json, Value}; + +use crate::deps::AuthDeps; +use crate::handlers::util::{decode_payload, read_model_error}; + +pub const EVENTS: &[&str] = &[ + "zitadel.user.human.created.v1", + "zitadel.user.human.updated.v1", + "zitadel.user.human.deactivated.v1", + "zitadel.user.human.reactivated.v1", + "zitadel.user.machine.created.v1", +]; + +pub fn guard(_ctx: &Context>) -> bool +where + R: crate::bounds::EventStore, + L: crate::bounds::Locks, + S: crate::bounds::ReadStore, +{ + true +} + +pub async fn handle(ctx: &Context<'_, AuthDeps>) -> Result +where + R: crate::bounds::EventStore, + L: crate::bounds::Locks, + S: crate::bounds::ReadStore, +{ + let payload: ZitadelUserPayload = decode_payload(ctx.message())?; + let name = ctx.message().name(); + let row = if name.contains("deactivated") || name.contains("reactivated") { + map_zitadel_user_status(name, &payload) + } else { + map_zitadel_user_upsert(name, &payload) + }; + + let store = ctx.read_model_store(); + let mut plan = ReadModelWritePlanBuilder::new(); + plan.upsert(&row).map_err(read_model_error)?; + plan.commit(store).await.map_err(read_model_error)?; + + Ok(json!({ + "event": name, + "user_id": row.user_id, + "status": row.status, + "display_name": row.display_name, + })) +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/events/project_chat_messages.rs b/tests/e2e-celld/crates/chat-service/src/handlers/events/project_chat_messages.rs new file mode 100644 index 000000000..89d15e440 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/events/project_chat_messages.rs @@ -0,0 +1,11 @@ +//! Apply the ChatMessages projection for matching domain events. + +use distributed::microsvc::{CausalProjectorContext, HandlerError, ModeledProjection}; +use e2e_projections::CHAT_MESSAGES; + +pub async fn handle( + context: CausalProjectorContext, + projection: ModeledProjection, +) -> Result<(), HandlerError> { + projection.apply(CHAT_MESSAGES, &context).await +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/mod.rs b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/mod.rs new file mode 100644 index 000000000..b85b44a6c --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/mod.rs @@ -0,0 +1,6 @@ +//! External ingress commands (provider webhooks / Actions / scrapes). +//! +//! These publish **provider** bus messages only; projectors map them into read models. + +pub mod zitadel; +pub mod zitadel_scrape; diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/auth.rs b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/auth.rs new file mode 100644 index 000000000..e748a155e --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/auth.rs @@ -0,0 +1,148 @@ +//! Authenticity for Zitadel Action → HTTP deliveries. +//! +//! Paths: +//! 1. **Shared secret** header `x-zitadel-ingestor-secret` or `Authorization: Bearer` +//! 2. **Actions v2 event body** when `ZITADEL_INGESTOR_ALLOW_ACTION_EVENTS=1` (local only) + +use std::env; + +use distributed::microsvc::{HandlerError, Session}; + +/// Env var for the shared secret (required for fixture/curl path). +pub const SECRET_ENV: &str = "ZITADEL_INGESTOR_SECRET"; + +/// Preferred Action/HTTP header (lowercase session keys). +pub const SECRET_HEADER: &str = "x-zitadel-ingestor-secret"; + +/// When `1`/`true`, accept native Actions v2 event envelopes without shared secret. +pub const ALLOW_ACTION_EVENTS_ENV: &str = "ZITADEL_INGESTOR_ALLOW_ACTION_EVENTS"; + +pub fn configured_secret() -> Option { + env::var(SECRET_ENV).ok().filter(|s| !s.trim().is_empty()) +} + +pub fn allow_action_events() -> bool { + matches!( + env::var(ALLOW_ACTION_EVENTS_ENV) + .ok() + .as_deref() + .map(str::trim), + Some("1") | Some("true") | Some("TRUE") | Some("yes") + ) +} + +pub fn presented_secret(session: &Session) -> Option { + if let Some(v) = session.get(SECRET_HEADER).filter(|s| !s.is_empty()) { + return Some(v.to_string()); + } + if let Some(auth) = session.get("authorization") { + if let Some(token) = auth + .strip_prefix("Bearer ") + .or_else(|| auth.strip_prefix("bearer ")) + { + let token = token.trim(); + if !token.is_empty() { + return Some(token.to_string()); + } + } + } + None +} + +pub fn verify_authenticity(session: &Session, is_action_event: bool) -> Result<(), HandlerError> { + if let Some(presented) = presented_secret(session) { + let expected = configured_secret().ok_or_else(|| { + HandlerError::Unauthorized(format!( + "{SECRET_ENV} is not configured; refusing Zitadel ingress" + )) + })?; + if presented != expected { + return Err(HandlerError::Unauthorized( + "invalid Zitadel ingestor secret".into(), + )); + } + return Ok(()); + } + + if is_action_event && allow_action_events() { + return Ok(()); + } + + if is_action_event { + return Err(HandlerError::Unauthorized(format!( + "Action event rejected: set {ALLOW_ACTION_EVENTS_ENV}=1 (local) or send {SECRET_HEADER}" + ))); + } + + let _expected = configured_secret().ok_or_else(|| { + HandlerError::Unauthorized(format!( + "{SECRET_ENV} is not configured; refusing Zitadel ingress" + )) + })?; + Err(HandlerError::Unauthorized(format!( + "missing Zitadel authenticity ({SECRET_HEADER} or Authorization: Bearer)" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_env(secret: Option<&str>, allow_actions: bool, f: impl FnOnce()) { + let _g = ENV_LOCK.lock().unwrap(); + let prev_s = env::var(SECRET_ENV).ok(); + let prev_a = env::var(ALLOW_ACTION_EVENTS_ENV).ok(); + match secret { + Some(s) => env::set_var(SECRET_ENV, s), + None => env::remove_var(SECRET_ENV), + } + if allow_actions { + env::set_var(ALLOW_ACTION_EVENTS_ENV, "1"); + } else { + env::remove_var(ALLOW_ACTION_EVENTS_ENV); + } + f(); + match prev_s { + Some(s) => env::set_var(SECRET_ENV, s), + None => env::remove_var(SECRET_ENV), + } + match prev_a { + Some(s) => env::set_var(ALLOW_ACTION_EVENTS_ENV, s), + None => env::remove_var(ALLOW_ACTION_EVENTS_ENV), + } + } + + fn session(pairs: &[(&str, &str)]) -> Session { + let mut m = HashMap::new(); + for (k, v) in pairs { + m.insert((*k).to_string(), (*v).to_string()); + } + Session::from_map(m) + } + + #[test] + fn rejects_when_secret_not_configured() { + with_env(None, false, || { + let err = verify_authenticity(&session(&[(SECRET_HEADER, "x")]), false).unwrap_err(); + assert!(matches!(err, HandlerError::Unauthorized(_))); + }); + } + + #[test] + fn accepts_matching_header() { + with_env(Some("s3cret"), false, || { + verify_authenticity(&session(&[(SECRET_HEADER, "s3cret")]), false).unwrap(); + }); + } + + #[test] + fn accepts_action_event_when_allowed() { + with_env(None, true, || { + verify_authenticity(&Session::new(), true).unwrap(); + }); + } +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/handle.rs b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/handle.rs new file mode 100644 index 000000000..b37b2b1e9 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/handle.rs @@ -0,0 +1,59 @@ +//! Command: `zitadel.ingress.v1` — verify + map + publish provider message only. + +use distributed::microsvc::{Context, HandlerError}; +use serde_json::{json, Value}; + +use super::auth::verify_authenticity; +use super::map::{looks_like_action_event, map_action_delivery, normalize_ingress_body}; +use super::publish::publish_mapped_delivery; +use crate::deps::AuthDeps; + +/// Public HTTP command name (POST `/{COMMAND}`). +pub const COMMAND: &str = "zitadel.ingress.v1"; + +pub fn guard(ctx: &Context>) -> bool +where + R: crate::bounds::EventStore, + L: crate::bounds::Locks, + S: Send + Sync + 'static, +{ + !ctx.raw_input().is_null() +} + +pub async fn handle(ctx: &Context<'_, AuthDeps>) -> Result +where + R: crate::bounds::EventStore, + L: crate::bounds::Locks, + S: Send + Sync + 'static, +{ + let raw = ctx.raw_input().clone(); + let is_action_event = looks_like_action_event(&raw); + verify_authenticity(ctx.session(), is_action_event)?; + + let input = normalize_ingress_body(&raw); + let Some(mapped) = map_action_delivery(&input) else { + return Ok(json!({ + "ok": true, + "published": null, + "skipped": "unmapped_event_type", + "event_type": input.event_type, + "action_event": is_action_event, + })); + }; + + // Provider envelope only — projector maps to auth_users. + let leaf = ctx.repo().repo(); + publish_mapped_delivery(leaf, &mapped) + .await + .map_err(|e| HandlerError::Other(Box::new(std::io::Error::other(e))))?; + + Ok(json!({ + "ok": true, + "published": mapped.message_name, + "event_id": mapped.delivery_id, + "provider_subject": mapped.payload.provider_subject, + "user_kind": mapped.payload.user_kind, + "approval_status": mapped.payload.approval_status, + "action_event": is_action_event, + })) +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/map.rs b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/map.rs new file mode 100644 index 000000000..9693b44a8 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/map.rs @@ -0,0 +1,424 @@ +//! Map Zitadel Action / fixture payloads → provider bus subjects + envelopes. + +use e2e_projections::{ZitadelEmail, ZitadelUserPayload}; +use serde::Deserialize; +use serde_json::Value; + +pub const HUMAN_CREATED: &str = "zitadel.user.human.created.v1"; +pub const HUMAN_UPDATED: &str = "zitadel.user.human.updated.v1"; +pub const HUMAN_DEACTIVATED: &str = "zitadel.user.human.deactivated.v1"; +pub const HUMAN_REACTIVATED: &str = "zitadel.user.human.reactivated.v1"; +pub const MACHINE_CREATED: &str = "zitadel.user.machine.created.v1"; + +/// Ingress body accepted from Zitadel Action HTTP or local fixtures. +#[derive(Debug, Clone, Deserialize)] +pub struct ActionDelivery { + #[serde(default, alias = "event_id", alias = "id")] + pub delivery_id: Option, + #[serde(default, alias = "event_type", alias = "action_event", alias = "type")] + pub event_type: Option, + #[serde(default, alias = "user_id", alias = "userId")] + pub provider_subject: Option, + #[serde(default, alias = "user_kind", alias = "kind")] + pub user_kind: Option, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub emails: Option>, + #[serde(default, alias = "display_name", alias = "displayName")] + pub display_name: Option, + #[serde(default, alias = "approval_status")] + pub approval_status: Option, + #[serde(default)] + pub grants: Option>, + #[serde(default)] + pub roles: Option>, + #[serde(default)] + pub payload: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EmailIn { + pub address: String, + #[serde(default)] + pub primary: bool, + #[serde(default = "default_true")] + pub verified: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone)] +pub struct MappedDelivery { + pub message_name: String, + pub delivery_id: String, + pub payload: ZitadelUserPayload, +} + +pub fn looks_like_action_event(raw: &Value) -> bool { + raw.get("aggregateID").is_some() + || raw.get("aggregateId").is_some() + || (raw.get("aggregateType").is_some() && raw.get("sequence").is_some()) +} + +pub fn normalize_ingress_body(raw: &Value) -> ActionDelivery { + if looks_like_action_event(raw) { + return action_event_to_delivery(raw); + } + serde_json::from_value(raw.clone()).unwrap_or(ActionDelivery { + delivery_id: None, + event_type: None, + provider_subject: None, + user_kind: None, + email: None, + emails: None, + display_name: None, + approval_status: None, + grants: None, + roles: None, + payload: Some(raw.clone()), + }) +} + +fn action_event_to_delivery(raw: &Value) -> ActionDelivery { + let aggregate_id = raw + .get("aggregateID") + .or_else(|| raw.get("aggregateId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let event_type = raw + .get("type") + .or_else(|| raw.get("eventType")) + .or_else(|| raw.get("event_type")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let sequence = raw + .get("sequence") + .map(|v| match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => String::new(), + }) + .filter(|s| !s.is_empty()); + let delivery_id = match (&aggregate_id, &event_type, &sequence) { + (Some(a), Some(t), Some(s)) => Some(format!("zitadel-action:{t}:{a}:{s}")), + (Some(a), Some(t), None) => Some(format!("zitadel-action:{t}:{a}")), + _ => sequence.clone(), + }; + + let event_payload = raw + .get("event_payload") + .or_else(|| raw.get("eventPayload")) + .or_else(|| raw.get("payload")) + .cloned() + .unwrap_or(Value::Null); + + let email = event_payload + .get("emailAddress") + .or_else(|| event_payload.get("email")) + .or_else(|| event_payload.pointer("/email/email")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let display_name = event_payload + .get("displayName") + .or_else(|| event_payload.get("display_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let user_name = event_payload + .get("userName") + .or_else(|| event_payload.get("user_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let grants = event_payload + .get("roleKeys") + .or_else(|| event_payload.get("role_keys")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect::>() + }) + .filter(|v| !v.is_empty()); + + let subject = event_payload + .get("userId") + .or_else(|| event_payload.get("userID")) + .or_else(|| event_payload.get("user_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or(aggregate_id); + + let kind = if event_type + .as_deref() + .unwrap_or("") + .to_ascii_lowercase() + .contains("machine") + { + Some("machine".into()) + } else { + Some("human".into()) + }; + + ActionDelivery { + delivery_id, + event_type, + provider_subject: subject, + user_kind: kind, + email: email.or(user_name), + emails: None, + display_name, + approval_status: None, + grants, + roles: None, + payload: Some(raw.clone()), + } +} + +pub fn map_action_delivery(input: &ActionDelivery) -> Option { + let subject = input + .provider_subject + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty())?; + let event_type = input + .event_type + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty())?; + + let message_name = resolve_message_name(event_type, input.user_kind.as_deref())?; + let user_kind = if message_name == MACHINE_CREATED { + "machine".to_string() + } else { + match input.user_kind.as_deref().map(str::to_ascii_lowercase) { + Some(k) if k == "machine" || k == "service" => "machine".into(), + _ => "human".into(), + } + }; + + let delivery_id = input + .delivery_id + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| format!("zitadel:{message_name}:{subject}")); + + let emails = normalize_emails(input); + let approval_status = derive_approval(input, &user_kind); + + let payload = ZitadelUserPayload { + schema_version: 1, + source: "zitadel".into(), + delivery_id: delivery_id.clone(), + provider: "zitadel".into(), + provider_subject: subject.to_string(), + user_kind, + emails, + display_name: input.display_name.clone().filter(|s| !s.trim().is_empty()), + approval_status, + ingested_at: now_rfc3339ish(), + }; + + Some(MappedDelivery { + message_name: message_name.to_string(), + delivery_id, + payload, + }) +} + +fn resolve_message_name(event_type: &str, user_kind: Option<&str>) -> Option<&'static str> { + let t = event_type.to_ascii_lowercase().replace('_', "."); + match t.as_str() { + HUMAN_CREATED | "zitadel.user.human.created" => return Some(HUMAN_CREATED), + HUMAN_UPDATED | "zitadel.user.human.updated" => return Some(HUMAN_UPDATED), + HUMAN_DEACTIVATED | "zitadel.user.human.deactivated" => return Some(HUMAN_DEACTIVATED), + HUMAN_REACTIVATED | "zitadel.user.human.reactivated" => return Some(HUMAN_REACTIVATED), + MACHINE_CREATED | "zitadel.user.machine.created" => return Some(MACHINE_CREATED), + _ => {} + } + + let kind_machine = matches!( + user_kind.map(str::to_ascii_lowercase).as_deref(), + Some("machine") | Some("service") + ); + + if t.contains("machine") && (t.contains("created") || t.ends_with(".added")) { + return Some(MACHINE_CREATED); + } + if t.contains("deactivat") || t.contains(".locked") || t.ends_with(".locked") { + return Some(HUMAN_DEACTIVATED); + } + if t.contains("reactivat") || t.contains(".unlocked") || t.ends_with(".unlocked") { + return Some(HUMAN_REACTIVATED); + } + if t.contains("human") && (t.contains("added") || t.contains("created")) { + return Some(HUMAN_CREATED); + } + if t.contains("created") + || t.contains("create") + || (t.ends_with(".added") && !t.contains("grant")) + { + return if kind_machine { + Some(MACHINE_CREATED) + } else { + Some(HUMAN_CREATED) + }; + } + if t.contains("updated") + || t.contains("update") + || t.contains("changed") + || t.contains("grant") + || t.contains("role") + || t.contains("profile") + || t.contains("email") + { + return Some(HUMAN_UPDATED); + } + None +} + +fn normalize_emails(input: &ActionDelivery) -> Vec { + if let Some(list) = &input.emails { + if !list.is_empty() { + return list + .iter() + .map(|e| ZitadelEmail { + address: e.address.clone(), + primary: e.primary, + verified: e.verified, + }) + .collect(); + } + } + if let Some(email) = input.email.as_ref().filter(|s| !s.trim().is_empty()) { + return vec![ZitadelEmail { + address: email.clone(), + primary: true, + verified: true, + }]; + } + Vec::new() +} + +fn derive_approval(input: &ActionDelivery, user_kind: &str) -> String { + if user_kind == "machine" { + return "approved".into(); + } + if let Some(status) = input + .approval_status + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return status.to_ascii_lowercase(); + } + let has_approved = input + .grants + .iter() + .flatten() + .chain(input.roles.iter().flatten()) + .any(|g| g.eq_ignore_ascii_case("approved")); + if has_approved { + "approved".into() + } else { + "pending".into() + } +} + +fn now_rfc3339ish() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let d = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + // Sortable timestamp without chrono dependency. + format!("{}", d.as_millis()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn maps_human_created_with_waitlist_pending() { + let input = ActionDelivery { + delivery_id: Some("d1".into()), + event_type: Some("user.human.created".into()), + provider_subject: Some("sub-1".into()), + user_kind: Some("human".into()), + email: Some("ada@example.com".into()), + emails: None, + display_name: Some("Ada".into()), + approval_status: None, + grants: None, + roles: None, + payload: None, + }; + let m = map_action_delivery(&input).expect("mapped"); + assert_eq!(m.message_name, HUMAN_CREATED); + assert_eq!(m.delivery_id, "d1"); + assert_eq!(m.payload.approval_status, "pending"); + assert_eq!(m.payload.user_kind, "human"); + assert_eq!(m.payload.emails[0].address, "ada@example.com"); + } + + #[test] + fn maps_updated_with_approved_grant() { + let input = ActionDelivery { + delivery_id: Some("d2".into()), + event_type: Some("user.human.updated".into()), + provider_subject: Some("sub-1".into()), + user_kind: None, + email: Some("ada@example.com".into()), + emails: None, + display_name: Some("Ada".into()), + approval_status: None, + grants: Some(vec!["approved".into()]), + roles: None, + payload: None, + }; + let m = map_action_delivery(&input).unwrap(); + assert_eq!(m.message_name, HUMAN_UPDATED); + assert_eq!(m.payload.approval_status, "approved"); + } + + #[test] + fn unmapped_type_returns_none() { + let input = ActionDelivery { + delivery_id: Some("d3".into()), + event_type: Some("org.metadata.set".into()), + provider_subject: Some("sub-1".into()), + user_kind: None, + email: None, + emails: None, + display_name: None, + approval_status: None, + grants: None, + roles: None, + payload: None, + }; + assert!(map_action_delivery(&input).is_none()); + } + + #[test] + fn maps_native_action_event_human_added() { + let raw = json!({ + "aggregateID": "user-99", + "aggregateType": "user", + "sequence": 7, + "type": "user.human.added", + "event_payload": { + "userName": "ada@example.com", + "emailAddress": "ada@example.com", + "displayName": "Ada" + } + }); + let d = normalize_ingress_body(&raw); + assert_eq!(d.provider_subject.as_deref(), Some("user-99")); + let m = map_action_delivery(&d).expect("mapped"); + assert_eq!(m.message_name, HUMAN_CREATED); + assert_eq!(m.payload.provider_subject, "user-99"); + } +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/mod.rs b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/mod.rs new file mode 100644 index 000000000..422982acb --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/mod.rs @@ -0,0 +1,54 @@ +//! Zitadel Action/HTTP ingress + Management API scrape → provider messages only. +//! +//! Teaching fixture (simplified from gitkb domain-service): +//! 1. Authenticity (`auth`) — shared secret header +//! 2. Map (`map`) — Action payload → typed `zitadel.*.v1` subjects +//! 3. Publish (`publish`) — outbox provider message only +//! 4. Projector (`project_auth_user`) — upserts `auth_users` for GraphQL joins +//! 5. Scrape (`scrape`) — periodic Management API reconcile for missed events +//! +//! See `docs/zitadel-ingestor.md`. + +mod auth; +mod handle; +mod map; +mod publish; +pub mod scrape; + +pub use auth::{ + allow_action_events, configured_secret, verify_authenticity, ALLOW_ACTION_EVENTS_ENV, + SECRET_ENV, SECRET_HEADER, +}; +pub use handle::{guard, handle, COMMAND}; +pub use map::{ + looks_like_action_event, map_action_delivery, normalize_ingress_body, ActionDelivery, + MappedDelivery, HUMAN_CREATED, HUMAN_DEACTIVATED, HUMAN_REACTIVATED, HUMAN_UPDATED, + MACHINE_CREATED, +}; +pub use scrape::{ + scrape_users_to_outbox, spawn_scrape_loop, ScrapeReport, ZitadelScrapeConfig, API_URL_ENV, + INTERVAL_ENV, ON_START_ENV, TOKEN_ENV, +}; + +/// Provider event names published by this ingestor (never domain forgeries). +pub fn is_provider_message_name(name: &str) -> bool { + name.starts_with("zitadel.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_names_are_zitadel_prefixed() { + for name in [ + HUMAN_CREATED, + HUMAN_UPDATED, + HUMAN_DEACTIVATED, + HUMAN_REACTIVATED, + MACHINE_CREATED, + ] { + assert!(is_provider_message_name(name), "{name}"); + } + } +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/publish.rs b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/publish.rs new file mode 100644 index 000000000..cde749354 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/publish.rs @@ -0,0 +1,22 @@ +//! Shared outbox publish for provider messages (ingress + scrape). + +use distributed::{CommitBuilderExt, OutboxMessage, TransactionalCommit}; + +use super::map::MappedDelivery; + +/// Encode + leaf-outbox commit a mapped provider delivery. +pub async fn publish_mapped_delivery( + repo: &R, + mapped: &MappedDelivery, +) -> Result<(), String> { + let outbox = OutboxMessage::encode( + mapped.delivery_id.clone(), + mapped.message_name.as_str(), + &mapped.payload, + ) + .map_err(|e| e.to_string())?; + CommitBuilderExt::outbox(repo, outbox) + .commit_all() + .await + .map_err(|e| e.to_string()) +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/scrape.rs b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/scrape.rs new file mode 100644 index 000000000..2c3c3214e --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel/scrape.rs @@ -0,0 +1,510 @@ +//! Periodic / on-demand Zitadel Management API scrape → same provider outbox path. +//! +//! Actions cover the happy path. Scrape reconciles users we never got events for +//! (Action downtime, misconfig, historical backfill). + +use std::env; +use std::time::Duration; + +use distributed::TransactionalCommit; +use e2e_projections::{ZitadelEmail, ZitadelUserPayload}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::map::{MappedDelivery, HUMAN_DEACTIVATED, HUMAN_UPDATED, MACHINE_CREATED}; +use super::publish::publish_mapped_delivery; + +/// Env: Management API base (no trailing slash). Falls back to `OIDC_ISSUER`. +pub const API_URL_ENV: &str = "ZITADEL_API_URL"; +/// Env: PAT / service user token (same as Login V2 `ZITADEL_SERVICE_USER_TOKEN`). +pub const TOKEN_ENV: &str = "ZITADEL_SERVICE_USER_TOKEN"; +/// Env: scrape interval seconds. `0` or unset with no token → disabled. +/// Default when token present: `60`. +pub const INTERVAL_ENV: &str = "ZITADEL_SCRAPE_INTERVAL_SECS"; +/// Env: run one scrape immediately on process start (`1`/`true`). Default on when configured. +pub const ON_START_ENV: &str = "ZITADEL_SCRAPE_ON_START"; + +#[derive(Debug, Clone)] +pub struct ZitadelScrapeConfig { + pub api_base: String, + pub token: String, + pub interval: Duration, + pub on_start: bool, + pub page_size: u32, +} + +impl ZitadelScrapeConfig { + /// Load from env. Returns `None` when token or API base is missing, or interval is 0 + /// with scrape explicitly disabled. + pub fn from_env() -> Option { + let token = env::var(TOKEN_ENV) + .or_else(|_| env::var("ZITADEL_MANAGEMENT_PAT")) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty())?; + + let api_base = env::var(API_URL_ENV) + .or_else(|_| env::var("OIDC_ISSUER")) + .ok() + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty())?; + + let interval_secs: u64 = env::var(INTERVAL_ENV) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(60); + if interval_secs == 0 { + // Allow on-demand command only; no background loop. + return Some(Self { + api_base, + token, + interval: Duration::ZERO, + on_start: false, + page_size: 100, + }); + } + + let on_start = !matches!( + env::var(ON_START_ENV).ok().as_deref().map(str::trim), + Some("0") | Some("false") | Some("FALSE") | Some("off") + ); + + Some(Self { + api_base, + token, + interval: Duration::from_secs(interval_secs), + on_start, + page_size: 100, + }) + } + + pub fn background_enabled(&self) -> bool { + !self.interval.is_zero() + } +} + +#[derive(Debug, Default, Clone)] +pub struct ScrapeReport { + pub listed: usize, + pub published: usize, + pub skipped: usize, + pub errors: Vec, +} + +/// List users from Zitadel Management API and publish provider messages for each. +pub async fn scrape_users_to_outbox( + repo: &R, + cfg: &ZitadelScrapeConfig, +) -> ScrapeReport { + let mut report = ScrapeReport::default(); + let users = match list_all_users(cfg).await { + Ok(u) => u, + Err(e) => { + report.errors.push(e); + return report; + } + }; + report.listed = users.len(); + + for user in users { + let Some(mapped) = map_mgmt_user(&user) else { + report.skipped += 1; + continue; + }; + match publish_mapped_delivery(repo, &mapped).await { + Ok(()) => report.published += 1, + Err(e) => { + // Content-addressed scrape ids: unchanged profile re-scrape hits the + // outbox unique key. That is the durable "already emitted" cache — + // count as skip, not error. + if is_expected_scrape_duplicate(&e) { + report.skipped += 1; + } else { + report.errors.push(format!( + "user {}: publish failed: {e}", + mapped.payload.provider_subject + )); + } + } + } + } + report +} + +/// True when publish failed because this scrape delivery id was already committed. +/// +/// Matches repository `DuplicateOutboxMessageInBatch` display text and common +/// SQL unique-violation wording from drivers. +fn is_expected_scrape_duplicate(err: &str) -> bool { + let lower = err.to_ascii_lowercase(); + lower.contains("duplicate outbox message id") + || lower.contains("duplicateoutboxmessageinbatch") + || lower.contains("unique") + || lower.contains("already exists") +} + +#[derive(Debug, Clone, Deserialize)] +struct SearchResponse { + #[serde(default)] + result: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct MgmtUser { + id: Option, + #[serde(default, rename = "userName")] + user_name: Option, + #[serde(default)] + state: Option, + #[serde(default)] + human: Option, + #[serde(default)] + machine: Option, + #[serde(default, rename = "changeDate")] + change_date: Option, + #[serde(default, rename = "details")] + details: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MgmtDetails { + #[serde(default, rename = "changeDate")] + change_date: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MgmtHuman { + #[serde(default)] + profile: Option, + #[serde(default)] + email: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MgmtProfile { + #[serde(default, rename = "displayName")] + display_name: Option, + #[serde(default, rename = "firstName")] + first_name: Option, + #[serde(default, rename = "lastName")] + last_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MgmtEmail { + #[serde(default)] + email: Option, + #[serde(default, rename = "isEmailVerified")] + is_email_verified: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MgmtMachine { + #[serde(default)] + name: Option, +} + +async fn list_all_users(cfg: &ZitadelScrapeConfig) -> Result, String> { + let client = reqwest::Client::new(); + let mut offset: u64 = 0; + let mut all = Vec::new(); + + loop { + let body = json!({ + "query": { + "offset": offset.to_string(), + "limit": cfg.page_size, + "asc": true + }, + "sortingColumn": "USER_FIELD_NAME_USER_NAME", + "queries": [] + }); + let url = format!("{}/management/v1/users/_search", cfg.api_base); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {}", cfg.token)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("zitadel search request: {e}"))?; + + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| format!("zitadel search body: {e}"))?; + if !status.is_success() { + return Err(format!("zitadel search HTTP {status}: {text}")); + } + let page: SearchResponse = serde_json::from_str(&text) + .map_err(|e| format!("zitadel search json: {e}; body={text}"))?; + let n = page.result.len(); + all.extend(page.result); + if n < cfg.page_size as usize { + break; + } + offset += n as u64; + if offset > 10_000 { + break; // safety + } + } + Ok(all) +} + +/// Map one Management API user row → provider bus delivery (or None if unusable). +pub fn map_management_user(raw: &Value) -> Option { + let user: MgmtUser = serde_json::from_value(raw.clone()).ok()?; + map_mgmt_user(&user) +} + +fn map_mgmt_user(user: &MgmtUser) -> Option { + let id = user.id.as_deref()?.trim(); + if id.is_empty() { + return None; + } + let state = user.state.as_deref().unwrap_or("USER_STATE_ACTIVE"); + let is_machine = user.machine.is_some() && user.human.is_none(); + let deactivated = state.contains("INACTIVE") + || state.contains("LOCKED") + || state.contains("SUSPEND") + || state.contains("DELETED"); + + let (email, display_name, user_kind) = if is_machine { + let name = user + .machine + .as_ref() + .and_then(|m| m.name.clone()) + .or_else(|| user.user_name.clone()) + .unwrap_or_else(|| id.to_string()); + (String::new(), name, "machine".to_string()) + } else { + let human = user.human.as_ref(); + let email = human + .and_then(|h| h.email.as_ref()) + .and_then(|e| e.email.clone()) + .unwrap_or_default(); + let display = human + .and_then(|h| h.profile.as_ref()) + .and_then(|p| { + p.display_name + .clone() + .or_else(|| match (&p.first_name, &p.last_name) { + (Some(f), Some(l)) => Some(format!("{f} {l}")), + (Some(f), None) => Some(f.clone()), + _ => None, + }) + }) + .or_else(|| user.user_name.clone()) + .unwrap_or_else(|| { + if email.is_empty() { + id.to_string() + } else { + email.clone() + } + }); + (email, display, "human".to_string()) + }; + + let message_name = if is_machine { + MACHINE_CREATED + } else if deactivated { + HUMAN_DEACTIVATED + } else { + // Reconcile as update — projector upserts; works for create + change. + HUMAN_UPDATED + }; + + let change = user + .change_date + .clone() + .or_else(|| user.details.as_ref().and_then(|d| d.change_date.clone())) + .unwrap_or_else(|| "0".into()); + // Stable when profile unchanged so re-scrape can skip duplicate outbox ids. + let fingerprint = simple_fingerprint(&[&email, &display_name, state, &user_kind, &change]); + let delivery_id = format!("zitadel-scrape:{id}:{fingerprint}"); + + let emails = if email.is_empty() { + Vec::new() + } else { + vec![ZitadelEmail { + address: email, + primary: true, + verified: user + .human + .as_ref() + .and_then(|h| h.email.as_ref()) + .and_then(|e| e.is_email_verified) + .unwrap_or(true), + }] + }; + + let payload = ZitadelUserPayload { + schema_version: 1, + source: "zitadel-scrape".into(), + delivery_id: delivery_id.clone(), + provider: "zitadel".into(), + provider_subject: id.to_string(), + user_kind, + emails, + display_name: Some(display_name), + // Scrape treats every listed identity as a directory member. + approval_status: "approved".into(), + ingested_at: now_ms(), + }; + + Some(MappedDelivery { + message_name: message_name.to_string(), + delivery_id, + payload, + }) +} + +fn simple_fingerprint(parts: &[&str]) -> String { + // FNV-1a 64 — stable, no extra deps. + let mut hash: u64 = 0xcbf29ce484222325; + for p in parts { + for b in p.as_bytes() { + hash ^= u64::from(*b); + hash = hash.wrapping_mul(0x100000001b3); + } + hash ^= 0xff; + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +fn now_ms() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let d = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", d.as_millis()) +} + +/// Background loop: optional immediate scrape, then every `cfg.interval`. +pub fn spawn_scrape_loop(repo: R, cfg: ZitadelScrapeConfig) +where + R: TransactionalCommit + Clone + Send + Sync + 'static, +{ + if !cfg.background_enabled() && !cfg.on_start { + return; + } + tokio::spawn(async move { + if cfg.on_start { + let r = scrape_users_to_outbox(&repo, &cfg).await; + eprintln!( + "zitadel scrape (start): listed={} published={} skipped={} errors={}", + r.listed, + r.published, + r.skipped, + r.errors.len() + ); + for e in &r.errors { + eprintln!("zitadel scrape: {e}"); + } + } + if !cfg.background_enabled() { + return; + } + loop { + tokio::time::sleep(cfg.interval).await; + let r = scrape_users_to_outbox(&repo, &cfg).await; + if r.published > 0 || !r.errors.is_empty() { + eprintln!( + "zitadel scrape: listed={} published={} skipped={} errors={}", + r.listed, + r.published, + r.skipped, + r.errors.len() + ); + } + for e in &r.errors { + eprintln!("zitadel scrape: {e}"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn maps_active_human() { + let raw = json!({ + "id": "user-1", + "userName": "alice", + "state": "USER_STATE_ACTIVE", + "human": { + "profile": { "displayName": "Alice" }, + "email": { "email": "alice@e2e.local", "isEmailVerified": true } + }, + "changeDate": "2026-01-01T00:00:00Z" + }); + let m = map_management_user(&raw).expect("mapped"); + assert_eq!(m.message_name, HUMAN_UPDATED); + assert_eq!(m.payload.provider_subject, "user-1"); + assert_eq!(m.payload.display_name.as_deref(), Some("Alice")); + assert_eq!(m.payload.emails[0].address, "alice@e2e.local"); + assert!(m.delivery_id.starts_with("zitadel-scrape:user-1:")); + } + + #[test] + fn maps_inactive_as_deactivated() { + let raw = json!({ + "id": "user-2", + "state": "USER_STATE_INACTIVE", + "human": { + "profile": { "displayName": "Bob" }, + "email": { "email": "bob@e2e.local" } + } + }); + let m = map_management_user(&raw).unwrap(); + assert_eq!(m.message_name, HUMAN_DEACTIVATED); + } + + #[test] + fn maps_machine() { + let raw = json!({ + "id": "svc-1", + "state": "USER_STATE_ACTIVE", + "machine": { "name": "bot" } + }); + let m = map_management_user(&raw).unwrap(); + assert_eq!(m.message_name, MACHINE_CREATED); + assert_eq!(m.payload.user_kind, "machine"); + } + + #[test] + fn expected_duplicate_classifies_outbox_unique() { + assert!(is_expected_scrape_duplicate( + "duplicate outbox message id in commit batch: zitadel-scrape:u1:abc" + )); + assert!(is_expected_scrape_duplicate( + "error: UNIQUE constraint failed: outbox_messages.message_id" + )); + assert!(is_expected_scrape_duplicate( + "duplicate key value violates unique constraint \"outbox_messages_pkey\"" + )); + assert!(!is_expected_scrape_duplicate("connection refused")); + assert!(!is_expected_scrape_duplicate("zitadel search HTTP 401")); + } + + #[test] + fn same_profile_same_fingerprint() { + let raw = json!({ + "id": "user-1", + "state": "USER_STATE_ACTIVE", + "human": { + "profile": { "displayName": "Alice" }, + "email": { "email": "a@x.com" } + }, + "changeDate": "t1" + }); + let a = map_management_user(&raw).unwrap(); + let b = map_management_user(&raw).unwrap(); + assert_eq!(a.delivery_id, b.delivery_id); + } +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel_scrape.rs b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel_scrape.rs new file mode 100644 index 000000000..2b24f17d5 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/ingestors/zitadel_scrape.rs @@ -0,0 +1,52 @@ +//! Command: `zitadel.scrape.v1` — on-demand Management API reconciliation scrape. +//! +//! Authenticity: same shared secret as Action ingress (`x-zitadel-ingestor-secret`). +//! Requires `ZITADEL_SERVICE_USER_TOKEN` + `ZITADEL_API_URL` / `OIDC_ISSUER` in env. + +use distributed::microsvc::{Context, HandlerError}; +use serde_json::{json, Value}; + +use super::zitadel::scrape::{scrape_users_to_outbox, ZitadelScrapeConfig}; +use super::zitadel::verify_authenticity; +use crate::deps::AuthDeps; + +pub const COMMAND: &str = "zitadel.scrape.v1"; + +pub fn guard(_ctx: &Context>) -> bool +where + R: crate::bounds::EventStore, + L: crate::bounds::Locks, + S: Send + Sync + 'static, +{ + // Empty body is fine; authenticity checked in handle. + true +} + +pub async fn handle(ctx: &Context<'_, AuthDeps>) -> Result +where + R: crate::bounds::EventStore, + L: crate::bounds::Locks, + S: Send + Sync + 'static, +{ + // Not an Action event envelope — require shared secret. + verify_authenticity(ctx.session(), false)?; + + let cfg = ZitadelScrapeConfig::from_env().ok_or_else(|| { + HandlerError::Rejected(format!( + "scrape not configured: set {} and {} (or OIDC_ISSUER)", + super::zitadel::scrape::TOKEN_ENV, + super::zitadel::scrape::API_URL_ENV + )) + })?; + + let leaf = ctx.repo().repo(); + let report = scrape_users_to_outbox(leaf, &cfg).await; + + Ok(json!({ + "ok": report.errors.is_empty(), + "listed": report.listed, + "published": report.published, + "skipped": report.skipped, + "errors": report.errors, + })) +} diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/mod.rs b/tests/e2e-celld/crates/chat-service/src/handlers/mod.rs new file mode 100644 index 000000000..e7d0ab091 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/mod.rs @@ -0,0 +1,3 @@ +pub mod events; +pub mod ingestors; +pub mod util; diff --git a/tests/e2e-celld/crates/chat-service/src/handlers/util.rs b/tests/e2e-celld/crates/chat-service/src/handlers/util.rs new file mode 100644 index 000000000..9aa0154b2 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/util.rs @@ -0,0 +1,151 @@ +//! Shared handler helpers. +//! +//! **Admission vs domain** +//! - [`session_has_user`] / [`session_is_admin`] / [`causal_has_user`] / +//! [`causal_is_admin`] — command **guards** (session admission only). +//! - Handler bodies bind the principal and call the domain; they do not re-check +//! “am I logged in?” when a guard already did. +//! - Domain owns entity invariants (empty title, ownership, board rules). + +use distributed::bus::Message; +use distributed::microsvc::{CausalCommandContext, HandlerError, Session}; +use distributed::{Aggregate, BitcodePayloadCodec, PayloadCodec}; +use serde::de::DeserializeOwned; + +/// Decode event payload as JSON (tests) or bitcode (outbox → bus). +pub fn decode_payload(message: &Message) -> Result { + let ct = message.content_type.as_str(); + if ct.contains("json") || looks_like_json(message.payload()) { + return serde_json::from_slice(message.payload()) + .map_err(|e| HandlerError::DecodeFailed(format!("json payload: {e}"))); + } + BitcodePayloadCodec::decode(message.payload()) + .map_err(|e| HandlerError::DecodeFailed(format!("bitcode payload: {e}"))) +} + +fn looks_like_json(bytes: &[u8]) -> bool { + matches!( + bytes.iter().find(|b| !b.is_ascii_whitespace()), + Some(b'{' | b'[') + ) +} + +pub fn rejected(err: impl std::fmt::Display) -> HandlerError { + HandlerError::Rejected(err.to_string()) +} + +pub fn read_model_error(e: impl std::fmt::Display) -> HandlerError { + HandlerError::Other(Box::new(std::io::Error::other(e.to_string()))) +} + +/// Authenticated user from session (`x-user-id` via DevHeaders or OIDC claim map). +pub fn require_user(session: &Session) -> Result { + session + .user_id() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .ok_or_else(|| HandlerError::Unauthorized("missing x-user-id".into())) +} + +/// Session has a non-empty user id (for `guard` — bool, not Result). +pub fn session_has_user(session: &Session) -> bool { + session.user_id().is_some_and(|s| !s.is_empty()) +} + +/// Engine role set contains `admin` (`x-roles` / OIDC claim map). For `guard`. +pub fn session_is_admin(session: &Session) -> bool { + session.has_role("admin") +} + +/// Typed causal guard: non-empty session user id. +pub fn causal_has_user(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + session_has_user(ctx.session()) +} + +/// Typed causal guard: session user present and carries `admin`. +pub fn causal_is_admin(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + session_has_user(ctx.session()) && session_is_admin(ctx.session()) +} + +/// Principal after a user-session guard (for domain `owner_id` / author args). +pub fn principal(ctx: &CausalCommandContext<'_, A>) -> Result +where + A: Aggregate + Send + Sync + 'static, +{ + ctx.user_id().map(str::to_string) +} + +/// Require engine role `admin` (handler-path Result form). +pub fn require_admin(session: &Session) -> Result<(), HandlerError> { + if session.has_role("admin") { + return Ok(()); + } + let roles = session.roles(); + if roles.is_empty() { + Err(HandlerError::Unauthorized("missing x-roles".into())) + } else { + Err(HandlerError::Rejected(format!( + "admin role required, got `{}`", + roles.join(",") + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; + + #[test] + fn session_has_user_requires_nonempty_id() { + let mut s = Session::new(); + assert!(!session_has_user(&s)); + s.set(USER_ID_KEY, ""); + assert!(!session_has_user(&s)); + s.set(USER_ID_KEY, "alice"); + assert!(session_has_user(&s)); + } + + #[test] + fn session_is_admin_exact_role() { + let mut s = Session::new(); + assert!(!session_is_admin(&s)); + s.set(ROLE_KEY, "user"); + assert!(!session_is_admin(&s)); + s.set(ROLE_KEY, "admin"); + assert!(session_is_admin(&s)); + } + + #[test] + fn require_admin_errors() { + let mut s = Session::new(); + assert!(require_admin(&s).is_err()); + s.set(ROLE_KEY, "user"); + assert!(require_admin(&s).is_err()); + s.set(ROLE_KEY, "admin"); + assert!(require_admin(&s).is_ok()); + } + + #[test] + fn require_user_errors_and_returns_id() { + let mut s = Session::new(); + assert!(require_user(&s).is_err()); + s.set(USER_ID_KEY, "bob"); + assert_eq!(require_user(&s).unwrap(), "bob"); + } + + #[test] + fn session_is_admin_requires_user_for_causal_admin_guard_semantics() { + // Admin role without a user id is not a usable principal for force_archive. + let mut s = Session::new(); + s.set(ROLE_KEY, "admin"); + assert!(session_is_admin(&s)); + assert!(!session_has_user(&s)); + } +} diff --git a/tests/e2e-celld/crates/chat-service/src/lib.rs b/tests/e2e-celld/crates/chat-service/src/lib.rs new file mode 100644 index 000000000..b86d4a8d3 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/lib.rs @@ -0,0 +1,11 @@ +//! Chat + Zitadel identity-ingestor service crate (in-process). + +mod bounds; +mod deps; +pub mod handlers; +mod routes; + +pub use handlers::ingestors::zitadel::{ + scrape_users_to_outbox, spawn_scrape_loop, ScrapeReport, ZitadelScrapeConfig, +}; +pub use routes::{routes, MODULE_ID}; diff --git a/tests/e2e-celld/crates/chat-service/src/routes.rs b/tests/e2e-celld/crates/chat-service/src/routes.rs new file mode 100644 index 000000000..a1bfbae95 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/routes.rs @@ -0,0 +1,61 @@ +//! Chat + identity-ingestor module: room messages, Zitadel ingress, auth_user projector. + +use chat_domain::ChatMessage; +use distributed::graphql::SurfaceProjector; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers; + +/// Logical module id for composition inventories. +pub const MODULE_ID: &str = "chat"; + +type ChatRoutes = + Routes, ChatMessage>, S>>; + +/// Mount chat commands, Zitadel extension commands, and chat/auth projectors. +pub fn routes( + repo: R, + locks: L, + read_models: S, + chat_projector: SurfaceProjector, +) -> ChatRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, ChatMessage>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + Routes::for_aggregate::(repo, locks, read_models) + .mount(chat_domain::commands::post()) + // Zitadel Action ingress + on-demand scrape remain non-GraphQL + // integration commands (explicit extension mounts). + .command(handlers::ingestors::zitadel::COMMAND) + .guarded( + handlers::ingestors::zitadel::guard, + handlers::ingestors::zitadel::handle, + ) + .command(handlers::ingestors::zitadel_scrape::COMMAND) + .guarded( + handlers::ingestors::zitadel_scrape::guard, + handlers::ingestors::zitadel_scrape::handle, + ) + .modeled_projector(chat_projector) + .handle(handlers::events::project_chat_messages::handle) + .events(handlers::events::project_auth_user::EVENTS) + .guarded( + handlers::events::project_auth_user::guard, + handlers::events::project_auth_user::handle, + ) +} diff --git a/tests/e2e-celld/crates/graphql-service/Cargo.toml b/tests/e2e-celld/crates/graphql-service/Cargo.toml new file mode 100644 index 000000000..d7bd5be43 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "e2e-celld-graphql" +version.workspace = true +edition.workspace = true +publish = false +description = "GraphQL CommandHost process for the celld example (not e2e-ui)" + +[dependencies] +distributed = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +sqlx = { workspace = true } +axum = { workspace = true } +reqwest = { workspace = true } +tower = "0.5" +futures-util = "0.3" +todo-domain = { path = "../../../e2e-ui/crates/todo-domain" } +chat-domain = { path = "../../../e2e-ui/crates/chat-domain" } +blob-domain = { path = "../../../e2e-ui/crates/blob-domain" } +e2e-readmodels = { path = "../../../e2e-ui/crates/readmodels" } +e2e-projections = { path = "../../../e2e-ui/crates/projections" } +e2e-celld-todo = { path = "../todo-service" } +e2e-celld-chat = { path = "../chat-service" } +e2e-celld-blob = { path = "../blob-service" } diff --git a/tests/e2e-celld/crates/graphql-service/src/application.rs b/tests/e2e-celld/crates/graphql-service/src/application.rs new file mode 100644 index 000000000..98f3c7a5a --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/application.rs @@ -0,0 +1,32 @@ +//! e2e-ui application composition root. +//! +//! This is the review-visible product declaration: surface identities, module +//! inventory, and re-exports of the composed host APIs. Infrastructure +//! (dialect, outbox, OIDC serve) stays in `host`; handlers stay in modules. + +use crate::modules::compose; +use e2e_celld_blob as blob; +use e2e_celld_chat as chat; +use e2e_celld_todo as todo; + +/// Stable normal-application surface shared by user and admin sessions. +pub const DISTRIBUTED_CLIENT_SURFACE: &str = "e2e-ui"; +/// Stable elevated surface for routes that intentionally include admin-only fields. +pub const DISTRIBUTED_ADMIN_CLIENT_SURFACE: &str = "e2e-ui-admin"; +/// Unauthenticated public surface (lobby message peek). +pub const DISTRIBUTED_PUBLIC_CLIENT_SURFACE: &str = "e2e-ui-public"; + +/// Logical application name used for manifest / plan identity. +pub const E2E_UI_APPLICATION: &str = "e2e-ui"; + +/// Explicit module identities owned by the e2e application. +pub const E2E_UI_MODULE_IDS: &[&str] = compose::MODULE_IDS; + +/// Compile-time proof that module inventory matches bounded-context crates. +#[allow(dead_code)] +pub const MODULE_DECLARATIONS: &[(&str, &str)] = &[ + (todo::MODULE_ID, "todo commands + projector"), + (chat::MODULE_ID, "chat commands + Zitadel extension + projectors"), + (blob::MODULE_ID, "blob Atomic commands"), + ("identity", "AuthUsers projection via chat module ingestors"), +]; diff --git a/tests/e2e-celld/crates/graphql-service/src/bounds.rs b/tests/e2e-celld/crates/graphql-service/src/bounds.rs new file mode 100644 index 000000000..dbaf42fe0 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/bounds.rs @@ -0,0 +1,40 @@ +//! Trait aliases for generic handler storage parameters. + +use distributed::microsvc::{CausalProjectionStore, CausalRepositoryBackend}; +use distributed::{ + GetStream, LockManager, ReadModelWritePlanStore, RelationalReadModelQueryStore, + TransactionalCommit, +}; + +pub trait EventStore: + CausalRepositoryBackend + GetStream + TransactionalCommit + Clone + Send + Sync + 'static +{ +} +impl EventStore for T where + T: CausalRepositoryBackend + GetStream + TransactionalCommit + Clone + Send + Sync + 'static +{ +} + +pub trait Locks: LockManager + Clone + 'static {} +impl Locks for T where T: LockManager + Clone + 'static {} + +pub trait ReadStore: + CausalProjectionStore + + ReadModelWritePlanStore + + RelationalReadModelQueryStore + + Clone + + Send + + Sync + + 'static +{ +} +impl ReadStore for T where + T: CausalProjectionStore + + ReadModelWritePlanStore + + RelationalReadModelQueryStore + + Clone + + Send + + Sync + + 'static +{ +} diff --git a/tests/e2e-celld/crates/graphql-service/src/host.rs b/tests/e2e-celld/crates/graphql-service/src/host.rs new file mode 100644 index 000000000..2a64f1727 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/host.rs @@ -0,0 +1,164 @@ +//! Celld example GraphQL host. Not the e2e-ui one-process playground. +//! +//! Todo create/complete wait-dispatch to celld; Chat/Blob stay in-process. + +use std::sync::Arc; +use std::time::Duration; + +use distributed::bus::{PostgresBus, SqliteBus}; +use distributed::command_dispatch::SharedCommandHost; +use distributed::graphql::IdentityConfig; +use distributed::microsvc::{spawn_outbox_publish_loop, spawn_service_consumer_loop}; +use distributed::{PostgresLockManager, PostgresRepository, SqliteLockManager, SqliteRepository}; +use e2e_celld_todo::CelldTodoCommandHost; + +use crate::oidc_layer::serve_with_oidc_and_host; +use crate::{ + build_graphql_engine, build_service, distributed_manifest, spawn_scrape_loop, + ZitadelScrapeConfig, E2E_UI_APPLICATION, +}; + +const BUS_GROUP: &str = "e2e-celld"; + +pub struct HostOptions { + pub bind: String, + pub identity: IdentityConfig, + pub celld_url: String, +} + +pub async fn run( + database_url: &str, + options: HostOptions, +) -> Result<(), Box> { + let celld_url = options.celld_url.trim_end_matches('/').to_string(); + eprintln!( + "e2e-celld graphql application=`{}` bind={} CELLD_URL={}", + E2E_UI_APPLICATION, options.bind, celld_url + ); + if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { + run_postgres(database_url, options, celld_url).await + } else { + run_sqlite(database_url, options, celld_url).await + } +} + +async fn run_sqlite( + database_url: &str, + options: HostOptions, + celld_url: String, +) -> Result<(), Box> { + let repo = SqliteRepository::connect_and_migrate(database_url).await?; + let registry = distributed_manifest() + .table_registry() + .map_err(|e| format!("manifest: {e}"))?; + repo.bootstrap_table_schema_for_dev(®istry).await?; + let locks = SqliteLockManager::new(repo.pool().clone()); + let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); + bus.ensure_tables().await?; + + let change_rx = repo.read_model_changes(); + let service = build_service(repo.clone(), locks.clone(), repo.clone()) + .with_bus(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)); + let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let service = Arc::new(service.try_with_graphql(gql)?); + let host: SharedCommandHost = + Arc::new(CelldTodoCommandHost::new(celld_url, Arc::clone(&service))); + + spawn_outbox_publish_loop( + repo.outbox_store(), + Arc::new(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)), + "e2e-celld", + Duration::from_secs(30), + 5, + ); + { + let repo = repo.clone(); + let locks = locks.clone(); + spawn_service_consumer_loop(move || { + let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); + build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus) + }); + } + spawn_zitadel_scrape(repo.clone()); + + eprintln!( + "e2e-celld (sqlite) listening on http://{} — Todo create/complete → celld", + options.bind + ); + serve_with_oidc_and_host(service, host, options.identity, &options.bind).await?; + Ok(()) +} + +async fn run_postgres( + database_url: &str, + options: HostOptions, + celld_url: String, +) -> Result<(), Box> { + let repo = PostgresRepository::connect_and_migrate(database_url).await?; + let registry = distributed_manifest() + .table_registry() + .map_err(|e| format!("manifest: {e}"))?; + repo.bootstrap_table_schema_for_dev(®istry).await?; + let locks = PostgresLockManager::new(repo.pool().clone()); + let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); + bus.ensure_tables().await?; + + let change_rx = repo.read_model_changes(); + let service = build_service(repo.clone(), locks.clone(), repo.clone()) + .with_bus(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)); + let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let service = Arc::new(service.try_with_graphql(gql)?); + let host: SharedCommandHost = + Arc::new(CelldTodoCommandHost::new(celld_url, Arc::clone(&service))); + + spawn_outbox_publish_loop( + repo.outbox_store(), + Arc::new(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)), + "e2e-celld", + Duration::from_secs(30), + 5, + ); + { + let repo = repo.clone(); + let locks = locks.clone(); + spawn_service_consumer_loop(move || { + let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); + build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus) + }); + } + spawn_zitadel_scrape(repo.clone()); + + eprintln!( + "e2e-celld (postgres) listening on http://{} — Todo create/complete → celld", + options.bind + ); + serve_with_oidc_and_host(service, host, options.identity, &options.bind).await?; + Ok(()) +} + +fn spawn_zitadel_scrape(repo: R) +where + R: distributed::TransactionalCommit + Clone + Send + Sync + 'static, +{ + match ZitadelScrapeConfig::from_env() { + Some(cfg) if cfg.background_enabled() || cfg.on_start => { + eprintln!( + "zitadel scrape: enabled (api={}, interval={}s, on_start={})", + cfg.api_base, + cfg.interval.as_secs(), + cfg.on_start + ); + spawn_scrape_loop(repo, cfg); + } + Some(_) => { + eprintln!( + "zitadel scrape: credentials present, background off (interval=0); use POST /zitadel.scrape.v1" + ); + } + None => { + eprintln!( + "zitadel scrape: disabled (set ZITADEL_SERVICE_USER_TOKEN + OIDC_ISSUER/ZITADEL_API_URL)" + ); + } + } +} diff --git a/tests/e2e-celld/crates/graphql-service/src/lib.rs b/tests/e2e-celld/crates/graphql-service/src/lib.rs new file mode 100644 index 000000000..f6113e4fc --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/lib.rs @@ -0,0 +1,26 @@ +//! GraphQL process for the celld example (sibling of e2e-ui, not `make run`). +//! +//! Todo create/complete wait-dispatch to celld. Chat and Blob stay in-process +//! via [`e2e_celld_chat`] and [`e2e_celld_blob`]. Domain crates are the e2e-ui +//! ones. + +mod application; +mod bounds; +mod host; +pub mod modules; +mod oidc_layer; + +pub use application::{ + DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + E2E_UI_APPLICATION, E2E_UI_MODULE_IDS, +}; +pub use e2e_celld_chat::{ + scrape_users_to_outbox, spawn_scrape_loop, ScrapeReport, ZitadelScrapeConfig, +}; +pub use e2e_readmodels::distributed_manifest; +pub use host::{run, HostOptions}; +pub use modules::compose::build_service; +pub use modules::graphql::{ + build_graphql_engine, dev_identity, distributed_admin_client_surface, distributed_client_surface, + distributed_public_client_surface, identity_from_env, oidc_bearer_config, +}; diff --git a/tests/e2e-celld/crates/graphql-service/src/modules/compose.rs b/tests/e2e-celld/crates/graphql-service/src/modules/compose.rs new file mode 100644 index 000000000..81f9e7091 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/modules/compose.rs @@ -0,0 +1,66 @@ +//! Compose bounded-context modules into one e2e-ui Service. + +use blob_domain::BlobGame; +use chat_domain::ChatMessage; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, Service, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; +use todo_domain::Todo; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::modules::projections; +use e2e_celld_blob as blob; +use e2e_celld_chat as chat; +use e2e_celld_todo as todo; + +/// Explicit module inventory for the celld example (same ids as e2e-ui so the UI client matches). +pub const MODULE_IDS: &[&str] = &[todo::MODULE_ID, chat::MODULE_ID, blob::MODULE_ID, "identity"]; + +/// Compose todo + chat (+ identity ingestors) + blob modules into one Service. +/// +/// This is the review-visible application wiring: list modules, do not invent +/// infrastructure. Dialect runners and workers live in `host`. +pub fn build_service(repo: R, locks: L, read_models: S) -> Service +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, Todo>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, + AggregateRepository, ChatMessage>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, + AggregateRepository, BlobGame>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + let projections = projections::projection_owners(); + let todos = todo::routes( + repo.clone(), + locks.clone(), + read_models.clone(), + projections.todo, + ); + let chat = chat::routes( + repo.clone(), + locks.clone(), + read_models.clone(), + projections.chat, + ); + let blob = blob::routes(repo, locks, read_models, projections.blob); + + // GraphQL-only public write surface. POST /todo.* stays 404 (suite T0). + // Zitadel Action ingress still needs HTTP: those commands are registered in + // the chat module and re-mounted in `serve_with_oidc`. + Service::new() + .named("e2e-ui") + .routes(todos) + .routes(chat) + .routes(blob) +} diff --git a/tests/e2e-celld/crates/graphql-service/src/modules/graphql.rs b/tests/e2e-celld/crates/graphql-service/src/modules/graphql.rs new file mode 100644 index 000000000..1aec044db --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/modules/graphql.rs @@ -0,0 +1,728 @@ +use std::sync::Arc; + +use distributed::graphql::{ + build_surface, surface_for_application_contract, DistributedClientSurfaceExport, GraphqlEngine, + GraphqlPoolSource, IdentityConfig, OidcConfig, SurfaceOptions, +}; +use distributed::microsvc::Service; +use distributed::{InMemoryLockManager, InMemoryRepository, LockError, LockManager}; +use e2e_readmodels::{AuthUsers, BlobGames, ChatMessages, Todos}; + +use crate::application::{ + DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE, +}; +use crate::modules::projections; + +// Stable only for this local copyable fixture. Real deployments must inject +// their own per-deployment key rather than copying this development value. +const E2E_PROTOCOL_TOKEN_KEY: [u8; 32] = [0xe2; 32]; + +#[derive(Clone, Default)] +pub(crate) struct ClientSurfaceLocks(Arc); + +impl LockManager for ClientSurfaceLocks { + type Lock = distributed::InMemoryLock; + + fn get_lock(&self, id: &str) -> Result, LockError> { + self.0.get_lock(id) + } +} + +/// GraphQL over todos + chat + blob + AuthUsers. +pub fn build_graphql_engine( + pool: impl Into, + service: &Service, + identity: IdentityConfig, + change_rx: Option>, +) -> Result { + build_graphql_engine_with_graphiql(pool, service, identity, change_rx, graphiql_enabled()) +} + +pub(crate) fn build_graphql_engine_with_graphiql( + pool: impl Into, + service: &Service, + identity: IdentityConfig, + change_rx: Option>, + graphiql: bool, +) -> Result { + let projections = projections::projection_owners(); + let mut b = GraphqlEngine::builder(pool) + .protocol_token_key(E2E_PROTOCOL_TOKEN_KEY) + .roles(&["user", "admin", "anonymous"]) + .client_application_surface_with_schema_roles( + DISTRIBUTED_CLIENT_SURFACE, + ["admin", "user"], + ["user"], + ) + .client_application_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, ["admin"], ["admin"]) + .client_application_surface( + DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + ["anonymous"], + ["anonymous"], + ) + .model::(Todos::permissions()) + .model::(ChatMessages::permissions()) + .model::(BlobGames::permissions()) + .model::(AuthUsers::permissions()) + .service(service) + .client_projection_owners([ + projections.todo.into(), + projections.chat.into(), + projections.blob.into(), + ]) + .identity(identity) + .graphiql(graphiql); + if let Some(rx) = change_rx { + b = b.change_stream(rx); + } + b.build().map_err(|e| e.to_string()) +} + +fn pool_free_client_surface(application: &str, roles: &[&str]) -> DistributedClientSurfaceExport { + pool_free_client_surface_contract(application, roles, roles) +} + +fn pool_free_client_surface_contract( + application: &str, + eligible_roles: &[&str], + schema_roles: &[&str], +) -> DistributedClientSurfaceExport { + let project = e2e_readmodels::distributed_manifest(); + let repository = InMemoryRepository::new(); + let service = crate::modules::compose::build_service( + repository.clone(), + ClientSurfaceLocks::default(), + repository, + ); + let projections = projections::projection_owners(); + let full = build_surface(&project.tables, &SurfaceOptions::sqlite()) + .expect("e2e-ui client Surface should build") + .with_projection_owners([ + projections.todo.into(), + projections.chat.into(), + projections.blob.into(), + ]) + .expect("e2e-ui projector topology should bind") + .with_service(&service) + .expect("e2e-ui typed Service inventory should bind"); + let eligible = eligible_roles + .iter() + .map(|role| (*role).to_string()) + .collect::>(); + let schema = schema_roles + .iter() + .map(|role| (*role).to_string()) + .collect::>(); + let grants = e2e_readmodels::application_grants(); + let selected = + surface_for_application_contract(&full, application, &eligible, &schema, &grants) + .expect("e2e-ui application Surface should select"); + DistributedClientSurfaceExport::from_selected("e2e-ui", selected) + .expect("e2e-ui application Surface should export") +} + +/// Pool-free normal application export consumed by `distributed client-manifest`. +pub fn distributed_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface_contract(DISTRIBUTED_CLIENT_SURFACE, &["admin", "user"], &["user"]) +} + +pub fn distributed_admin_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, &["admin"]) +} + +pub fn distributed_public_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, &["anonymous"]) +} + +pub fn dev_identity() -> IdentityConfig { + IdentityConfig::dev_headers() +} + +pub fn graphiql_enabled() -> bool { + match std::env::var("GRAPHIQL") { + Ok(v) => { + let v = v.trim(); + !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off")) + } + Err(_) => true, + } +} + +fn env_clean(name: &str) -> String { + let mut s = std::env::var(name).unwrap_or_default().trim().to_string(); + for _ in 0..2 { + if s.len() >= 2 + && ((s.starts_with('\'') && s.ends_with('\'')) + || (s.starts_with('"') && s.ends_with('"'))) + { + s = s[1..s.len() - 1].trim().to_string(); + } else { + break; + } + } + s +} + +pub fn identity_from_env() -> IdentityConfig { + let iss = env_clean("OIDC_ISSUER"); + let aud = env_clean("OIDC_AUDIENCE"); + if iss.is_empty() || aud.is_empty() { + eprintln!("e2e-ui: OIDC_* unset — using DevHeaders (local only)"); + return dev_identity(); + } + let jwks = env_clean("OIDC_JWKS_URI"); + eprintln!("e2e-ui: OidcBearer issuer={iss} audience={aud}"); + oidc_bearer_config( + iss, + aud, + if jwks.is_empty() { None } else { Some(jwks) }, + None, + ) +} + +pub fn oidc_bearer_config( + issuer: impl Into, + audience: impl Into, + jwks_uri: Option, + static_jwks: Option, +) -> IdentityConfig { + let mut oidc = OidcConfig::new(issuer, audience); + if let Some(uri) = jwks_uri.filter(|s| !s.is_empty()) { + oidc.jwks_uri = Some(uri); + } + if let Some(jwks) = static_jwks { + oidc = oidc.with_static_jwks(jwks); + } + let cid = env_clean("OIDC_CLIENT_ID"); + if !cid.is_empty() { + oidc.extra_audiences = vec![cid]; + } + oidc.claim_map.engine_roles = vec!["user".into(), "admin".into()]; + oidc.claim_map.role_claims = vec![ + "groups".into(), + "roles".into(), + "realm_access.roles".into(), + "urn:zitadel:iam:org:project:roles".into(), + ]; + oidc.require_auth = false; + IdentityConfig::oidc_bearer(oidc) +} + +#[cfg(test)] +mod client_surface_tests { + use super::*; + use crate::application::{DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE}; + use crate::modules::compose::build_service; + use distributed::InMemoryRepository; + + #[test] + fn pool_free_user_and_admin_exports_compile_real_manifests() { + distributed_client_surface() + .manifest() + .expect("normal application client manifest"); + distributed_admin_client_surface() + .manifest() + .expect("elevated application client manifest"); + } + + #[test] + fn application_todos_keep_portable_owner_row_policy_for_optimistic_list_inserts() { + use distributed::graphql::ClientRowPolicy; + + let manifest = distributed_client_surface().manifest().unwrap(); + let todos = manifest + .models + .iter() + .find(|model| model.typename == "Todos") + .expect("Todos model on application surface"); + match &todos.row_policy { + ClientRowPolicy::Predicate { expression } => { + let text = serde_json::to_string(expression).expect("serialize row policy"); + assert!( + text.contains("x-user-id") && text.contains("owner_id"), + "owner claim predicate must be client-portable: {text}" + ); + } + other => panic!( + "Todos must not collapse to server-only row policy (blocks optimistic create list membership); got {other:?}" + ), + } + + let blob = manifest + .models + .iter() + .find(|model| model.typename == "BlobGames") + .expect("BlobGames model on application surface"); + assert!( + matches!(blob.row_policy, ClientRowPolicy::Predicate { .. }), + "BlobGames should keep portable owner row policy" + ); + } + + #[test] + fn todo_commands_auto_derive_optimism_without_applies() { + use distributed::graphql::{ClientProjectionPreviewSource, ClientProjectionValue}; + + let manifest = distributed_client_surface().manifest().unwrap(); + let create = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_create") + .expect("todos_create command"); + let projection = create + .extensions + .projection + .as_ref() + .expect("todos_create must export projection extension"); + assert!( + !projection.preview_occurrences.is_empty(), + "auto-optimism must invent preview occurrences from emits + projection arms" + ); + let sources: Vec<_> = projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["title"] + )), + "create title must map from command input: {sources:?}" + ); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::GeneratedDefault { path } if path == &["todo_id"] + )), + "create todo_id must map from generated default: {sources:?}" + ); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::TrustedPreset { name, codec } + if name == "x-user-id" && codec == "string" + )), + "create owner_id must map from row-policy claim: {sources:?}" + ); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Constant { + value: ClientProjectionValue::String(value), + } if value == "open" + )), + "create status must come from the sourced transition: {sources:?}" + ); + assert!( + sources + .iter() + .any(|source| matches!(source, ClientProjectionPreviewSource::Null)), + "create assignee_id must come from the sourced transition: {sources:?}" + ); + + // Sparse update commands only need the known input slots. + let rename = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_rename") + .expect("todos_rename command"); + let rename_projection = rename + .extensions + .projection + .as_ref() + .expect("todos_rename projection"); + let rename_sources: Vec<_> = rename_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + rename_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["title"] + )), + "rename title must map from input without .applies: {rename_sources:?}" + ); + assert!( + rename_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["todo_id"] + )), + "rename todo_id must map from input without .applies: {rename_sources:?}" + ); + + for (mutation_field, expected_status) in [ + ("todos_complete", "completed"), + ("todos_reopen", "open"), + ("todos_archive", "archived"), + ] { + let command = manifest + .commands + .iter() + .find(|command| command.mutation_field == mutation_field) + .unwrap_or_else(|| panic!("{mutation_field} command")); + let status_sources: Vec<_> = command + .extensions + .projection + .as_ref() + .unwrap_or_else(|| panic!("{mutation_field} projection")) + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + status_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Constant { + value: ClientProjectionValue::String(value), + } if value == expected_status + )), + "{mutation_field} status must come from the sourced transition: {status_sources:?}" + ); + } + + let purge = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_purge") + .expect("todos_purge command"); + let purge_projection = purge + .extensions + .projection + .as_ref() + .expect("todos_purge projection"); + let purge_sources: Vec<_> = purge_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + purge_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["todo_id"] + )), + "purge aggregate id must map from input without envelope .applies: {purge_sources:?}" + ); + } + + #[test] + fn chat_and_blob_commands_auto_derive_optimism_without_applies() { + use distributed::graphql::ClientProjectionPreviewSource; + + let manifest = distributed_client_surface().manifest().unwrap(); + + let post = manifest + .commands + .iter() + .find(|command| command.mutation_field == "chat_messages_post") + .expect("chat_messages_post command"); + let post_projection = post + .extensions + .projection + .as_ref() + .expect("chat post projection"); + let post_sources: Vec<_> = post_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + !post_projection.preview_occurrences.is_empty(), + "chat post must auto-derive preview occurrences" + ); + assert!( + post_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["body"] + )), + "chat body from input: {post_sources:?}" + ); + assert!( + post_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["message_id"] + )), + "chat message_id from input: {post_sources:?}" + ); + assert!( + post_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::TrustedPreset { name, codec } + if name == "x-user-id" && codec == "string" + )), + "chat author_id must bind the authenticated user without .applies: {post_sources:?}" + ); + + let blob_move = manifest + .commands + .iter() + .find(|command| command.mutation_field == "blob_games_move") + .expect("blob_games_move command"); + let move_projection = blob_move + .extensions + .projection + .as_ref() + .expect("blob move projection"); + assert!( + !move_projection.preview_occurrences.is_empty(), + "blob move still exports projection arms for Atomic sealing" + ); + // Thin input: only game_id + direction. Board fields come from pure + // reduce (`blob.simulate_move` over the known cache row) + Atomic seal. + let move_input = match &blob_move.input { + distributed::graphql::ClientCommandShape::Object { definition } => definition, + other => panic!("blob move should be object input, got {other:?}"), + }; + let field_names: Vec<_> = move_input + .fields + .iter() + .map(|field| field.name.as_str()) + .collect(); + assert_eq!( + field_names, + vec!["direction", "game_id"], + "blob move input must stay thin (no fat board fields on the wire)" + ); + } + + #[test] + fn chat_manifest_uses_unit_partition_so_lobby_live_can_stay_active() { + let manifest = distributed_client_surface().manifest().unwrap(); + let program = manifest + .projection_programs + .iter() + .find(|program| program.name == "project_chat_messages") + .expect("Chat projection program should be exported"); + assert!( + program.arms.iter().all(|arm| matches!( + &arm.partition, + distributed::graphql::ClientProjectionPartition::Unit + )), + "lobby chat uses unit partition so the chat_messages live query can advertise \ + supported index evidence (room isolation stays in the GraphQL where clause). \ + Surface-wide live_resume may still be false when owner-scoped models share the surface." + ); + } + + #[test] + fn blob_projection_owner_has_no_async_fact_route() { + let manifest = distributed_client_surface().manifest().unwrap(); + let owner = manifest + .projectors + .iter() + .find(|projector| projector.name == "project_blob") + .expect("Blob direct owner should be exported"); + assert!(owner.facts.is_empty()); + assert!(!owner.causal_confirmation); + + let repository = InMemoryRepository::new(); + let service = build_service( + repository.clone(), + crate::modules::graphql::ClientSurfaceLocks::default(), + repository, + ); + let plan = service.subscription_plan(); + for event in [ + "todo.created", + "todo.renamed", + "todo.completed", + "todo.reopened", + "todo.archived", + "todo.force_archived", + "todo.purged", + "chat_message.posted", + ] { + assert!( + plan.events.iter().any(|candidate| candidate == event), + "eventual modeled projection must subscribe to {event}" + ); + } + for fact in [ + "blob.started", + "blob.initialized", + "blob.level_started", + "blob.moved", + ] { + assert!( + !plan.events.iter().any(|event| event == fact), + "direct-only Blob ownership must not register an async route for {fact}" + ); + } + } + + #[tokio::test] + async fn graphiql_does_not_change_the_postgres_runtime_client_manifest() { + let generated = distributed_client_surface().manifest().unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://postgres:postgres@localhost/distributed") + .unwrap(); + let repository = distributed::PostgresRepository::new(pool.clone()); + let service = build_service( + repository.clone(), + distributed::PostgresLockManager::new(pool), + repository.clone(), + ); + let engine = crate::modules::graphql::build_graphql_engine_with_graphiql( + &repository, + &service, + dev_identity(), + None, + true, + ) + .expect("engine"); + let runtime = engine + .client_manifest_for_application( + DISTRIBUTED_CLIENT_SURFACE, + &["admin", "user"], + &["user"], + ) + .unwrap(); + + assert_eq!(generated, runtime); + + let make_request = || { + serde_json::from_value(serde_json::json!({ + "query": "{ todos @skip(if: true) { todo_id } }", + "extensions": { + "distributed": { + "client": { + "surface": { + "kind": "application", + "name": DISTRIBUTED_CLIENT_SURFACE, + "eligible_roles": ["admin", "user"], + "schema_roles": ["user"] + }, + "schemaHash": generated.schema_fingerprint + } + } + } + })) + .expect("generated application request") + }; + let mut session = distributed::microsvc::Session::new(); + session.set("x-roles", "user"); + session.set("x-user-id", "person-1"); + let response = engine.execute(&session, make_request()).await; + assert!( + !response.is_err(), + "the runtime must accept the generated application surface: {:?}", + response.errors + ); + // Multi-role admin principal may open the same portable contract. + let mut admin = session.clone(); + admin.set("x-roles", "admin,user"); + let admin_response = engine.execute(&admin, make_request()).await; + assert!( + !admin_response.is_err(), + "admin with user asserted roles must open e2e-ui: {:?}", + admin_response.errors + ); + let envelope = response + .extensions + .get("distributed") + .expect("distributed protocol envelope"); + let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); + assert_eq!( + envelope["schemaHash"], generated.schema_fingerprint, + "the authoritative response must attest the generated schema" + ); + } + + /// Empty-session open of e2e-ui-public + chat query (anonymous privilege). + /// + /// Bare protocol path for unauthenticated lobby peeks; UI route `/public` + /// documents the same surface name and extension shape. + #[tokio::test] + async fn public_surface_opens_and_queries_chat_without_identity() { + let generated = distributed_public_client_surface().manifest().unwrap(); + assert_eq!( + generated.surface, + distributed::graphql::ClientSurfaceIdentity::application( + DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + ["anonymous"], + ["anonymous"], + ) + ); + let repository = distributed::SqliteRepository::connect_and_migrate("sqlite::memory:") + .await + .expect("sqlite memory repo"); + let registry = e2e_readmodels::distributed_manifest() + .table_registry() + .expect("registry"); + repository + .bootstrap_table_schema_for_dev(®istry) + .await + .expect("bootstrap tables"); + let service = build_service( + repository.clone(), + crate::modules::graphql::ClientSurfaceLocks::default(), + repository.clone(), + ); + let engine = crate::modules::graphql::build_graphql_engine_with_graphiql( + &repository, + &service, + dev_identity(), + None, + false, + ) + .expect("engine"); + let runtime = engine + .client_manifest_for_application( + DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + &["anonymous"], + &["anonymous"], + ) + .expect("public surface registered"); + assert_eq!(generated.schema_fingerprint, runtime.schema_fingerprint); + + let request = serde_json::from_value(serde_json::json!({ + "query": "{ chat_messages(limit: 5, offset: 0) { message_id body room_id } }", + "extensions": { + "distributed": { + "client": { + "surface": { + "kind": "application", + "name": DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + "eligible_roles": ["anonymous"], + "schema_roles": ["anonymous"] + }, + "schemaHash": generated.schema_fingerprint + } + } + } + })) + .expect("public application request"); + + // No x-user-id, no x-roles — unauthenticated principal. + let session = distributed::microsvc::Session::new(); + let response = engine.execute(&session, request).await; + assert!( + !response.is_err(), + "anonymous open + chat query must succeed: {:?}", + response.errors + ); + let data = response.data.into_json().expect("json data"); + assert!( + data.get("chat_messages") + .and_then(|v| v.as_array()) + .is_some(), + "expected chat_messages array: {data}" + ); + let envelope = response + .extensions + .get("distributed") + .expect("distributed protocol envelope"); + let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); + assert_eq!(envelope["schemaHash"], generated.schema_fingerprint); + } + + #[test] + fn module_inventory_lists_todo_chat_blob_identity() { + assert_eq!( + crate::E2E_UI_MODULE_IDS, + &["todo", "chat", "blob", "identity"] + ); + assert_eq!(crate::application::MODULE_DECLARATIONS.len(), 4); + } +} diff --git a/tests/e2e-celld/crates/graphql-service/src/modules/mod.rs b/tests/e2e-celld/crates/graphql-service/src/modules/mod.rs new file mode 100644 index 000000000..068b0ba61 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/modules/mod.rs @@ -0,0 +1,8 @@ +//! Bounded-context application modules for e2e-ui. +//! +//! Each module owns its command/projection mounts. [`compose`] lists them +//! into one Service; [`graphql`] owns surfaces and the query engine. + +pub mod compose; +pub mod graphql; +pub mod projections; diff --git a/tests/e2e-celld/crates/graphql-service/src/modules/projections.rs b/tests/e2e-celld/crates/graphql-service/src/modules/projections.rs new file mode 100644 index 000000000..69b4723c7 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/modules/projections.rs @@ -0,0 +1,43 @@ +//! e2e-ui projection mounts — product declaration only. +//! +//! Topology, catalog activation, and Surface packaging come from +//! [`distributed::LocalProjectionMountsBuilder`]. + +use distributed::graphql::{SurfaceDirectProjection, SurfaceProjector}; +use distributed::LocalProjectionMountsBuilder; +use e2e_projections::{BLOB_GAMES, CHAT_MESSAGES, TODOS}; +use e2e_readmodels::{BlobGames, ChatMessages, Todos}; + +/// Projection surface mounts used by compose + GraphQL. +#[derive(Clone)] +pub struct ProjectionOwners { + pub todo: SurfaceProjector, + pub chat: SurfaceProjector, + pub blob: SurfaceDirectProjection, +} + +/// Compile local projection mounts for the e2e-ui application. +pub fn projection_owners() -> ProjectionOwners { + let mounts = LocalProjectionMountsBuilder::new("e2e-ui", "ordered-domain-events") + .expect("projection source") + .eventual_model::("project_todos", TODOS, "e2e-ui-todos-v2") + .expect("todo mount") + .eventual_model::("project_chat_messages", CHAT_MESSAGES, "e2e-ui-chat-v2") + .expect("chat mount") + .direct_model::("project_blob", BLOB_GAMES, "e2e-ui-blob-v2") + .expect("blob mount") + .build() + .expect("projection catalog"); + + ProjectionOwners { + todo: mounts + .projector("project_todos") + .expect("todo projector"), + chat: mounts + .projector("project_chat_messages") + .expect("chat projector"), + blob: mounts + .direct_projection("project_blob") + .expect("blob direct"), + } +} diff --git a/tests/e2e-celld/crates/graphql-service/src/oidc_layer.rs b/tests/e2e-celld/crates/graphql-service/src/oidc_layer.rs new file mode 100644 index 000000000..6dbb2ba31 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/oidc_layer.rs @@ -0,0 +1,312 @@ +//! Tower layer: under OidcBearer/Hybrid, **require** a valid access token and +//! inject claim-derived `x-user-id` / `x-roles` for command routes. +//! +//! Security: client-supplied identity headers are stripped before validation so +//! spoofed `x-user-id` cannot pass when Bearer is missing or invalid. +//! GraphQL already uses IdentityConfig; commands only read Session headers — +//! this layer bridges OIDC → DevHeaders-shaped keys for handlers. + +use std::collections::HashMap; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::body::Body; +use axum::http::{header, HeaderMap, Method, Request, Response, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::Json; +use axum::Router; +use distributed::command_dispatch::SharedCommandHost; +use distributed::graphql::{ + graphql_router_with_host, AuthError, IdentityConfig, IdentityMode, IdentityResolver, + DEFAULT_IDENTITY_STRIP_HEADERS, +}; +use distributed::microsvc::{HandlerError, Service, Session}; +use futures_util::future::BoxFuture; +use serde_json::{json, Value}; +use tower::{Layer, Service as TowerService}; + +#[derive(Clone)] +pub struct OidcIdentityLayer { + resolver: Arc, +} + +impl OidcIdentityLayer { + pub fn new(identity: IdentityConfig) -> Self { + Self { + resolver: Arc::new(IdentityResolver::new(identity)), + } + } +} + +impl Layer for OidcIdentityLayer { + type Service = OidcIdentityService; + + fn layer(&self, inner: S) -> Self::Service { + OidcIdentityService { + inner, + resolver: Arc::clone(&self.resolver), + } + } +} + +#[derive(Clone)] +pub struct OidcIdentityService { + inner: S, + resolver: Arc, +} + +fn skip_oidc_gate(method: &Method, path: &str) -> bool { + // Public probes + GraphiQL HTML + WS upgrade (auth on connection_init). + // Zitadel Action ingress uses shared-secret authenticity (not OIDC bearer). + matches!( + path, + "/health" | "/metrics" | "/graphql/ws" | "/zitadel.ingress.v1" | "/zitadel.scrape.v1" + ) || (path == "/graphql" && *method == Method::GET) +} + +fn unauthorized_response() -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"error":"unauthorized","extensions":{"code":"UNAUTHENTICATED"}}"#, + )) + .expect("401 response") +} + +/// Strip client-supplied identity headers (same list as TrustedProxy defaults). +fn strip_client_identity(headers: &mut HeaderMap) { + for name in DEFAULT_IDENTITY_STRIP_HEADERS { + headers.remove(*name); + } + // Also strip common casing variants axum may have normalized differently. + headers.remove("x-user-id"); + headers.remove("x-role"); + headers.remove("x-roles"); +} + +impl TowerService> for OidcIdentityService +where + S: TowerService, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + let mut inner = self.inner.clone(); + let resolver = Arc::clone(&self.resolver); + Box::pin(async move { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + if !matches!( + resolver.config().mode, + IdentityMode::OidcBearer | IdentityMode::Hybrid + ) { + // DevHeaders: ambient headers trusted only for local/offline. + return inner.call(req).await; + } + + if skip_oidc_gate(&method, &path) { + return inner.call(req).await; + } + + // Fail closed: never trust client identity headers under OidcBearer. + strip_client_identity(req.headers_mut()); + + match resolver.resolve_session(req.headers()).await { + Ok(session) => { + if let Some(uid) = session.user_id() { + if let Ok(v) = axum::http::HeaderValue::from_str(uid) { + req.headers_mut().insert("x-user-id", v); + } + } + let roles = session.roles(); + if !roles.is_empty() { + let joined = roles.join(","); + if let Ok(v) = axum::http::HeaderValue::from_str(&joined) { + req.headers_mut().insert("x-roles", v); + } + } + // Authenticated with empty role set stays empty (anonymous-eligible + // surfaces only) — no synthetic default role injection. + inner.call(req).await + } + Err(AuthError::Unauthorized) => Ok(unauthorized_response()), + } + }) + } +} + +fn session_from_headers(headers: &HeaderMap) -> Session { + let mut vars = HashMap::new(); + for (name, value) in headers.iter() { + if let Ok(v) = value.to_str() { + vars.insert(name.as_str().to_string(), v.to_string()); + } + } + Session::from_map(vars) +} + +fn status_for_error(error: &HandlerError) -> StatusCode { + match error { + HandlerError::UnknownCommand(_) | HandlerError::NotFound(_) => StatusCode::NOT_FOUND, + HandlerError::DecodeFailed(_) | HandlerError::GuardRejected(_) => StatusCode::BAD_REQUEST, + HandlerError::Rejected(_) => StatusCode::UNPROCESSABLE_ENTITY, + HandlerError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + HandlerError::Repository(_) | HandlerError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, + // HandlerError is non_exhaustive. + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +/// Dispatch a named HTTP command (Zitadel ingress/scrape only). +async fn dispatch_named( + service: Arc, + headers: HeaderMap, + input: Value, + command: &'static str, +) -> impl IntoResponse { + let session = session_from_headers(&headers); + match service.dispatch(command, input, session).await { + Ok(value) => (StatusCode::OK, Json(value)).into_response(), + Err(err) => { + let status = status_for_error(&err); + if status.is_server_error() { + eprintln!("microsvc command `{command}` failed: {err}"); + } + let body = json!({ "error": err.client_facing_message() }); + (status, Json(body)).into_response() + } + } +} + +/// Serve with OIDC identity injection on all routes (commands + GraphQL). +/// +/// App writes are GraphQL-only (HTTP command routes stay off). Zitadel +/// Action ingress still needs HTTP, so those two command names are mounted +/// explicitly — `POST /todo.create` stays 404 (suite T0). +/// +/// Note: `microsvc::router` already applies `.with_state(service)`, so handlers +/// cannot use `State>`. Capture the `Arc` in the route closures. +#[allow(dead_code)] +pub async fn serve_with_oidc( + service: Arc, + identity: IdentityConfig, + addr: &str, +) -> Result<(), std::io::Error> { + let ingress = service.clone(); + let scrape = service.clone(); + let app = distributed::microsvc::router(service) + .route( + "/zitadel.ingress.v1", + post(move |headers: HeaderMap, Json(input): Json| { + let svc = ingress.clone(); + async move { dispatch_named(svc, headers, input, "zitadel.ingress.v1").await } + }), + ) + .route( + "/zitadel.scrape.v1", + post(move |headers: HeaderMap, Json(input): Json| { + let svc = scrape.clone(); + async move { dispatch_named(svc, headers, input, "zitadel.scrape.v1").await } + }), + ) + .layer(OidcIdentityLayer::new(identity)); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await +} + +/// GraphQL wait-dispatches through an explicit [`SharedCommandHost`]. +pub async fn serve_with_oidc_and_host( + service: Arc, + host: SharedCommandHost, + identity: IdentityConfig, + addr: &str, +) -> Result<(), std::io::Error> { + let engine = service + .graphql_engine() + .ok_or_else(|| std::io::Error::other("serve_with_oidc_and_host requires GraphQL"))?; + let ingress = service.clone(); + let scrape = service.clone(); + let commands: Vec = service + .command_names() + .into_iter() + .map(str::to_string) + .collect(); + let health_body = json!({ + "ok": true, + "profile": "celld", + "graphql": true, + "commands": commands, + }); + let app = Router::new() + .route( + "/health", + get(move || { + let body = health_body.clone(); + async move { Json(body) } + }), + ) + .merge(graphql_router_with_host(engine, host)) + .route( + "/zitadel.ingress.v1", + post(move |headers: HeaderMap, Json(input): Json| { + let svc = ingress.clone(); + async move { dispatch_named(svc, headers, input, "zitadel.ingress.v1").await } + }), + ) + .route( + "/zitadel.scrape.v1", + post(move |headers: HeaderMap, Json(input): Json| { + let svc = scrape.clone(); + async move { dispatch_named(svc, headers, input, "zitadel.scrape.v1").await } + }), + ) + .layer(OidcIdentityLayer::new(identity)); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skip_gate_paths() { + assert!(skip_oidc_gate(&Method::GET, "/health")); + assert!(skip_oidc_gate(&Method::GET, "/graphql/ws")); + assert!(skip_oidc_gate(&Method::GET, "/graphql")); + assert!(!skip_oidc_gate(&Method::POST, "/graphql")); + // Zitadel Action ingress + scrape use shared secret, not OIDC bearer. + assert!(skip_oidc_gate(&Method::POST, "/zitadel.ingress.v1")); + assert!(skip_oidc_gate(&Method::POST, "/zitadel.scrape.v1")); + // Other HTTP command routes still require OIDC under OidcBearer. + assert!(!skip_oidc_gate(&Method::POST, "/todo.create")); + assert!(!skip_oidc_gate(&Method::POST, "/graphql")); + } + + #[test] + fn strip_removes_spoof_headers() { + let mut h = HeaderMap::new(); + h.insert("x-user-id", "attacker".parse().unwrap()); + h.insert("x-roles", "admin".parse().unwrap()); + h.insert("x-role", "admin".parse().unwrap()); // legacy spoof — still stripped + h.insert("authorization", "Bearer tok".parse().unwrap()); + strip_client_identity(&mut h); + assert!(!h.contains_key("x-user-id")); + assert!(!h.contains_key("x-roles")); + assert!(!h.contains_key("x-role")); + // Authorization must survive for resolve_session + assert!(h.contains_key("authorization")); + } +} diff --git a/tests/e2e-celld/crates/runner/Cargo.toml b/tests/e2e-celld/crates/runner/Cargo.toml new file mode 100644 index 000000000..d450e5f06 --- /dev/null +++ b/tests/e2e-celld/crates/runner/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "e2e-celld-runner" +version.workspace = true +edition.workspace = true +publish = false +description = "Runner for the celld GraphQL example (not make run in e2e-ui)" + +[[bin]] +name = "e2e-celld" +path = "src/main.rs" + +[dependencies] +e2e-celld-graphql = { path = "../graphql-service" } +tokio = { workspace = true } diff --git a/tests/e2e-celld/crates/runner/src/main.rs b/tests/e2e-celld/crates/runner/src/main.rs new file mode 100644 index 000000000..24e268ead --- /dev/null +++ b/tests/e2e-celld/crates/runner/src/main.rs @@ -0,0 +1,31 @@ +//! Celld example runner (not `tests/e2e-ui` / `make run`). +//! +//! Env: +//! - `CELLD_URL` — required +//! - `DATABASE_URL` — `sqlite:…` (default) or `postgres://…` +//! - `BIND` (default `127.0.0.1:8791`) +//! - `OIDC_*` → OidcBearer; else DevHeaders + +use std::env; + +use e2e_celld_graphql::{identity_from_env, run, HostOptions}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let database_url = + env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:./e2e-celld.db?mode=rwc".into()); + let bind = env::var("BIND").unwrap_or_else(|_| "127.0.0.1:8791".into()); + let celld_url = env::var("CELLD_URL").map_err(|_| { + "CELLD_URL is required. Start infra: make -C tests/e2e-ui up-celld-nats" + })?; + eprintln!("e2e-celld CELLD_URL={celld_url}"); + run( + &database_url, + HostOptions { + bind, + identity: identity_from_env(), + celld_url, + }, + ) + .await +} diff --git a/tests/e2e-celld/crates/todo-service/Cargo.toml b/tests/e2e-celld/crates/todo-service/Cargo.toml new file mode 100644 index 000000000..4279f0aae --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "e2e-celld-todo" +version.workspace = true +edition.workspace = true +publish = false +description = "Todo service: local dual-write mounts + celld HttpCommandHost wait-path" + +[dependencies] +distributed = { workspace = true } +async-trait = { workspace = true } +serde_json = { workspace = true } +todo-domain = { path = "../../../e2e-ui/crates/todo-domain" } +e2e-projections = { path = "../../../e2e-ui/crates/projections" } diff --git a/tests/e2e-celld/crates/todo-service/src/bounds.rs b/tests/e2e-celld/crates/todo-service/src/bounds.rs new file mode 100644 index 000000000..dbaf42fe0 --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/src/bounds.rs @@ -0,0 +1,40 @@ +//! Trait aliases for generic handler storage parameters. + +use distributed::microsvc::{CausalProjectionStore, CausalRepositoryBackend}; +use distributed::{ + GetStream, LockManager, ReadModelWritePlanStore, RelationalReadModelQueryStore, + TransactionalCommit, +}; + +pub trait EventStore: + CausalRepositoryBackend + GetStream + TransactionalCommit + Clone + Send + Sync + 'static +{ +} +impl EventStore for T where + T: CausalRepositoryBackend + GetStream + TransactionalCommit + Clone + Send + Sync + 'static +{ +} + +pub trait Locks: LockManager + Clone + 'static {} +impl Locks for T where T: LockManager + Clone + 'static {} + +pub trait ReadStore: + CausalProjectionStore + + ReadModelWritePlanStore + + RelationalReadModelQueryStore + + Clone + + Send + + Sync + + 'static +{ +} +impl ReadStore for T where + T: CausalProjectionStore + + ReadModelWritePlanStore + + RelationalReadModelQueryStore + + Clone + + Send + + Sync + + 'static +{ +} diff --git a/tests/e2e-celld/crates/todo-service/src/handlers/mod.rs b/tests/e2e-celld/crates/todo-service/src/handlers/mod.rs new file mode 100644 index 000000000..a5bc70692 --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/src/handlers/mod.rs @@ -0,0 +1 @@ +pub mod project_todos; diff --git a/tests/e2e-celld/crates/todo-service/src/handlers/project_todos.rs b/tests/e2e-celld/crates/todo-service/src/handlers/project_todos.rs new file mode 100644 index 000000000..4e13f8fe0 --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/src/handlers/project_todos.rs @@ -0,0 +1,11 @@ +//! Apply the Todos projection for matching domain events. + +use distributed::microsvc::{CausalProjectorContext, HandlerError, ModeledProjection}; +use e2e_projections::TODOS; + +pub async fn handle( + context: CausalProjectorContext, + projection: ModeledProjection, +) -> Result<(), HandlerError> { + projection.apply(TODOS, &context).await +} diff --git a/tests/e2e-celld/crates/todo-service/src/host.rs b/tests/e2e-celld/crates/todo-service/src/host.rs new file mode 100644 index 000000000..ad1fef47e --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/src/host.rs @@ -0,0 +1,128 @@ +//! Wait-path host: celld for create/complete, local dual-write for SQL lists. + +use std::sync::Arc; + +use async_trait::async_trait; +use distributed::command_dispatch::{CommandHost, HttpCommandHost, LocalCommandHost}; +use distributed::graphql::protocol::ProtocolResponseAccumulator; +use distributed::graphql::VerifiedPrincipal; +use distributed::microsvc::{ + CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult, Service, Session, +}; +use serde_json::{json, Value}; + +const CELLD_TODO_COMMANDS: &[&str] = &["todo.create", "todo.complete"]; + +/// Routes `todo.create` / `todo.complete` to `{CELLD_URL}/todo/{id}/{command}`. +/// Other commands stay on the local [`Service`] so Chat/Blob and extra Todo +/// transitions keep working. After a cell wait-path succeeds, the local host +/// runs too so Eventual SQL lists fill (projectors are not cell methods). +pub struct CelldTodoCommandHost { + celld_url: String, + local: LocalCommandHost, +} + +impl CelldTodoCommandHost { + pub fn new(celld_url: impl Into, service: Arc) -> Self { + Self { + celld_url: celld_url.into().trim_end_matches('/').to_string(), + local: LocalCommandHost::new(service), + } + } +} + +#[async_trait] +impl CommandHost for CelldTodoCommandHost { + async fn invoke( + &self, + command: &str, + command_id: &str, + input: Value, + session: Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result { + if !CELLD_TODO_COMMANDS.contains(&command) { + return self + .local + .invoke(command, command_id, input, session, principal, protocol) + .await; + } + let todo_id = input + .get("todo_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + CausalDispatchError::BadRequest("todo_id required for celld wait-path".into()) + })?; + let remote = HttpCommandHost::new(format!("{}/todo/{todo_id}", self.celld_url)); + let remote = remote + .invoke( + command, + command_id, + input.clone(), + session.clone(), + principal.clone(), + None, + ) + .await?; + match self + .local + .invoke( + command, + command_id, + input.clone(), + session.clone(), + principal, + protocol, + ) + .await + { + Ok(local) => Ok(local), + Err(error) => { + eprintln!("e2e-celld: local dual-write after cell wait-path failed: {error:?}"); + let payload = graphql_todo_payload(command, &input, remote.payload(), &session); + Ok(remote.with_payload(payload)) + } + } + } + + async fn status( + &self, + command_id: &str, + session: &Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result { + self.local + .status(command_id, session, principal, protocol) + .await + } +} + +fn graphql_todo_payload(command: &str, input: &Value, remote: &Value, session: &Session) -> Value { + let id = remote + .get("todo_id") + .or_else(|| remote.get("id")) + .or_else(|| input.get("todo_id")) + .cloned() + .unwrap_or(json!("")); + let status = remote.get("status").cloned().unwrap_or_else(|| { + if command == "todo.complete" { + json!("completed") + } else { + json!("open") + } + }); + if command == "todo.complete" { + json!({ "todo_id": id, "status": status }) + } else { + json!({ + "todo_id": id, + "owner_id": session.user_id().unwrap_or("celld-local"), + "title": remote.get("title").or_else(|| input.get("title")).cloned().unwrap_or(json!("")), + "status": status, + }) + } +} diff --git a/tests/e2e-celld/crates/todo-service/src/lib.rs b/tests/e2e-celld/crates/todo-service/src/lib.rs new file mode 100644 index 000000000..bd95c9446 --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/src/lib.rs @@ -0,0 +1,13 @@ +//! Todo service crate for the celld example. +//! +//! Domain commands stay in `todo-domain`. This crate mounts them for local +//! dual-write (SQL lists) and wait-dispatches `todo.create` / `todo.complete` +//! to celld through [`CelldTodoCommandHost`]. + +mod bounds; +mod handlers; +mod host; +mod routes; + +pub use host::CelldTodoCommandHost; +pub use routes::{routes, MODULE_ID}; diff --git a/tests/e2e-celld/crates/todo-service/src/routes.rs b/tests/e2e-celld/crates/todo-service/src/routes.rs new file mode 100644 index 000000000..4e20d90cf --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/src/routes.rs @@ -0,0 +1,48 @@ +//! Todo command mounts + eventual projector (SQL list dual-write). + +use distributed::graphql::SurfaceProjector; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; +use todo_domain::Todo; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers; + +pub const MODULE_ID: &str = "todo"; + +type TodoRoutes = + Routes, Todo>, S>>; + +pub fn routes( + repo: R, + locks: L, + read_models: S, + todo_projector: SurfaceProjector, +) -> TodoRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, Todo>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + Routes::for_aggregate::(repo, locks, read_models) + .mount(todo_domain::commands::create()) + .mount(todo_domain::commands::rename()) + .mount(todo_domain::commands::complete()) + .mount(todo_domain::commands::reopen()) + .mount(todo_domain::commands::archive()) + .mount(todo_domain::commands::force_archive()) + .mount(todo_domain::commands::purge()) + .modeled_projector(todo_projector) + .handle(handlers::project_todos::handle) +} From f8a545d634a1304ac293962f5895fa54d41474e7 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 23:59:09 -0500 Subject: [PATCH 20/37] docs(e2e-ui): point optional celld profile at sibling example Navbar shows a CELLD badge when PUBLIC_E2E_PROFILE=celld-nats. make run stays the one-process playground. Implements [[tasks/distributed-command-surfaces-7]] --- tests/e2e-ui/README.md | 5 +++-- tests/e2e-ui/celld-nats-profile/README.md | 6 +++++- .../src/lib/components/shared/header/Navbar.svelte | 7 +++++++ tests/e2e-ui/ui/src/lib/styles/chrome.css | 13 +++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index b41b92099..2dae73184 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -42,8 +42,9 @@ The UI is at `http://localhost:5180`; GraphQL is at with password `Password1!`. This is the **default one-process playground**. An optional celld+NATS -profile of the same UI is `make up-celld-nats` / `make test-celld-nats` -(`celld-nats-profile/`); it is not `make run`. +profile is `make up-celld-nats` / `make test-celld-nats` +(`celld-nats-profile/`); it is not `make run`. The GraphQL+UI host that +wait-dispatches Todo to celld is the sibling example `tests/e2e-celld`. ## The developer experience diff --git a/tests/e2e-ui/celld-nats-profile/README.md b/tests/e2e-ui/celld-nats-profile/README.md index 378ce2da3..084eb3021 100644 --- a/tests/e2e-ui/celld-nats-profile/README.md +++ b/tests/e2e-ui/celld-nats-profile/README.md @@ -20,10 +20,14 @@ make up-celld-nats # Azurite + celld + NATS (not make run) make test-celld-nats # GraphQL wait-path smoke + SQL list make down-celld-nats # NATS only make down-celld # Azurite + celld + +cd ../e2e-celld +make run # new GraphQL service crates + the Svelte UI ``` `tests/e2e-ui/crates/service/src/host.rs` stays a single backend process. -Do not add this topology there. +The playground UI against celld is the sibling example `tests/e2e-celld/` +(new service crates; same domain crates). Do not add that topology here. ## What this profile is 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 c4323ed26..860b6c902 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 @@ -1,5 +1,6 @@ From 4ba7dde53addbc0dbc07eeb1405cd65b12d62c09 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sun, 23 Aug 2026 18:44:29 -0500 Subject: [PATCH 28/37] ci: run celld and e2e-celld tests on PRs and main Add integration-celld.yaml: e2e-celld workspace tests plus live Azurite+celld+NATS (`make test-celld`). Wire it into the PR and main gates so live HTTP no longer skips without CELLD_URL. Implements [[tasks/portable-command-hosts-11]] --- .github/workflows/README.md | 1 + .github/workflows/integration-celld.yaml | 107 ++++++++++++++++++ .github/workflows/on-pr-quality.yaml | 3 + .../on-push-main-version-and-tag.yaml | 5 +- tests/celld/Makefile | 8 +- tests/celld/README.md | 7 ++ tests/e2e-celld/Makefile | 6 +- tests/e2e-celld/README.md | 6 + tests/e2e-ui/Makefile | 16 ++- tests/e2e-ui/celld-nats-profile/README.md | 3 +- 10 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/integration-celld.yaml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b7e5e67c1..c433f72cb 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -13,6 +13,7 @@ existing unbounded-tech quality provider plus the integration/* jobs below — | [`test-all-features.yaml`](./test-all-features.yaml) | yes | **This repo:** workspace `--all-features` | | [`integration-*.yaml`](./) | yes | **This repo:** broker / DB / CLI / observability / GraphQL identity+OIDC | | [`integration-e2e-ui.yaml`](./integration-e2e-ui.yaml) | yes | **This repo:** `tests/e2e-ui` offline suite + Playwright browser e2e | +| [`integration-celld.yaml`](./integration-celld.yaml) | yes | **This repo:** `tests/e2e-celld` workspace tests + live Azurite+celld+NATS | | [`integration-js.yaml`](./integration-js.yaml) | yes | **This repo:** install, typecheck, test, build, and packed-consumer smoke test for `js/` | | [`on-pr-quality.yaml`](./on-pr-quality.yaml) | entry | **This repo** PR gate (not the consumer quality contract) | | [`on-push-main-version-and-tag.yaml`](./on-push-main-version-and-tag.yaml) | entry | **This repo** main → **vnext** tag | diff --git a/.github/workflows/integration-celld.yaml b/.github/workflows/integration-celld.yaml new file mode 100644 index 000000000..798be1eb8 --- /dev/null +++ b/.github/workflows/integration-celld.yaml @@ -0,0 +1,107 @@ +name: celld (live + e2e-celld) + +# Reusable workflow: referenced via `uses: ./.github/workflows/integration-celld.yaml` +# from both the PR-quality and push-to-main pipelines. +# +# Local parity: +# make -C tests/e2e-celld test +# make -C tests/e2e-ui up-celld-nats && make -C tests/e2e-ui test-celld +# +# Default `cargo test` (quality) still runs fixture-only celld checks and +# skips live HTTP unless CELLD_URL is set. This job sets CELLD_URL / NATS_URL. +on: + workflow_call: + +env: + CARGO_TERM_COLOR: always + CELLD_HTTP_PORT: "18080" + CELLD_URL: http://127.0.0.1:18080 + NATS_PORT: "14222" + NATS_URL: nats://127.0.0.1:14222 + AZURE_STORAGE_USE_EMULATOR: "true" + AZURE_STORAGE_ACCOUNT_NAME: devstoreaccount1 + AZURE_STORAGE_ACCOUNT_KEY: Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + +jobs: + e2e-celld: + name: e2e-celld workspace tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: tests/e2e-celld + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tests/e2e-celld -> target + shared-key: e2e-celld-workspace + - name: Run e2e-celld workspace tests + run: cargo test --workspace --verbose + + live: + name: celld live (Azurite + worker + NATS) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + tests/celld/worker -> tests/celld/worker/target + shared-key: celld-live + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install esbuild + run: npm install -g esbuild + + - name: Install wasm-pack and worker-build + run: | + cargo install wasm-pack --locked || cargo install wasm-pack + cargo install worker-build --locked || cargo install worker-build + + - name: Install celld CLI + run: | + curl -fsSL https://celld.dev/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Bring up Azurite + celld + NATS + run: | + command -v celld + command -v worker-build + make -C tests/e2e-ui up-celld-nats + + - name: Live celld HTTP + NATS profile tests + run: make -C tests/e2e-ui test-celld + + - name: Dump logs on failure + if: failure() + run: | + echo '=== celld compose ===' + docker compose -f tests/celld/docker-compose.yml ps -a || true + docker compose -f tests/celld/docker-compose.yml logs --tail=200 || true + echo '=== NATS profile compose ===' + docker compose -f tests/e2e-ui/celld-nats-profile/docker-compose.yml ps -a || true + docker compose -f tests/e2e-ui/celld-nats-profile/docker-compose.yml logs --tail=80 || true + + - name: Tear down + if: always() + run: | + make -C tests/e2e-ui down-celld-nats || true + make -C tests/e2e-ui down-celld || true diff --git a/.github/workflows/on-pr-quality.yaml b/.github/workflows/on-pr-quality.yaml index 56061b40a..6528c0fd9 100644 --- a/.github/workflows/on-pr-quality.yaml +++ b/.github/workflows/on-pr-quality.yaml @@ -75,3 +75,6 @@ jobs: js-client: needs: [contracts] uses: ./.github/workflows/integration-js.yaml + + celld: + uses: ./.github/workflows/integration-celld.yaml diff --git a/.github/workflows/on-push-main-version-and-tag.yaml b/.github/workflows/on-push-main-version-and-tag.yaml index f7cc1ac61..a04f3a99c 100644 --- a/.github/workflows/on-push-main-version-and-tag.yaml +++ b/.github/workflows/on-push-main-version-and-tag.yaml @@ -49,11 +49,14 @@ jobs: js-client: uses: ./.github/workflows/integration-js.yaml + celld: + uses: ./.github/workflows/integration-celld.yaml + # This uses commit logs and tags from git to determine the next version number and create a tag for the release. # Some commits such as chore: will not trigger a version bump and tag; this is by design. version-and-tag: name: Version and Tag - needs: [quality, all-features, postgres, nats, rabbitmq, kafka, distributed-cli, observability, graphql, e2e-ui, js-client] + needs: [quality, all-features, postgres, nats, rabbitmq, kafka, distributed-cli, observability, graphql, e2e-ui, js-client, celld] uses: unbounded-tech/workflow-vnext-tag/.github/workflows/workflow.yaml@v1.22.2 secrets: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} diff --git a/tests/celld/Makefile b/tests/celld/Makefile index 48ae0bdc6..2b24854ad 100644 --- a/tests/celld/Makefile +++ b/tests/celld/Makefile @@ -7,10 +7,12 @@ # a source watcher. Nodes load a deployment at startup, so deploy is not # enough — this target restarts the celld container after each deploy. -.PHONY: reload watch ensure-watch help +.PHONY: reload watch ensure-watch test help REPO_ROOT := $(abspath ../..) COMPOSE ?= docker-compose.yml +CELLD_HTTP_PORT ?= 18080 +CELLD_URL ?= http://127.0.0.1:$(CELLD_HTTP_PORT) # Public Azurite emulator account (already in compose). Not a secret. AZURE_STORAGE_USE_EMULATOR ?= true AZURE_STORAGE_ACCOUNT_NAME ?= devstoreaccount1 @@ -31,6 +33,9 @@ reload: docker compose -f $(COMPOSE) restart celld @echo "celld restarted with new worker (nodes load a deployment at startup)" +test: + cd $(REPO_ROOT) && CELLD_URL="$(CELLD_URL)" cargo test --test celld -- --nocapture + watch: ensure-watch @echo "watching worker + cell_host + todo/chat domain (first change triggers reload)" cd worker && cargo watch \ @@ -49,3 +54,4 @@ help: @echo "celld worker" @echo " make reload worker-build --dev + celld deploy + restart celld" @echo " make watch cargo-watch reload (postpone until first change)" + @echo " make test cargo test --test celld (live HTTP when CELLD_URL is up)" diff --git a/tests/celld/README.md b/tests/celld/README.md index 2bbb13766..0bfae8bd7 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -58,6 +58,13 @@ before `docker compose up` and use that port in `CELLD_URL`. If host port 8080 i Without `CELLD_URL`, `cargo test --test celld` only checks the worker fixture and skips the live HTTP round-trip. +CI (`integration-celld.yaml`) brings the stack up and runs: + +```sh +make -C tests/e2e-ui up-celld-nats +make -C tests/e2e-ui test-celld # --test celld + e2e_ui_celld_nats_profile +``` + Durability: `POST /todo/:id/todo.create` (wait-path `{ commandId, input }`) writes `cell_events`, `cell_snapshots`, `cell_sealed`, and `cell_outbox` in the same Durable Object fetch (one SQLite transaction). Chat posts diff --git a/tests/e2e-celld/Makefile b/tests/e2e-celld/Makefile index ce0335c1e..e54813946 100644 --- a/tests/e2e-celld/Makefile +++ b/tests/e2e-celld/Makefile @@ -7,7 +7,7 @@ # the UI. WATCH=0 / WATCH_WORKER=0 disable those loops. CELLD_WATCH in # compose is the node's SQLite dir, not a source watcher. -.PHONY: run stop help wasm ensure-watch +.PHONY: run stop test help wasm ensure-watch BIND ?= 0.0.0.0:8791 API_PORT ?= 8791 @@ -145,6 +145,9 @@ run: wasm $(if $(filter 1,$(WATCH) $(WATCH_WORKER)),ensure-watch) echo ""; \ wait $$(cat .make-ui.pid) 2>/dev/null || wait +test: + cargo test --workspace -- --nocapture + stop: @stop_pidfile() { \ [ -f "$$1" ] || return 0; \ @@ -165,6 +168,7 @@ stop: help: @echo "e2e-celld (new example — not tests/e2e-ui)" + @echo " make test cargo test --workspace (CI)" @echo " make run GraphQL + UI (cargo-watch API, worker reload, Vite HMR)" @echo " WATCH=0 one-shot cargo run (no GraphQL reload)" @echo " WATCH_WORKER=0 skip worker-build + celld deploy watch" diff --git a/tests/e2e-celld/README.md b/tests/e2e-celld/README.md index db358d802..35312ba2d 100644 --- a/tests/e2e-celld/README.md +++ b/tests/e2e-celld/README.md @@ -23,6 +23,12 @@ alarms POST `/internal/outbox/drain`). Eventual projectors here fill SQL so on the engine); Zitadel Actions and outbox drain are internal HTTP on the same process. GraphQL and projectors are not cell class methods. +Workspace tests (no live celld): + +```sh +make test # cargo test --workspace (CI) +``` + ```sh cd tests/e2e-ui make up # Zitadel + Postgres (read models + login) diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 16d6229a0..5ac76464b 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -4,13 +4,13 @@ # make run # API + UI (cargo-watch GraphQL, Vite HMR; WATCH=0 to disable) # make test # offline unit/suite/UI structural # make test-browser # Playwright UI e2e (needs make up + make run) -# make up-celld-nats / test-celld-nats / down-celld-nats -# # optional celld+NATS profile (not make run) +# make up-celld-nats / test-celld / down-celld-nats +# # optional celld+NATS profile (not make run; CI live path) .PHONY: all up down run run-api stop test ci-offline test-domain test-suite \ test-browser test-browser-install js-install js-build wasm ui-install ui-build ui-check ui-test \ gen-client check-client contracts-check check clean help ensure-watch \ - up-celld-nats down-celld-nats down-celld test-celld-nats + up-celld-nats down-celld-nats down-celld test-celld test-celld-http test-celld-nats # Defaults only — do NOT `include e2e-ui.env` (shell-quoted dotenv breaks Make). # Recipes `source` the env file so values stay clean. @@ -202,6 +202,15 @@ down-celld: docker compose -f $(CELLD_COMPOSE) down @echo "celld + Azurite stopped. NATS: make down-celld-nats. playground: make down." +## Live celld tests (needs make up-celld-nats). Fixture-only checks still run +## in root `cargo test` without CELLD_URL; these set CELLD_URL so HTTP runs. +test-celld: test-celld-http test-celld-nats + +test-celld-http: + cd $(REPO_ROOT) && \ + CELLD_URL="$(CELLD_URL)" \ + cargo test --test celld -- --nocapture + test-celld-nats: @echo "optional profile smoke — default make test / make run unchanged" cd $(REPO_ROOT) && \ @@ -310,6 +319,7 @@ help: @echo " make down playground docker compose down (not celld/NATS)" @echo " make up-celld-nats optional celld+NATS profile (not make run)" @echo " make -C ../celld watch worker source reload after up-celld-nats" + @echo " make test-celld live --test celld + e2e_ui_celld_nats_profile (CI)" @echo " make test-celld-nats cargo test --test e2e_ui_celld_nats_profile" @echo " make down-celld-nats stop NATS profile only" @echo " make down-celld stop tests/celld Azurite + celld" diff --git a/tests/e2e-ui/celld-nats-profile/README.md b/tests/e2e-ui/celld-nats-profile/README.md index 084eb3021..529db2190 100644 --- a/tests/e2e-ui/celld-nats-profile/README.md +++ b/tests/e2e-ui/celld-nats-profile/README.md @@ -17,7 +17,8 @@ Optional profile: ```sh cd tests/e2e-ui make up-celld-nats # Azurite + celld + NATS (not make run) -make test-celld-nats # GraphQL wait-path smoke + SQL list +make test-celld # live --test celld + GraphQL wait-path smoke (CI) +make test-celld-nats # GraphQL wait-path smoke + SQL list only make down-celld-nats # NATS only make down-celld # Azurite + celld From 398059e03ad5b33d951749a11534b48bf7aa1b03 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sun, 23 Aug 2026 19:11:40 -0500 Subject: [PATCH 29/37] fix(ci): unblock js-client, quality, all-features, and chat e2e Pack-smoke now lists matchDistributedRoute. Snapshot tail loads clamp prefix to the durable stream so a planted-ahead cache misses and replays. CausalDispatchResult/OutboxMessage implement PartialEq so graphql lib tests compile. Chat Send no longer stays disabled while Eventual projected is still catching up. Implements [[tasks/portable-command-hosts-11]] --- js/scripts/pack-smoke.mjs | 3 +++ src/in_memory_repo/repository.rs | 8 +++++++- src/microsvc/service/causal.rs | 2 +- src/outbox/message.rs | 2 +- src/repository/traits.rs | 5 +++-- src/snapshot/repository.rs | 5 +++-- src/sqlx_repo/repo/commit.rs | 2 +- src/sqlx_repo/repo/streams.rs | 21 ++++++++++++++------ tests/e2e-ui/ui/src/routes/chat/+page.svelte | 5 +++-- 9 files changed, 37 insertions(+), 16 deletions(-) diff --git a/js/scripts/pack-smoke.mjs b/js/scripts/pack-smoke.mjs index 71b9c8454..65ea158fd 100644 --- a/js/scripts/pack-smoke.mjs +++ b/js/scripts/pack-smoke.mjs @@ -463,6 +463,7 @@ import { createDistributedSvelteKitServer, createPageDataSessionSource, defineDistributedSvelteKitOperation, + matchDistributedRoute, provideDistributedSvelteKitClient, useDistributedSvelteKitClient, useDistributedSvelteKitCommands @@ -513,6 +514,7 @@ createDistributedSvelteKitServer({ getSession: async () => null, getRole: () => 'user' }); +void matchDistributedRoute('/todos', '/todos'); const compiler = { clients: [{ @@ -557,6 +559,7 @@ assert.deepEqual(Object.keys(sveltekitSurface).sort(), [ 'createDistributedSvelteKitServer', 'createPageDataSessionSource', 'defineDistributedSvelteKitOperation', + 'matchDistributedRoute', 'provideDistributedSvelteKitClient', 'registerDistributedRoute', 'sessionSourceFromPageData', diff --git a/src/in_memory_repo/repository.rs b/src/in_memory_repo/repository.rs index 543fc7854..131afc4cd 100644 --- a/src/in_memory_repo/repository.rs +++ b/src/in_memory_repo/repository.rs @@ -538,14 +538,20 @@ impl GetStream for InMemoryRepository { let Some(events) = storage.get(&identity.storage_key()) else { return Ok(None); }; + let true_version = events.iter().map(|event| event.sequence).max().unwrap_or(0); let tail: Vec = events .iter() .filter(|event| event.sequence > after_version) .cloned() .collect(); + // Prefix must not exceed the durable stream. A snapshot cache + // planted past stream version would otherwise report + // `version == after_version` on an empty tail and hydrate the + // forged payload (`snapshot_repository_ignores_cache_past_stream_version`). + let prefix = after_version.min(true_version); let mut entity = Entity::new(); entity.set_id(identity.aggregate_id()); - entity.load_tail_from_history(tail, after_version); + entity.load_tail_from_history(tail, prefix); Ok(Some(entity)) } } diff --git a/src/microsvc/service/causal.rs b/src/microsvc/service/causal.rs index 7e8e78e1a..b2f2ab1db 100644 --- a/src/microsvc/service/causal.rs +++ b/src/microsvc/service/causal.rs @@ -213,7 +213,7 @@ impl CausalCommandReceiptSource { /// Successful typed causal dispatch plus its exact durable receipt source. #[cfg(feature = "graphql")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct CausalDispatchResult { pub(crate) payload: Value, pub(crate) receipt: CausalCommandReceiptSource, diff --git a/src/outbox/message.rs b/src/outbox/message.rs index 3b145f9d6..c14e1bf6f 100644 --- a/src/outbox/message.rs +++ b/src/outbox/message.rs @@ -102,7 +102,7 @@ impl std::str::FromStr for OutboxMessageStatus { /// The message is an immutable publishable envelope plus mutable delivery state. /// It is not an aggregate stream; repositories store it in their outbox storage /// and workers update delivery state directly. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[non_exhaustive] pub struct OutboxMessage { pub id: String, diff --git a/src/repository/traits.rs b/src/repository/traits.rs index ced2ab249..7a6513262 100644 --- a/src/repository/traits.rs +++ b/src/repository/traits.rs @@ -118,8 +118,9 @@ pub trait GetStream: Send + Sync { /// not optimized. /// /// The returned entity's `version`/`committed_version` reflect the true - /// persisted stream position (`after_version + tail.len()`), not the tail - /// length, so optimistic concurrency and `new_events()` stay correct. + /// persisted stream position (`max(sequence)`), not the tail length. + /// When `after_version` is past the stream (stale or planted snapshot + /// cache), prefix is clamped so hydrate treats that snapshot as a miss. /// /// [`get_stream`]: GetStream::get_stream fn get_stream_tail<'a>( diff --git a/src/snapshot/repository.rs b/src/snapshot/repository.rs index 91aaacab0..f26f82547 100644 --- a/src/snapshot/repository.rs +++ b/src/snapshot/repository.rs @@ -313,8 +313,9 @@ where /// still paid the full I/O and decode cost. Here the snapshot bounds the read. /// /// Degrades gracefully on a cache miss: if there is no snapshot, or it is -/// unusable (identity/codec/schema-version mismatch or decode failure), the -/// aggregate is rebuilt from a full stream load — correct, just not optimized. +/// unusable (identity/codec/schema-version mismatch, version past the +/// stream, or decode failure), the aggregate is rebuilt from a full stream +/// load — correct, just not optimized. fn load_from_store<'a, R, A>( repo: &'a R, identity: &'a StreamIdentity, diff --git a/src/sqlx_repo/repo/commit.rs b/src/sqlx_repo/repo/commit.rs index d0e13f1ee..d7841aec7 100644 --- a/src/sqlx_repo/repo/commit.rs +++ b/src/sqlx_repo/repo/commit.rs @@ -1053,7 +1053,7 @@ where /// Current committed version (`MAX(sequence)`, 0 for a missing stream) through /// any executor (pool or transaction). -async fn stream_version<'e, DB, E>( +pub(super) async fn stream_version<'e, DB, E>( executor: E, identity: &StreamIdentity, ) -> Result diff --git a/src/sqlx_repo/repo/streams.rs b/src/sqlx_repo/repo/streams.rs index 3e437e99f..1215ad49c 100644 --- a/src/sqlx_repo/repo/streams.rs +++ b/src/sqlx_repo/repo/streams.rs @@ -137,19 +137,28 @@ where .await .map_err(|err| repository_storage_error::("load stream tail", err))?; - // An empty tail is ambiguous from this query alone (no rows could - // mean "snapshot is current" or "stream does not exist"). The - // snapshot hydrate path only calls this after confirming a snapshot - // exists for the identity, so an empty tail means the snapshot is - // current. Return an entity at exactly `after_version`. let mut events = Vec::with_capacity(rows.len()); for row in rows { events.push(event_from_row::(row)?); } + // Empty tail is "snapshot current", "snapshot ahead of the stream", + // or "no events". Ask MAX(sequence) so a planted future snapshot + // cannot report `version == after_version` and hydrate forged state. + let prefix = if events.is_empty() { + let stream_version = + super::commit::stream_version::(&self.pool, identity).await?; + if stream_version == 0 { + return Ok(None); + } + after_version.min(stream_version) + } else { + after_version + }; + let mut entity = Entity::new(); entity.set_id(identity.aggregate_id()); - entity.load_tail_from_history(events, after_version); + entity.load_tail_from_history(events, prefix); Ok(Some(entity)) } } diff --git a/tests/e2e-ui/ui/src/routes/chat/+page.svelte b/tests/e2e-ui/ui/src/routes/chat/+page.svelte index 8d799c761..ce4909349 100644 --- a/tests/e2e-ui/ui/src/routes/chat/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/chat/+page.svelte @@ -327,8 +327,9 @@ room_id: LOBBY_ROOM, created_at: String(now) }); - // Wait for causal projection when the runtime provides it; otherwise - // the command receipt itself is the server confirmation. + // Mutation returned: allow the next compose. Eventual `projected` + // is delivery confirmation (SQL/@live), not a send lock. + busy = false; if (receipt.projected !== undefined) { await receipt.projected; } From 260c3e36aaf1922754fba25093fd61ded1ac5df2 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sun, 23 Aug 2026 19:57:44 -0500 Subject: [PATCH 30/37] fix: snapshot tail loads, Eventual projected, and chat Send Keep snapshot-only SQLite loads when event rows were deleted; clamp prefix only when a stream version exists so planted-ahead cache still misses. Tail-only hydrate keeps post-snapshot events in memory. Eventual `projected` settles when a committed result frame names the command (or has no command payload), even if membership fences keep the list overlay. Chat Send is disabled only while busy so it re-enables after projected with an empty composer. Implements [[tasks/portable-command-hosts-11]] --- js/src/replica/command-runtime/create.ts | 11 ++++++++++- src/sqlx_repo/repo/streams.rs | 10 ++++++---- tests/e2e-ui/ui/src/routes/chat/+page.svelte | 7 +++---- tests/snapshots/main.rs | 13 +++++++++++-- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/js/src/replica/command-runtime/create.ts b/js/src/replica/command-runtime/create.ts index 517276bd8..455d3e475 100644 --- a/js/src/replica/command-runtime/create.ts +++ b/js/src/replica/command-runtime/create.ts @@ -1681,10 +1681,19 @@ export function createReplicaCommandRuntime< * DistributedReplica is the authority on whether this frame's * snapshot/observations were admissible. This callback runs only * after that exact frame committed. + * + * Eventual list membership fences may keep the optimistic overlay + * until @live includes the new row. `projected` is delivery, not + * overlay retirement: settle when this frame names the command or + * when the overlay has already been retired. */ const remainsPending = replica.markOptimisticLayerAccepted(commandId); - if (!remainsPending) { + if ( + !remainsPending || + command === undefined || + command.commandId === commandId + ) { settleProjectionSuccess(controller); pending.delete(commandId); } diff --git a/src/sqlx_repo/repo/streams.rs b/src/sqlx_repo/repo/streams.rs index 1215ad49c..7dfd9c70f 100644 --- a/src/sqlx_repo/repo/streams.rs +++ b/src/sqlx_repo/repo/streams.rs @@ -143,15 +143,17 @@ where } // Empty tail is "snapshot current", "snapshot ahead of the stream", - // or "no events". Ask MAX(sequence) so a planted future snapshot - // cannot report `version == after_version` and hydrate forged state. + // or "no event rows" (sqlite hardening deletes pre-snapshot rows). + // MAX(sequence) distinguishes a planted future snapshot (clamp) from + // a snapshot-only load (no rows → keep after_version). let prefix = if events.is_empty() { let stream_version = super::commit::stream_version::(&self.pool, identity).await?; if stream_version == 0 { - return Ok(None); + after_version + } else { + after_version.min(stream_version) } - after_version.min(stream_version) } else { after_version }; diff --git a/tests/e2e-ui/ui/src/routes/chat/+page.svelte b/tests/e2e-ui/ui/src/routes/chat/+page.svelte index ce4909349..ed8911fbb 100644 --- a/tests/e2e-ui/ui/src/routes/chat/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/chat/+page.svelte @@ -327,9 +327,8 @@ room_id: LOBBY_ROOM, created_at: String(now) }); - // Mutation returned: allow the next compose. Eventual `projected` - // is delivery confirmation (SQL/@live), not a send lock. - busy = false; + // Wait for causal projection when the runtime provides it; otherwise + // the command receipt itself is the server confirmation. if (receipt.projected !== undefined) { await receipt.projected; } @@ -469,7 +468,7 @@ autocomplete="off" bind:value={draft} /> - {t.title} + {#if pendingCreateIds.has(t.todo_id)} + Saving… + {/if}
@@ -359,6 +378,29 @@ word-break: break-word; } + .item-pending .item-title { + color: var(--wf-ink-muted, #8a8a82); + } + + .item-pending:hover { + background: transparent; + } + + .pending-state { + align-self: center; + white-space: nowrap; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.045em; + text-transform: uppercase; + color: var(--wf-ink-muted, #8a8a82); + } + + .check:disabled { + cursor: wait; + opacity: 0.48; + } + .item-done .item-title { text-decoration: line-through; text-decoration-thickness: 1px; From 4afa1d8f05809a5fb65b5c095a9c076021541409 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 24 Aug 2026 01:33:33 -0500 Subject: [PATCH 36/37] fix(replica): preserve soft-navigation authority Install the anonymous public Chat client during client-side route entry even when SvelteKit omits data-request hydration. Atomically seal locally provable collection membership from authoritative direct command rows so Blob start is visible without refresh, while leaving unprovable membership stale. --- .../distributed-replica/impl-optimistic.ts | 12 ++- js/src/replica/distributed-replica/impl.ts | 86 +++++++++++++++-- js/tests/replica-protocol.test.mjs | 95 +++++++++++++++++++ tests/e2e-ui/e2e/unauth.anon.spec.ts | 27 ++++++ tests/e2e-ui/ui/src/routes/+layout.svelte | 5 +- .../e2e-ui/ui/src/routes/chat/+layout.svelte | 21 ++-- 6 files changed, 228 insertions(+), 18 deletions(-) diff --git a/js/src/replica/distributed-replica/impl-optimistic.ts b/js/src/replica/distributed-replica/impl-optimistic.ts index 8b37acc16..9ce9469eb 100644 --- a/js/src/replica/distributed-replica/impl-optimistic.ts +++ b/js/src/replica/distributed-replica/impl-optimistic.ts @@ -1,4 +1,5 @@ import type { + BaseCacheWriter, CacheEngine, OptimisticLayerReplacement } from '../../internal/cache-engine.js'; @@ -220,9 +221,18 @@ export function confirmOptimisticLayerOn( id: string, update: (writer: ReplicaBaseWriter) => T ): T { - const result = host.engine.confirmOptimisticLayer(id, (writer) => + return confirmOptimisticLayerWithCacheWriterOn(host, id, (writer) => update(baseWriter(writer)) ); +} + +/** Internal confirmation seam for protocol code that must atomically seal indexes. */ +export function confirmOptimisticLayerWithCacheWriterOn( + host: OptimisticHost, + id: string, + update: (writer: BaseCacheWriter) => T +): T { + const result = host.engine.confirmOptimisticLayer(id, update); host.retireDiagnosticLayer(id, 'retired', 'atomic'); host.optimisticReceipts.delete(id); host.syncDiagnostics(); diff --git a/js/src/replica/distributed-replica/impl.ts b/js/src/replica/distributed-replica/impl.ts index 9e8bf6328..f6371fee1 100644 --- a/js/src/replica/distributed-replica/impl.ts +++ b/js/src/replica/distributed-replica/impl.ts @@ -171,6 +171,7 @@ import { import { applyReceiptOnly as applyReceiptOnlyOn, confirmOptimisticLayerOn, + confirmOptimisticLayerWithCacheWriterOn, createOptimisticLayerOn, markOptimisticLayerAcceptedOn, planOptimisticReceipts as planOptimisticReceiptsOn, @@ -686,14 +687,61 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { pendingAnonymousRecordClocks, consumedAnonymousRecordClocks ); + /* + * The Atomic output is a complete authoritative row. Seal every active + * collection membership that the compiler plan can prove from that row in + * the same base transaction; otherwise the record exists by key while a + * warm @load index still omits it until refresh. + */ + const indexMutations = apply + ? this.#directProjectionIndexMutations( + commandId, + model, + recordKey, + fields + ) + : Object.freeze([]); + const indexRevision = + indexMutations.length === 0 + ? undefined + : this.#allocateIndexRevision(); - this.confirmOptimisticLayer(commandId, (writer) => { - if (!apply) return false; - return writer.writeRecord(model, identity, evidence.revision, { - incarnation: evidence.incarnation, - fields - }); - }); + confirmOptimisticLayerWithCacheWriterOn( + this.#optimisticHost(), + commandId, + (writer) => { + if (!apply) return false; + const wrote = writer.writeRecord({ + key: recordKey, + revision: evidence.revision, + incarnation: evidence.incarnation, + fields + }); + if (indexRevision !== undefined) { + for (const mutation of indexMutations) { + switch (mutation.kind) { + case 'write': + writer.writeIndex({ + ...mutation.write, + revision: indexRevision + }); + break; + case 'stale': + writer.markIndexStale( + mutation.key, + mutation.reason, + indexRevision + ); + break; + case 'delete': + writer.deleteIndex(mutation.key, indexRevision); + break; + } + } + } + return wrote; + } + ); for (const [key, clock] of pendingRecordClocks) { this.#recordClocks.set(key, clock); @@ -1990,6 +2038,30 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { return Object.freeze(mutations); } + #directProjectionIndexMutations( + commandId: string, + model: ReplicaModelArtifact, + recordKey: string, + fields: Readonly> + ): readonly DerivedIndexMutation[] { + const change: ReplicaIndexSemanticChange = Object.freeze({ + kind: 'upsert', + model: model.id, + key: recordKey, + fields + }); + const layer: OptimisticLayerView = Object.freeze({ + id: commandId, + sequence: Number.MAX_SAFE_INTEGER, + state: 'accepted', + context: Object.freeze({ + id: commandId, + changes: Object.freeze([change]) + }) + }); + return this.#deriveMaintainedIndexes(this.#engine.extract(), [layer]); + } + #operationProtocol( key: string, operation: string, diff --git a/js/tests/replica-protocol.test.mjs b/js/tests/replica-protocol.test.mjs index 52cfbe1ef..11d3a6744 100644 --- a/js/tests/replica-protocol.test.mjs +++ b/js/tests/replica-protocol.test.mjs @@ -195,6 +195,24 @@ const TodosServerOnly = Object.freeze({ ]) }); +const TodosLocallyMaintainable = Object.freeze({ + ...TodosServerOnly, + id: 'query:todos-local', + protocol: Object.freeze({ + ...TodosServerOnly.protocol, + operation: 'query:todos-local' + }), + roots: Object.freeze([ + Object.freeze({ + ...TodosServerOnly.roots[0], + filter: Object.freeze({ + ...TodosServerOnly.roots[0].filter, + rowPolicy: Object.freeze({ kind: 'unrestricted' }) + }) + }) + ]) +}); + const GamesWithOwner = Object.freeze({ id: 'query:games-with-owner', document: 'query GamesWithOwner { games { id owner_id owner { id name } } }', @@ -794,6 +812,83 @@ test('Atomic direct projection does not hold later complete @load membership', ( ]); }); +test('Atomic direct projection commits locally provable collection membership', () => { + const replica = createDistributedReplica(); + write( + replica, + { + operation: TodosLocallyMaintainable.id, + position: '1', + rows: [{ id: 'todo-1', title: 'first' }] + }, + 'network', + TodosLocallyMaintainable + ); + // Render once so the operation's compiler plan owns collection maintenance. + replica.read(TodosLocallyMaintainable, {}); + replica.createOptimisticLayer('cmd-atomic-create', (writer) => { + // Generated partial previews fail closed when the record is not known yet. + writer.writeRecord(Todo, 'todo-2', { + fields: { id: 'todo-2', title: 'preview' }, + ifPresent: true + }); + }); + replica[replicaCommandDirectProjection]('cmd-atomic-create', { + model: Todo, + identity: 'todo-2', + evidence: { + model: Todo.id, + scopeToken: 'record:todo-2', + incarnation: '1', + revision: '2', + tombstone: false + }, + fields: { id: 'todo-2', title: 'canonical', __typename: Todo.id } + }); + + assert.deepEqual(replica.read(TodosLocallyMaintainable, {}).data.todos, [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'canonical' } + ]); +}); + +test('Atomic direct projection keeps unprovable collection membership stale', () => { + const replica = createDistributedReplica(); + write( + replica, + { + operation: TodosServerOnly.id, + position: '1', + rows: [{ id: 'todo-1', title: 'first' }] + }, + 'network', + TodosServerOnly + ); + replica.read(TodosServerOnly, {}); + replica.createOptimisticLayer('cmd-atomic-server-only', (writer) => { + writer.writeRecord(Todo, 'todo-2', { + fields: { id: 'todo-2', title: 'preview' }, + ifPresent: true + }); + }); + replica[replicaCommandDirectProjection]('cmd-atomic-server-only', { + model: Todo, + identity: 'todo-2', + evidence: { + model: Todo.id, + scopeToken: 'record:todo-2', + incarnation: '1', + revision: '2', + tombstone: false + }, + fields: { id: 'todo-2', title: 'canonical', __typename: Todo.id } + }); + + const snapshot = replica.read(TodosServerOnly, {}); + assert.equal(snapshot.stale, true); + assert.deepEqual(snapshot.data.todos, [{ id: 'todo-1', title: 'first' }]); +}); + test('shared non-comparable membership follows request-start order across operations', async () => { const fetches = []; const replica = createDistributedReplica({ diff --git a/tests/e2e-ui/e2e/unauth.anon.spec.ts b/tests/e2e-ui/e2e/unauth.anon.spec.ts index 92c93cdb7..4057c8b90 100644 --- a/tests/e2e-ui/e2e/unauth.anon.spec.ts +++ b/tests/e2e-ui/e2e/unauth.anon.spec.ts @@ -25,6 +25,33 @@ test.describe('unauthenticated access', () => { }); }); + test('home soft-navigation installs the anonymous chat client', async ({ page }) => { + await page.goto('/'); + const continuityToken = `anonymous-chat-${Date.now()}`; + await page.evaluate((token) => { + Object.assign(globalThis, { __anonymousChatContinuityToken: token }); + }, continuityToken); + + await page + .getByLabel('main navigation') + .getByRole('link', { name: 'Chat', exact: true }) + .click(); + + await expect(page).toHaveURL(/\/chat(?:[/?#]|$)/); + await expect(page.getByRole('heading', { name: 'Lobby' })).toBeVisible({ + timeout: 20_000 + }); + expect( + await page.evaluate( + () => + (globalThis as typeof globalThis & { + __anonymousChatContinuityToken?: string; + }).__anonymousChatContinuityToken + ), + 'Chat navigation must preserve the current document' + ).toBe(continuityToken); + }); + test('home page is reachable without a session', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ diff --git a/tests/e2e-ui/ui/src/routes/+layout.svelte b/tests/e2e-ui/ui/src/routes/+layout.svelte index 404ce5cc6..ef7a70862 100644 --- a/tests/e2e-ui/ui/src/routes/+layout.svelte +++ b/tests/e2e-ui/ui/src/routes/+layout.svelte @@ -74,9 +74,12 @@ if (link.target && link.target !== '_self') return; if (link.origin !== window.location.origin) return; const signedIn = !!data.session?.user; + // Anonymous routes install their own public-surface client below this + // layout. Prefetching their user-surface artifact here cannot warm that + // client and may establish the wrong schema binding before navigation. + if (!signedIn) return; for (const { plan, artifact } of DISTRIBUTED_ROUTE_OPERATIONS) { if (!matchDistributedRoute(plan.route, link.pathname)) continue; - if (!signedIn && plan.operation !== 'ChatMessages') continue; const variables = plan.operation === 'ChatMessages' ? { limit: CHAT_PAGE_SIZE, offset: 0 } diff --git a/tests/e2e-ui/ui/src/routes/chat/+layout.svelte b/tests/e2e-ui/ui/src/routes/chat/+layout.svelte index af4b534f0..04006b3c0 100644 --- a/tests/e2e-ui/ui/src/routes/chat/+layout.svelte +++ b/tests/e2e-ui/ui/src/routes/chat/+layout.svelte @@ -19,12 +19,11 @@ const signedIn = $derived(!!data.session?.user); const initialData = untrack(() => data); - const guestBootstrap = untrack( - () => - !initialData.session?.user && - initialData.distributed !== undefined && - initialData.distributedAuthority !== undefined - ); + const guestAtMount = untrack(() => !initialData.session?.user); + const guestBootstrap = + guestAtMount && + initialData.distributed !== undefined && + initialData.distributedAuthority !== undefined; const pageData = createPageDataSessionSource(initialData); let appliedHydration: SveltekitReplicaHydration | undefined = guestBootstrap @@ -32,12 +31,16 @@ : undefined; let hydrationTimer: ReturnType | undefined; - const client = guestBootstrap + const client = guestAtMount ? provideDistributed({ session: pageData.session, browser, - hydration: initialData.distributed!, - authority: initialData.distributedAuthority! + ...(guestBootstrap + ? { + hydration: initialData.distributed!, + authority: initialData.distributedAuthority! + } + : {}) }) : null; From 880bea18db17e1e1b136bb62497f8fd383d55b76 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 24 Aug 2026 02:15:54 -0500 Subject: [PATCH 37/37] fix: settle exact command projections without refetch --- js/scripts/pack-smoke.mjs | 1 + js/src/replica/command-runtime/create.ts | 12 ++++++ js/tests/replica-command-runtime.test.mjs | 48 +++++++++++++++++++++++ tests/e2e-ui/celld-nats-profile/README.md | 2 +- tests/e2e-ui/e2e/unauth.anon.spec.ts | 1 + tests/e2e_ui_celld_nats_profile/main.rs | 43 +++++++++++++++++--- 6 files changed, 100 insertions(+), 7 deletions(-) diff --git a/js/scripts/pack-smoke.mjs b/js/scripts/pack-smoke.mjs index 65ea158fd..0ff98f3eb 100644 --- a/js/scripts/pack-smoke.mjs +++ b/js/scripts/pack-smoke.mjs @@ -430,6 +430,7 @@ assert.deepEqual(Object.keys(replicaSurface).sort(), [ 'createReplicaGraphqlTransport', 'createReplicaIndexMaintenanceRegistry', 'createReplicaIndexedDbPersistence', + 'createReplicaUuidV7', 'createWasmJsonPure', 'decideReplicaPaginationMaintenance', 'evaluateReplicaFilter', diff --git a/js/src/replica/command-runtime/create.ts b/js/src/replica/command-runtime/create.ts index b353a843e..307e5a6c7 100644 --- a/js/src/replica/command-runtime/create.ts +++ b/js/src/replica/command-runtime/create.ts @@ -1020,6 +1020,18 @@ export function createReplicaCommandRuntime< if (tracker.pending !== undefined) { settleTrackedProjection(tracker, pending); } + } else if ( + metadata.state === 'atomic' && + !prepared.revalidation.required && + !statusRequiresRevalidation + ) { + /* + * An exact terminal delta proves delivery but carries no + * canonical revision. Keep its accepted overlay until a later + * comparable authoritative result seals it, without racing + * sibling commands with a command-triggered query. + */ + settleTrackedProjection(tracker, pending); } else if ( metadata.state === 'atomic' || (metadata.state === 'succeeded' && diff --git a/js/tests/replica-command-runtime.test.mjs b/js/tests/replica-command-runtime.test.mjs index eebbef219..2e2645da3 100644 --- a/js/tests/replica-command-runtime.test.mjs +++ b/js/tests/replica-command-runtime.test.mjs @@ -1430,6 +1430,54 @@ for (const statusState of ['atomic', 'succeeded_pending_projection']) { }); } +test('terminal exact projection status settles without command-triggered revalidation', async () => { + const replica = new TestReplica(); + let pendingMetadata; + const runtime = createReplicaCommandRuntime( + replica, + { + dispatch(request) { + pendingMetadata = commandMetadata(request, { + actualTitle: 'accepted' + }); + return Promise.resolve( + envelope(request, { command: pendingMetadata }) + ); + }, + status(request) { + const terminalMetadata = Object.freeze({ + ...pendingMetadata, + state: 'atomic', + observations: Object.freeze( + pendingMetadata.expects.map((expectation) => + Object.freeze({ + ...expectation, + causationId: pendingMetadata.causationId + }) + ) + ) + }); + return Promise.resolve( + statusEnvelope(request, terminalMetadata) + ); + } + }, + { change: artifact() }, + { status: STATUS } + ); + const receipt = await runtime.commands.change( + { id: 'todo-1', title: 'preview' }, + { commandId: COMMAND_A } + ); + + assert.equal((await receipt.status()).state, 'atomic'); + assert.equal((await receipt.projected).state, 'atomic'); + assert.deepEqual(replica.revalidations, []); + assert.equal(replica.layer(COMMAND_A), 'accepted'); + assert.equal(replica.record('todo-1').fields.title, 'accepted'); + runtime.dispose(); +}); + test('invalid live progression cannot poison a later valid status transition', async () => { const replica = new TestReplica(); let request; diff --git a/tests/e2e-ui/celld-nats-profile/README.md b/tests/e2e-ui/celld-nats-profile/README.md index 529db2190..10e842d91 100644 --- a/tests/e2e-ui/celld-nats-profile/README.md +++ b/tests/e2e-ui/celld-nats-profile/README.md @@ -34,7 +34,7 @@ The playground UI against celld is the sibling example `tests/e2e-celld/` | Path | Where | |---|---| -| GraphQL wait-path mutations | `HttpCommandHost` → celld `POST /todo/{id}/todo.create` (`{ commandId, input }`) | +| GraphQL wait-path mutations | `CelldCommandHost` → celld `POST /todo/{id}/todo.create` (`{ commandId, input }` + internal identity headers) | | Fire-and-forget / events | NATS JetStream `publish` / `subscribe` | | Todo / Chat lists | SQL read models (projectors subscribe on NATS, **not** in cells) | | BlobGames by-id | `ReadStore::CellByKey` GET of the sealed row | diff --git a/tests/e2e-ui/e2e/unauth.anon.spec.ts b/tests/e2e-ui/e2e/unauth.anon.spec.ts index 4057c8b90..8031bb77f 100644 --- a/tests/e2e-ui/e2e/unauth.anon.spec.ts +++ b/tests/e2e-ui/e2e/unauth.anon.spec.ts @@ -27,6 +27,7 @@ test.describe('unauthenticated access', () => { test('home soft-navigation installs the anonymous chat client', async ({ page }) => { await page.goto('/'); + await page.waitForLoadState('networkidle'); const continuityToken = `anonymous-chat-${Date.now()}`; await page.evaluate((token) => { Object.assign(globalThis, { __anonymousChatContinuityToken: token }); diff --git a/tests/e2e_ui_celld_nats_profile/main.rs b/tests/e2e_ui_celld_nats_profile/main.rs index fd392d3a8..2fb2763d1 100644 --- a/tests/e2e_ui_celld_nats_profile/main.rs +++ b/tests/e2e_ui_celld_nats_profile/main.rs @@ -73,16 +73,40 @@ fn optional_profile_is_named_and_not_the_playground() { mod live { use super::*; use async_graphql::Request; - use distributed::command_dispatch::{HttpCommandHost, SharedCommandHost}; + use distributed::bus::InMemoryBus; + use distributed::cell_host::{CelldCommandHost, CelldRoute}; + use distributed::command_dispatch::SharedCommandHost; use distributed::graphql::{ read, typed_command, GraphqlEngine, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, ModelPermissions, Succeeded, VerifiedPrincipal, }; use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; use distributed::{ - Aggregate, AggregateBuilder, Entity, InMemoryRepository, ReadModel, Snapshot, + Aggregate, AggregateBuilder, BusPublisher, Entity, InMemoryRepository, ReadModel, Snapshot, }; use serde::{Deserialize, Serialize}; + use serde_json::{json, Value}; + + const OPTIONAL_TODO_COMMANDS: &[&str] = &["todo.create"]; + + fn optional_todo_shard(input: &Value) -> Option { + input.get("id").and_then(Value::as_str).map(str::to_owned) + } + + fn optional_todo_payload( + _command: &str, + input: &Value, + remote: &Value, + _session: &Session, + ) -> Value { + json!({ + "id": remote + .get("id") + .or_else(|| input.get("id")) + .cloned() + .unwrap_or(Value::Null) + }) + } #[derive(Default, Snapshot)] struct SchemaAgg { @@ -228,20 +252,27 @@ mod live { .as_nanos() ); let celld = celld.trim_end_matches('/'); - let host: SharedCommandHost = - Arc::new(HttpCommandHost::new(format!("{celld}/todo/{todo_id}"))); let pool = sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(); sqlx::query("CREATE TABLE IF NOT EXISTS todos (id TEXT PRIMARY KEY, title TEXT)") .execute(&pool) .await .ok(); - let schema = schema_service(); + let schema = Arc::new(schema_service()); + let publisher = BusPublisher::new(Arc::new(InMemoryBus::new())); + let host: SharedCommandHost = Arc::new( + CelldCommandHost::new(celld, Arc::clone(&schema), publisher).route(CelldRoute::new( + OPTIONAL_TODO_COMMANDS, + "todo", + optional_todo_shard, + optional_todo_payload, + )), + ); let engine = GraphqlEngine::builder(pool) .protocol_token_key([0x5a; 32]) .roles(&["user"]) .model::(ModelPermissions::new().grant("user", read().all_columns())) - .service(&schema) + .service(schema.as_ref()) .build() .expect("optional-profile GraphQL engine");