diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b7e5e67c..c433f72c 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 00000000..0faa6052 --- /dev/null +++ b/.github/workflows/integration-celld.yaml @@ -0,0 +1,265 @@ +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 && make -C tests/e2e-ui up-celld-nats +# WATCH=0 WATCH_WORKER=0 make -C tests/e2e-celld run +# E2E_UI_ORIGIN=http://localhost:5180 npx --prefix tests/e2e-ui playwright test \ +# todos.user.spec.ts chat.user.spec.ts --project chromium-user +# +# 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 + # Zitadel owns :18080 in the browser topology. + CELLD_HTTP_PORT: "18880" + CELLD_URL: http://127.0.0.1:18880 + NATS_PORT: "14222" + NATS_URL: nats://127.0.0.1:14222 + DISTRIBUTED_INTERNAL_SECRET: test-only-internal-secret-change-me-2026 + 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" + cache: npm + cache-dependency-path: | + js/package-lock.json + tests/e2e-ui/package-lock.json + tests/e2e-ui/ui/package-lock.json + + - name: Install host tools + run: sudo apt-get update && sudo apt-get install -y jq openssl curl + + - 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 Postgres + Zitadel and bootstrap OIDC + run: make -C tests/e2e-ui up + + - 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: Build e2e-celld API and UI + run: | + cargo build --manifest-path tests/e2e-celld/Cargo.toml \ + -p e2e-celld-runner --bin e2e-celld + make -C tests/e2e-ui ui-install + npm install --prefix tests/e2e-ui + + - name: Start e2e-celld API + UI + run: | + set -euo pipefail + set -a + # shellcheck disable=SC1091 + . tests/e2e-ui/e2e-ui.env + set +a + export BIND=127.0.0.1:8791 + export CELLD_URL=http://127.0.0.1:18880 + export NATS_URL=nats://127.0.0.1:14222 + export AUTH_URL=http://localhost:5180 + export AUTH_USE_SECURE_COOKIES=false + export AUTH_TRUST_HOST=true + + tests/e2e-celld/target/debug/e2e-celld \ + > tests/e2e-celld/.ci-runner.log 2>&1 & + echo $! > tests/e2e-celld/.ci-runner.pid + + ok=0 + for i in $(seq 1 120); do + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + "http://127.0.0.1:8791/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 "e2e-celld API failed to become ready" + tail -120 tests/e2e-celld/.ci-runner.log + exit 1 + fi + + cd tests/e2e-ui/ui + PUBLIC_E2E_PROFILE=celld-nats \ + E2E_API_ORIGIN=http://127.0.0.1:8791 \ + npm run dev -- --host localhost --port 5180 \ + > ../.ci-celld-ui.log 2>&1 & + echo $! > ../.ci-celld-ui.pid + cd ../../.. + + ok=0 + for i in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' \ + "http://localhost:5180/" 2>/dev/null || echo 000) + if [ "$code" = "200" ] || [ "$code" = "302" ] || [ "$code" = "303" ]; then + ok=1 + break + fi + sleep 0.5 + done + if [ "$ok" != "1" ]; then + echo "e2e-celld UI failed to become ready (last HTTP $code)" + tail -100 tests/e2e-ui/.ci-celld-ui.log + exit 1 + fi + + - name: Install Playwright + Chromium + working-directory: tests/e2e-ui + run: npx playwright install chromium --with-deps + + - name: Red-team private HTTP boundaries + run: | + set -euo pipefail + assert_status() { + expected="$1" + shift + actual=$(curl -sS -o /tmp/celld-red-team-response -w '%{http_code}' "$@") + if [ "$actual" != "$expected" ]; then + echo "expected HTTP $expected, got $actual" + sed -n '1,40p' /tmp/celld-red-team-response + exit 1 + fi + } + + assert_status 401 -X POST \ + http://127.0.0.1:8791/internal/outbox/drain \ + -H 'content-type: application/json' \ + -d '{"kind":"todo","id":"red-team"}' + assert_status 401 -X POST \ + http://127.0.0.1:8791/internal/outbox/drain \ + -H 'content-type: application/json' \ + -H 'x-distributed-internal-secret: forged-red-team-secret-000000' \ + -d '{"kind":"todo","id":"red-team"}' + assert_status 400 -X POST \ + http://127.0.0.1:8791/internal/outbox/drain \ + -H 'content-type: application/json' \ + -H "x-distributed-internal-secret: $DISTRIBUTED_INTERNAL_SECRET" \ + -d '{"kind":"todo","id":"red-team","outbox":[]}' + assert_status 401 -X POST \ + http://127.0.0.1:8791/zitadel.scrape.v1 \ + -H 'content-type: application/json' \ + -d '{}' + assert_status 401 \ + http://127.0.0.1:18880/todo/red-team + assert_status 401 -X POST \ + http://127.0.0.1:18880/todo/red-team/outbox.complete \ + -H 'content-type: application/json' \ + -d '{"ids":["forged"]}' + + - name: Todo + Chat browser lifecycle through celld + working-directory: tests/e2e-ui + run: >- + npx playwright test todos.user.spec.ts chat.user.spec.ts + --project chromium-user + env: + E2E_UI_ORIGIN: http://localhost:5180 + E2E_API_ORIGIN: http://127.0.0.1:8791 + CI: true + + - name: Upload celld Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: celld-playwright-report + path: | + tests/e2e-ui/playwright-report + tests/e2e-ui/test-results + if-no-files-found: ignore + retention-days: 7 + + - 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 + echo '=== e2e-celld API ===' + tail -180 tests/e2e-celld/.ci-runner.log || true + echo '=== e2e-celld UI ===' + tail -100 tests/e2e-ui/.ci-celld-ui.log || true + echo '=== Postgres + Zitadel ===' + docker compose -f tests/e2e-ui/docker/docker-compose.yml ps -a || true + docker compose -f tests/e2e-ui/docker/docker-compose.yml logs --tail=100 || true + + - name: Tear down + if: always() + run: | + [ -f tests/e2e-ui/.ci-celld-ui.pid ] && \ + kill "$(cat tests/e2e-ui/.ci-celld-ui.pid)" 2>/dev/null || true + [ -f tests/e2e-celld/.ci-runner.pid ] && \ + kill "$(cat tests/e2e-celld/.ci-runner.pid)" 2>/dev/null || true + lsof -ti:5180 2>/dev/null | xargs -r kill -9 2>/dev/null || true + lsof -ti:8791 2>/dev/null | xargs -r kill -9 2>/dev/null || true + make -C tests/e2e-ui down-celld-nats || true + make -C tests/e2e-ui down-celld || true + docker compose -f tests/e2e-ui/docker/docker-compose.yml down -v || true diff --git a/.github/workflows/on-pr-quality.yaml b/.github/workflows/on-pr-quality.yaml index 56061b40..6528c0fd 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 f7cc1ac6..a04f3a99 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/.gitignore b/.gitignore index 619edf85..a224987c 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 b46a2e6e..f2f808b5 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] @@ -58,7 +58,7 @@ base64 = "0.23.0" futures = { version = "0.3", optional = true } lapin = { version = "4", optional = true } rdkafka = { version = "0.39", features = ["cmake-build", "tokio"], optional = true } -reqwest = { version = "0.13", default-features = false, features = ["rustls"], optional = true } +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } jsonwebtoken = { version = "9", optional = true } bitcode = { version = "0.6.9", features = ["serde"] } event-emitter-rs = { version = "0.1.4", optional = true } @@ -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/README.md b/README.md index 89baf5a1..33459a7f 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,19 @@ It is also a toolkit of distributed-systems tools. You do not need the whole path. Event-source aggregates and stop there. Use the service bus alone. Take GraphQL reads without the replica. Adopt what you need. -Rust · TypeScript · CQRS / ES · SvelteKit +Distributed lets you define domain logic cleanly, then compose those pieces +like blocks into one service or many — whatever suits your size. Change +transports and sharding later as you grow. + +Rust · TypeScript · CQRS / ES · SvelteKit · celld · Kafka · NATS · RabbitMQ · +PSQL · SQLite · OIDC · Keycloak · Authentik The living playground is [`tests/e2e-ui`](tests/e2e-ui): real apps (chat, -todos, blob, admin) with a **How it is built** panel on every screen. This -README is the same story, in the repo. +todos, blob, admin) with a **How it is built** panel on every screen. Default +is one process. The same UI can wait-dispatch Todo create/complete and +`chat.post` to celld from [`tests/e2e-celld`](tests/e2e-celld). Chat is a +small cell so GraphQL `@live` still working is the demo. This README is the +same story, in the repo. - [The bar](#the-bar) — what “state of the art” means here - [Backstory](#backstory) — why the full path is one system, and the pieces still stand alone @@ -54,9 +62,14 @@ the generated client hosts it. **Same blocks, few or many processes.** Domain, modules, and projections are packages — not a deploy shape. A **service crate** lists the modules this -process runs. Today the playground is one host. Later you write another -`Service` from the same modules. Eventual projectors can move; Atomic seals -stay with commands. The same Rust pures can compile to WASM for the replica. +process runs. Todo commands are `portable_command!` declarations in +`todo-domain`. Today the playground is one host. [`tests/e2e-celld`](tests/e2e-celld) +mounts the same declarations and wait-dispatches create/complete and +`chat.post` to a cell (one private SQLite per todo or message). GraphQL +`@live` and Eventual projectors stay off the cell. Later you write another +`Service` from the same modules. Eventual +projectors can move; Atomic seals stay with commands. The same Rust pures +can compile to WASM for the replica. **Distributed** is that path when you want the whole product — one system so generation can keep the DX simple. The same crates stay usable as tools: @@ -297,25 +310,23 @@ mutation SaveTodo { } ``` -Handlers stay thin: load the aggregate, call a proven domain method, -commit events. +Handlers stay thin: most Todo commands are `portable_command!` — shard, +invoke one domain method, commit Eventual. `todo.create` keeps a `handle:` +escape hatch when the body needs extra checks. ```rust,ignore -pub async fn handle( - ctx: &CausalCommandContext<'_, Todo>, - input: TodoArchiveInput, -) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); - let mut todo = ctx.repo() - .get(&input.todo_id).await? - .ok_or_else(|| HandlerError::NotFound(input.todo_id.clone()))?; - todo.archive(&owner).map_err(rejected)?; - - let state = TodoState::from(&*todo); - ctx.repo().publish_events().commit(todo)?.eventual(TodoArchivePayload { - todo_id: state.todo_id, - status: state.status, - }) +portable_command! { + name: "todo.complete", + transition: domain_commands::Complete, + aggregate: Todo, + input: TodoCompleteInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + load: required, + roles: ["user", "admin"], + field: "todos_complete", + invoke: |todo, _input, principal| todo.complete(principal), + payload: |todo| TodoStatusPayload::from_todo(&**todo), } ``` @@ -328,6 +339,13 @@ A module mounts one bounded context — commands, guards, projectors. A playground is one `Service`, one host, one runner that only reads env and calls `run`. You do not set a runtime role flag. +Todo commands are `portable_command!` declarations in `todo-domain`. This +playground mounts them on a local Service. The sibling example +[`tests/e2e-celld`](tests/e2e-celld) mounts the same declarations and +wait-dispatches create, complete, and `chat.post` to a **cell** (one private +SQLite per todo or message). GraphQL `@live` and Eventual projectors stay +off the cell. + The same packages can back a different `Service` later: all modules in one binary, or commands here and Eventual projectors there. **Atomic** work (blob’s board seal) stays with the command process. **Eventual** work can @@ -452,23 +470,30 @@ OIDC, SvelteKit SSR, generated clients, live WS. Full runbook: **[`tests/e2e-ui/README.md`](tests/e2e-ui/README.md)**. ```bash +# Default: one process (SQLite or Postgres + bus) cd tests/e2e-ui make up # Postgres + Zitadel → e2e-ui.env source e2e-ui.env && make run # UI http://localhost:5180 # API http://127.0.0.1:8791 + +# Optional: same UI, Todo create/complete on celld +cd tests/e2e-ui && make up && make up-celld-nats +cd ../e2e-celld && make run ``` Demo logins after `make up`: `alice` / `bob` / `admin` · `Password1!`. +Full celld runbook: **[`tests/e2e-celld/README.md`](tests/e2e-celld/README.md)**. Small apps, full patterns. Each screen has **How it is built**: query, then command, then handler, then domain, then events, then service and -host. +host. Todos also run against celld from [`tests/e2e-celld`](tests/e2e-celld) +with the same domain crate. | Demo | Tag | What it shows | |---|---|---| | [`/chat`](tests/e2e-ui/ui/src/routes/chat) | Live + anonymous | Shared room with SSR, live updates, guest reads | -| [`/todos`](tests/e2e-ui/ui/src/routes/todos) | Eventual | Ownership rules, optimistic commands, projector fill | +| [`/todos`](tests/e2e-ui/ui/src/routes/todos) | Eventual · celld | Ownership rules, optimistic commands, projector fill. Same declarations on a Service or a cell | | [`/blob`](tests/e2e-ui/ui/src/routes/blob) | Atomic + WASM | Atomic board in the response. Same domain pure runs as WASM in the replica | | [`/admin`](tests/e2e-ui/ui/src/routes/admin) | Surface | Elevated surface — separate client, more power | | [`/session`](tests/e2e-ui/ui/src/routes/session) | OIDC | Who you are: tokens, groups, roles | @@ -1842,6 +1867,9 @@ make up && set -a && source e2e-ui.env && set +a && make run # /todos /chat /blob /admin /login make test # domain + behavioral + JS-backed UI build/typecheck/tests make check-client # generated user/admin clients are current + +# Same UI against celld (Todo + chat.post wait-dispatch; @live stays on GraphQL) +make up-celld-nats && cd ../e2e-celld && make run ``` ### TypeScript client (`js/` → `@hops-ops/distributed`) @@ -2204,11 +2232,12 @@ CI also publishes `lcov.info` as a workflow artifact and attempts an optional Co ## Examples **Start here (product demos):** [See it run](#see-it-run) — e2e-ui (`tests/e2e-ui`), -Blob game, live chat, GraphiQL. +e2e-celld (`tests/e2e-celld`), Blob game, live chat, GraphiQL. | Path | What it showcases | |---|---| | [`tests/e2e-ui/`](tests/e2e-ui/) | Full-stack CQRS + GraphQL + OIDC + SvelteKit (todos, chat, blob) | +| [`tests/e2e-celld/`](tests/e2e-celld/) | Same UI; Todo + `chat.post` wait-dispatch to celld; `@live` stays on GraphQL | | [`js/`](js/) | `@hops-ops/distributed` — transport, causal replica, SvelteKit/React | | [`examples/graphiql.rs`](examples/graphiql.rs) | Seeded GraphQL playground (`--features "graphql,sqlite"`) | | `tests/graphql_*` | Engine, HTTP/WS, harden, identity, multi-IdP OIDC | diff --git a/distributed_cli/src/client_compiler/manifest/projections.rs b/distributed_cli/src/client_compiler/manifest/projections.rs index f11f176b..8f57977c 100644 --- a/distributed_cli/src/client_compiler/manifest/projections.rs +++ b/distributed_cli/src/client_compiler/manifest/projections.rs @@ -319,13 +319,6 @@ pub(crate) fn validate_command_projections( .get(&value.slot) .expect("exact slot coverage was validated"); validate_preview_source(command, &value.source, expected)?; - if matches!( - value.source, - ManifestProjectionPreviewSource::Unknown - | ManifestProjectionPreviewSource::Absent - ) { - requiring_revalidation.insert(command.name.clone()); - } } } if selected_programs.is_empty() { diff --git a/distributed_cli/src/client_compiler/projection_delta/preview.rs b/distributed_cli/src/client_compiler/projection_delta/preview.rs index e199389e..4d6bb485 100644 --- a/distributed_cli/src/client_compiler/projection_delta/preview.rs +++ b/distributed_cli/src/client_compiler/projection_delta/preview.rs @@ -165,7 +165,10 @@ impl CompiledCommandProjection { } pub(crate) fn requires_revalidation(&self) -> bool { - !self.preview.recoveries.is_empty() + self.preview + .recoveries + .iter() + .any(|recovery| recovery.condition == PreviewRecoveryCondition::Always) } pub(crate) fn selected_models(&self) -> &BTreeSet { diff --git a/distributed_cli/src/client_compiler/tests.rs b/distributed_cli/src/client_compiler/tests.rs index 78ebc130..f0f90dae 100644 --- a/distributed_cli/src/client_compiler/tests.rs +++ b/distributed_cli/src/client_compiler/tests.rs @@ -3221,6 +3221,7 @@ fn command_protocol_and_extensions_are_preserved_exactly() { assert!(!partial_commands.contains("\"op\": \"upsert\"")); assert!(partial_commands.contains("\"condition\": \"if_record_missing\"")); assert!(partial_commands.contains("\"kind\": \"record\"")); + assert!(partial_commands.contains("\"required\": false")); let absent = compile_client(ClientCompileInput::new( absent_value, @@ -3290,6 +3291,7 @@ fn command_protocol_and_extensions_are_preserved_exactly() { assert!(fallback_commands.contains("\"kind\": \"model\"")); assert!(!fallback_commands.contains("\"op\": \"upsert\"")); assert!(!fallback_commands.contains("\"op\": \"patch\"")); + assert!(fallback_commands.contains("\"required\": true")); } let delete = compile_client(ClientCompileInput::new( diff --git a/distributed_macros/src/command.rs b/distributed_macros/src/command.rs index 51100206..f3871497 100644 --- a/distributed_macros/src/command.rs +++ b/distributed_macros/src/command.rs @@ -1,5 +1,6 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; +use std::collections::HashSet; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::{Expr, FnArg, Ident, ItemFn, LitStr, PathArguments, ReturnType, Token, Type}; @@ -10,7 +11,7 @@ struct CommandArgs { field_name: Option, input: Option, outcome: Option, - roles: Vec, + roles: Option>, emits: Vec, applies: Option, defaults: Option, @@ -20,33 +21,60 @@ struct CommandArgs { impl Parse for CommandArgs { fn parse(input: ParseStream<'_>) -> syn::Result { let mut args = Self::default(); + let mut seen_options = HashSet::new(); while !input.is_empty() { let key: Ident = input.parse()?; + let option = key.to_string(); + if !seen_options.insert(option.clone()) { + return Err(syn::Error::new( + key.span(), + format!("duplicate command option {option}"), + )); + } match key.to_string().as_str() { "roles" => { let content; syn::parenthesized!(content in input); let values = Punctuated::::parse_terminated(&content)?; + let mut roles = Vec::new(); + let mut seen_roles = HashSet::new(); for value in values { - match value { - syn::Expr::Path(path) if path.path.segments.len() == 1 => { - args.roles.push(LitStr::new( - &path.path.segments[0].ident.to_string(), - path.path.segments[0].ident.span(), - )); - } + let role = match value { + syn::Expr::Path(path) if path.path.segments.len() == 1 => LitStr::new( + &path.path.segments[0].ident.to_string(), + path.path.segments[0].ident.span(), + ), syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(value), .. - }) => args.roles.push(value), + }) => value, other => { return Err(syn::Error::new_spanned( other, "command roles must be identifiers or string literals", )) } + }; + if role.value().trim().is_empty() { + return Err(syn::Error::new( + role.span(), + "command roles must not be empty", + )); + } + if !seen_roles.insert(role.value()) { + return Err(syn::Error::new( + role.span(), + "command roles must not contain duplicates", + )); } + roles.push(role); + } + if roles.is_empty() { + return Err(content.error( + "command roles must declare at least one role; use an explicit anonymous role when intended", + )); } + args.roles = Some(roles); } "emits" => { let content; @@ -120,6 +148,29 @@ impl Parse for CommandArgs { } } +fn validate_command_id(id: &LitStr) -> syn::Result<()> { + let value = id.value(); + let segments = value.split('.').collect::>(); + if value.len() > 128 + || segments.len() < 2 + || segments.iter().any(|segment| { + segment.is_empty() + || !segment.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || byte == b'_' + || byte == b'-' + }) + }) + { + return Err(syn::Error::new( + id.span(), + "command id must be 2+ dot-separated lowercase ASCII identifier segments", + )); + } + Ok(()) +} + pub fn expand( attr: proc_macro2::TokenStream, item: proc_macro2::TokenStream, @@ -133,6 +184,13 @@ pub fn expand( "command declaration requires `id = \"...\"`", ) })?; + validate_command_id(&id)?; + let roles = args.roles.ok_or_else(|| { + syn::Error::new( + function.sig.ident.span(), + "command declaration requires explicit roles(...)", + ) + })?; if !function.sig.asyncness.is_some() { return Err(syn::Error::new_spanned( &function.sig.fn_token, @@ -148,7 +206,11 @@ pub fn expand( FnArg::Receiver(_) => None, }) .collect::>(); - if function.sig.inputs.iter().any(|argument| matches!(argument, FnArg::Receiver(_))) + if function + .sig + .inputs + .iter() + .any(|argument| matches!(argument, FnArg::Receiver(_))) || typed_args.len() != 2 { return Err(syn::Error::new_spanned( @@ -213,18 +275,13 @@ pub fn expand( let mount_name = format_ident!("{}_mount", function_name); let mount_static = format_ident!("{}_MOUNT", function_name.to_string().to_uppercase()); let definition_name = format_ident!("{}_definition", function_name); - let definition_static = format_ident!( - "{}_DEFINITION", - function_name.to_string().to_uppercase() - ); - let command_id_static = format_ident!( - "{}_COMMAND_ID", - function_name.to_string().to_uppercase() - ); + let definition_static = + format_ident!("{}_DEFINITION", function_name.to_string().to_uppercase()); + let command_id_static = + format_ident!("{}_COMMAND_ID", function_name.to_string().to_uppercase()); let accessor_name = format_ident!("{}_application_command", function_name); let register_name = format_ident!("{}_register", function_name); let visibility = &function.vis; - let roles = args.roles; let emits = args.emits; let applies = args.applies; let defaults = args.defaults; @@ -233,9 +290,7 @@ pub fn expand( #framework::graphql::typed_command::<#input, #outcome>(#id) .field_name(#field_name) }; - if !roles.is_empty() { - builder.extend(quote! { .roles([#(#roles),*]) }); - } + builder.extend(quote! { .roles([#(#roles),*]) }); if !emits.is_empty() { builder.extend(quote! { .emits(#framework::events!(#(#emits),*)) }); } @@ -397,7 +452,10 @@ fn validate_prepared_return(output: &ReturnType, outcome: &Type) -> syn::Result< )); }; let Some(result_segment) = result.path.segments.last() else { - return Err(syn::Error::new_spanned(output, "missing Result return type")); + return Err(syn::Error::new_spanned( + output, + "missing Result return type", + )); }; if result_segment.ident != "Result" { return Err(syn::Error::new_spanned( @@ -416,10 +474,16 @@ fn validate_prepared_return(output: &ReturnType, outcome: &Type) -> syn::Result< _ => None, }); let Some(prepared) = types.next() else { - return Err(syn::Error::new_spanned(output, "missing PreparedCommand return type")); + return Err(syn::Error::new_spanned( + output, + "missing PreparedCommand return type", + )); }; let Some(error) = types.next() else { - return Err(syn::Error::new_spanned(output, "missing HandlerError return type")); + return Err(syn::Error::new_spanned( + output, + "missing HandlerError return type", + )); }; let Type::Path(prepared) = prepared else { return Err(syn::Error::new_spanned( @@ -428,7 +492,10 @@ fn validate_prepared_return(output: &ReturnType, outcome: &Type) -> syn::Result< )); }; let Some(prepared_segment) = prepared.path.segments.last() else { - return Err(syn::Error::new_spanned(prepared, "missing PreparedCommand type")); + return Err(syn::Error::new_spanned( + prepared, + "missing PreparedCommand type", + )); }; if prepared_segment.ident != "PreparedCommand" { return Err(syn::Error::new_spanned( diff --git a/distributed_macros/src/entry_tests.rs b/distributed_macros/src/entry_tests.rs index 43c56aba..51fba8b0 100644 --- a/distributed_macros/src/entry_tests.rs +++ b/distributed_macros/src/entry_tests.rs @@ -494,4 +494,99 @@ mod tests { ); assert!(out.contains("replay_event"), "got: {out}"); } + + #[test] + fn expand_portable_command_emits_thin_mount() { + let input = quote! { + name: "todo.complete", + transition: domain_commands::Complete, + aggregate: Todo, + input: TodoCompleteInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + load: required, + roles: ["user", "admin"], + field: "todos_complete", + invoke: |todo, _input, principal| todo.complete(principal), + payload: |todo| TodoStatusPayload::from_todo(&**todo), + }; + let out = crate::portable_command::expand(input) + .expect("expand") + .to_string(); + assert!(out.contains("struct Complete"), "{out}"); + assert!(out.contains("fn complete"), "{out}"); + assert!(out.contains("todo . complete"), "{out}"); + assert!(out.contains("load_by"), "{out}"); + assert!(out.contains("eventual"), "{out}"); + assert!(out.contains("PortableCommand"), "{out}"); + } + + #[test] + fn expand_portable_command_preserves_contract_projection_options() { + let input = quote! { + name: "chat.post", + transition: domain_commands::Post, + aggregate: ChatMessage, + input: ChatPostInput, + outcome: Eventual, + shard: |input| input.message_id.clone(), + roles: ["user", "admin"], + field: "chat_messages_post", + constructor: post_message, + authenticated_user_field: ( + ChatMessagePostedDomainEvent, + ChatMessageState, + "author_id" + ), + preview_reduce_known_record: blob_preview(), + guard: authenticated_user, + handle: handle_post, + }; + let out = crate::portable_command::expand(input) + .expect("expand") + .to_string(); + assert!(out.contains("authenticated_user_field"), "{out}"); + assert!(out.contains("fn post_message"), "{out}"); + assert!(out.contains("ChatMessagePostedDomainEvent"), "{out}"); + assert!(out.contains("ChatMessageState"), "{out}"); + assert!(out.contains("author_id"), "{out}"); + assert!(out.contains("preview_reduce_known_record"), "{out}"); + assert!(out.contains("blob_preview"), "{out}"); + } + + #[test] + fn expand_portable_command_allows_constructor_override_for_keyword_name() { + let input = quote! { + name: "blob.move", + transition: domain_commands::MoveDir, + aggregate: BlobGame, + input: BlobMoveInput, + outcome: Atomic, + shard: |input| input.game_id.clone(), + roles: ["user", "admin"], + field: "blob_games_move", + constructor: move_dir, + guard: authenticated_user, + handle: handle_move, + }; + let out = crate::portable_command::expand(input) + .expect("expand") + .to_string(); + assert!(out.contains("struct Move"), "{out}"); + assert!(out.contains("fn move_dir"), "{out}"); + } + + #[test] + fn expand_portable_command_rejects_unknown_key() { + let input = quote! { + name: "todo.complete", + nope: 1, + }; + let err = crate::portable_command::expand(input).expect_err("unknown key"); + assert!( + err.to_string() + .contains("unknown portable_command key `nope`"), + "got: {err}" + ); + } } diff --git a/distributed_macros/src/lib.rs b/distributed_macros/src/lib.rs index a8fd31ec..b075a46f 100644 --- a/distributed_macros/src/lib.rs +++ b/distributed_macros/src/lib.rs @@ -2,6 +2,7 @@ mod aggregate; mod application; mod command; mod command_input_defaults; +mod portable_command; mod digest; mod domain_event; mod domain_state; @@ -27,6 +28,18 @@ pub fn command(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } +/// Domain-owned portable command mount (`PCH-DEC-001`). +/// +/// Spec sketches used `command!`; that name is the handler attribute +/// [`command`]. This is the function-like form for shard + invoke + Eventual +/// (or a `handle:` escape hatch). +#[proc_macro] +pub fn portable_command(input: TokenStream) -> TokenStream { + portable_command::expand(input.into()) + .unwrap_or_else(|error| error.to_compile_error()) + .into() +} + /// Register an explicit logical module. #[proc_macro] pub fn module(input: TokenStream) -> TokenStream { diff --git a/distributed_macros/src/portable_command.rs b/distributed_macros/src/portable_command.rs new file mode 100644 index 00000000..229906ae --- /dev/null +++ b/distributed_macros/src/portable_command.rs @@ -0,0 +1,345 @@ +//! `portable_command!` — domain command mounts (`PCH-DEC-001`). +//! +//! The handler attribute is already `#[command]`, so this function-like form +//! uses a distinct name. Spec sketches called it `command!`. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::{parenthesized, Expr, Ident, LitStr, Token, Type}; + +struct AuthenticatedUserField { + event: Type, + state: Type, + field: LitStr, +} + +impl Parse for AuthenticatedUserField { + fn parse(input: ParseStream<'_>) -> syn::Result { + let values; + parenthesized!(values in input); + let event = values.parse()?; + values.parse::()?; + let state = values.parse()?; + values.parse::()?; + let field = values.parse()?; + if !values.is_empty() { + return Err(values + .error("authenticated_user_field expects (EventType, StateType, \"field_name\")")); + } + Ok(Self { + event, + state, + field, + }) + } +} + +struct PortableCommandArgs { + name: LitStr, + transition: Type, + aggregate: Type, + input: Type, + outcome: Type, + shard: Expr, + field: LitStr, + roles: Vec, + load: LoadKind, + invoke: Option, + payload: Option, + handle: Option, + guard: Option, + defaults: Option, + constructor: Option, + authenticated_user_field: Option, + preview_reduce_known_record: Option, +} + +enum LoadKind { + Required, + Create, + None, +} + +impl Parse for PortableCommandArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut name = None; + let mut transition = None; + let mut aggregate = None; + let mut input_ty = None; + let mut outcome = None; + let mut shard = None; + let mut field = None; + let mut roles = Vec::new(); + let mut load = LoadKind::None; + let mut invoke = None; + let mut payload = None; + let mut handle = None; + let mut guard = None; + let mut defaults = None; + let mut constructor = None; + let mut authenticated_user_field = None; + let mut preview_reduce_known_record = None; + + while !input.is_empty() { + let key: Ident = input.parse()?; + input.parse::()?; + match key.to_string().as_str() { + "name" => name = Some(input.parse()?), + "transition" => transition = Some(input.parse()?), + "aggregate" => aggregate = Some(input.parse()?), + "input" => input_ty = Some(input.parse()?), + "outcome" => outcome = Some(input.parse()?), + "shard" => shard = Some(input.parse()?), + "field" => field = Some(input.parse()?), + "invoke" => invoke = Some(input.parse()?), + "payload" => payload = Some(input.parse()?), + "handle" => handle = Some(input.parse()?), + "guard" => guard = Some(input.parse()?), + "defaults" => defaults = Some(input.parse()?), + "constructor" => constructor = Some(input.parse()?), + "authenticated_user_field" => authenticated_user_field = Some(input.parse()?), + "preview_reduce_known_record" => preview_reduce_known_record = Some(input.parse()?), + "load" => { + let ident: Ident = input.parse()?; + load = match ident.to_string().as_str() { + "required" => LoadKind::Required, + "create" => LoadKind::Create, + other => { + return Err(syn::Error::new( + ident.span(), + format!("load must be `required` or `create`, not `{other}`"), + )) + } + }; + } + "roles" => { + let expr: Expr = input.parse()?; + roles = lit_strs_from_array(&expr)?; + } + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown portable_command key `{other}`"), + )) + } + } + if input.peek(Token![,]) { + input.parse::()?; + } + } + + let name = name.ok_or_else(|| input.error("portable_command requires `name: \"...\"`"))?; + Ok(Self { + name, + transition: transition + .ok_or_else(|| input.error("portable_command requires `transition:`"))?, + aggregate: aggregate + .ok_or_else(|| input.error("portable_command requires `aggregate:`"))?, + input: input_ty.ok_or_else(|| input.error("portable_command requires `input:`"))?, + outcome: outcome.ok_or_else(|| input.error("portable_command requires `outcome:`"))?, + shard: shard.ok_or_else(|| input.error("portable_command requires `shard:`"))?, + field: field.ok_or_else(|| input.error("portable_command requires `field:`"))?, + roles, + load, + invoke, + payload, + handle, + guard, + defaults, + constructor, + authenticated_user_field, + preview_reduce_known_record, + }) + } +} + +fn lit_strs_from_array(expr: &Expr) -> syn::Result> { + let Expr::Array(array) = expr else { + return Err(syn::Error::new_spanned( + expr, + "roles must be an array of string literals, e.g. [\"user\", \"admin\"]", + )); + }; + array + .elems + .iter() + .map(|elem| match elem { + Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(value), + .. + }) => Ok(value.clone()), + other => Err(syn::Error::new_spanned( + other, + "roles entries must be string literals", + )), + }) + .collect() +} + +fn names_from_command(name: &LitStr) -> syn::Result<(Ident, Ident)> { + let value = name.value(); + let last = value.rsplit('.').next().unwrap_or(value.as_str()); + if last.is_empty() || !last.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(syn::Error::new( + name.span(), + "command name must end in a snake_case ident (e.g. todo.complete)", + )); + } + let pascal = last + .split('_') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect::(); + Ok((format_ident!("{pascal}"), Ident::new(last, name.span()))) +} + +pub fn expand(input: TokenStream) -> syn::Result { + let framework = crate::shared::framework_path()?; + let args = syn::parse2::(input)?; + let (ty, default_ctor) = names_from_command(&args.name)?; + let ctor = args.constructor.as_ref().unwrap_or(&default_ctor); + let name = &args.name; + let transition = &args.transition; + let aggregate = &args.aggregate; + let input_ty = &args.input; + let outcome = &args.outcome; + let shard = &args.shard; + let field = &args.field; + let roles = &args.roles; + let defaults = args.defaults.as_ref().map(|defaults| { + quote! { .input_defaults(#defaults) } + }); + let authenticated_user_field = args.authenticated_user_field.as_ref().map(|value| { + let event = &value.event; + let state = &value.state; + let field = &value.field; + quote! { .authenticated_user_field::<#event, #state>(#field) } + }); + let preview_reduce_known_record = args + .preview_reduce_known_record + .as_ref() + .map(|preview| quote! { .preview_reduce_known_record(#preview) }); + + let install_body = if let Some(handle) = &args.handle { + if args.invoke.is_some() || args.payload.is_some() { + return Err(syn::Error::new_spanned( + handle, + "handle: is the escape hatch; do not also set invoke:/payload:", + )); + } + let finish = match &args.guard { + Some(guard) => quote! { .guarded(#guard, #handle) }, + None => quote! { .handle(#handle) }, + }; + quote! { + routes + .command_transition::<#transition, #input_ty, #outcome>(Self::COMMAND) + .field_name(#field) + .roles([#(#roles),*].into_iter()) + #defaults + #authenticated_user_field + #preview_reduce_known_record + #finish + } + } else { + let invoke = args.invoke.as_ref().ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "thin portable_command requires `invoke:` (or `handle:` as the escape hatch)", + ) + })?; + let payload = args.payload.as_ref().ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "thin portable_command requires `payload:` (or `handle:` as the escape hatch)", + ) + })?; + if args.guard.is_some() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "guard: is only valid with handle:; thin commands admit via roles", + )); + } + let load = match args.load { + LoadKind::Required => quote! { .load_by(|input: &#input_ty| Self::shard(input)) }, + LoadKind::Create => quote! { .create() }, + LoadKind::None => { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "thin portable_command requires `load: required` or `load: create`", + )) + } + }; + let finish = thin_finish(outcome, payload)?; + quote! { + routes + .command_transition::<#transition, #input_ty, #outcome>(Self::COMMAND) + .field_name(#field) + .roles([#(#roles),*].into_iter()) + #defaults + #authenticated_user_field + #preview_reduce_known_record + #load + .invoke(#invoke) + #finish + } + }; + + Ok(quote! { + pub struct #ty; + + pub fn #ctor() -> #ty { + #ty + } + + impl #ty { + pub const COMMAND: &'static str = #name; + + pub fn shard(input: &#input_ty) -> String { + let shard: fn(&#input_ty) -> String = #shard; + shard(input) + } + } + + impl #framework::microsvc::PortableCommand for #ty + where + D: #framework::microsvc::CausalRouteDependencies + + Send + + Sync + + 'static, + { + fn install(self, routes: #framework::microsvc::Routes) -> #framework::microsvc::Routes { + #install_body + } + } + }) +} + +fn thin_finish(outcome: &Type, payload: &Expr) -> syn::Result { + let last = match outcome { + Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident.to_string()), + _ => None, + }; + match last.as_deref() { + Some("Eventual") => Ok(quote! { .eventual(#payload) }), + Some("Succeeded") => Ok(quote! { .succeeded(#payload) }), + Some("Atomic") => Err(syn::Error::new_spanned( + outcome, + "thin portable_command does not yet support Atomic; use handle:", + )), + _ => Err(syn::Error::new_spanned( + outcome, + "thin portable_command outcome must be Eventual<...> or Succeeded<...>", + )), + } +} diff --git a/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.rs b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.rs index 2cdff592..8c097363 100644 --- a/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.rs +++ b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.rs @@ -4,6 +4,7 @@ struct ActualInput; #[distributed::command( id = "todo.mismatch", + roles(user), input = ExpectedInput, outcome = distributed::graphql::Succeeded )] diff --git a/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr index 5b581fe7..436554e9 100644 --- a/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr +++ b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr @@ -1,5 +1,5 @@ error: handler input parameter does not match the declared `input = ...` type - --> tests/compile_fail/application_command_declared_type_mismatch.rs:12:13 + --> tests/compile_fail/application_command_declared_type_mismatch.rs:13:13 | -12 | _input: ActualInput, +13 | _input: ActualInput, | ^^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_option.rs b/distributed_macros/tests/compile_fail/application_command_duplicate_option.rs new file mode 100644 index 00000000..bde61a0f --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_option.rs @@ -0,0 +1,21 @@ +struct Aggregate; +struct Input; + +#[distributed::command( + id = "todo.create", + id = "todo.rename", + roles(user), + input = Input, + outcome = distributed::graphql::Succeeded +)] +async fn duplicate_option( + _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, + _input: Input, +) -> Result< + distributed::graphql::PreparedCommand>, + distributed::microsvc::HandlerError, +> { + unreachable!() +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_option.stderr b/distributed_macros/tests/compile_fail/application_command_duplicate_option.stderr new file mode 100644 index 00000000..3b93dc88 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_option.stderr @@ -0,0 +1,5 @@ +error: duplicate command option id + --> tests/compile_fail/application_command_duplicate_option.rs:6:5 + | +6 | id = "todo.rename", + | ^^ diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_role.rs b/distributed_macros/tests/compile_fail/application_command_duplicate_role.rs new file mode 100644 index 00000000..d498453c --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_role.rs @@ -0,0 +1,20 @@ +struct Aggregate; +struct Input; + +#[distributed::command( + id = "todo.create", + roles(user, user), + input = Input, + outcome = distributed::graphql::Succeeded +)] +async fn duplicate_role( + _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, + _input: Input, +) -> Result< + distributed::graphql::PreparedCommand>, + distributed::microsvc::HandlerError, +> { + unreachable!() +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_role.stderr b/distributed_macros/tests/compile_fail/application_command_duplicate_role.stderr new file mode 100644 index 00000000..e70a54a2 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_role.stderr @@ -0,0 +1,5 @@ +error: command roles must not contain duplicates + --> tests/compile_fail/application_command_duplicate_role.rs:6:17 + | +6 | roles(user, user), + | ^^^^ diff --git a/distributed_macros/tests/compile_fail/application_command_empty_roles.rs b/distributed_macros/tests/compile_fail/application_command_empty_roles.rs new file mode 100644 index 00000000..6846e99a --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_empty_roles.rs @@ -0,0 +1,20 @@ +struct Aggregate; +struct Input; + +#[distributed::command( + id = "todo.create", + roles(), + input = Input, + outcome = distributed::graphql::Succeeded +)] +async fn empty_roles( + _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, + _input: Input, +) -> Result< + distributed::graphql::PreparedCommand>, + distributed::microsvc::HandlerError, +> { + unreachable!() +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_empty_roles.stderr b/distributed_macros/tests/compile_fail/application_command_empty_roles.stderr new file mode 100644 index 00000000..1c5b79d2 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_empty_roles.stderr @@ -0,0 +1,5 @@ +error: unexpected end of input, command roles must declare at least one role; use an explicit anonymous role when intended + --> tests/compile_fail/application_command_empty_roles.rs:6:11 + | +6 | roles(), + | ^ diff --git a/distributed_macros/tests/compile_fail/application_command_invalid_id.rs b/distributed_macros/tests/compile_fail/application_command_invalid_id.rs new file mode 100644 index 00000000..722f2394 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_invalid_id.rs @@ -0,0 +1,20 @@ +struct Aggregate; +struct Input; + +#[distributed::command( + id = "../Admin", + roles(admin), + input = Input, + outcome = distributed::graphql::Succeeded +)] +async fn invalid_id( + _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, + _input: Input, +) -> Result< + distributed::graphql::PreparedCommand>, + distributed::microsvc::HandlerError, +> { + unreachable!() +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_invalid_id.stderr b/distributed_macros/tests/compile_fail/application_command_invalid_id.stderr new file mode 100644 index 00000000..54791173 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_invalid_id.stderr @@ -0,0 +1,5 @@ +error: command id must be 2+ dot-separated lowercase ASCII identifier segments + --> tests/compile_fail/application_command_invalid_id.rs:5:10 + | +5 | id = "../Admin", + | ^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_command_missing_roles.rs b/distributed_macros/tests/compile_fail/application_command_missing_roles.rs new file mode 100644 index 00000000..d1aa37d1 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_missing_roles.rs @@ -0,0 +1,19 @@ +struct Aggregate; +struct Input; + +#[distributed::command( + id = "todo.create", + input = Input, + outcome = distributed::graphql::Succeeded +)] +async fn missing_roles( + _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, + _input: Input, +) -> Result< + distributed::graphql::PreparedCommand>, + distributed::microsvc::HandlerError, +> { + unreachable!() +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_missing_roles.stderr b/distributed_macros/tests/compile_fail/application_command_missing_roles.stderr new file mode 100644 index 00000000..d81232cb --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_missing_roles.stderr @@ -0,0 +1,5 @@ +error: command declaration requires explicit roles(...) + --> tests/compile_fail/application_command_missing_roles.rs:9:10 + | +9 | async fn missing_roles( + | ^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs b/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs index 330615c9..f022ebcc 100644 --- a/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs +++ b/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs @@ -3,6 +3,7 @@ struct WrongInput; #[distributed::command( id = "todo.create", + roles(user), input = WrongInput, outcome = distributed::graphql::Succeeded )] diff --git a/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr b/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr index 7b77905c..fd9c910e 100644 --- a/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr +++ b/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr @@ -1,5 +1,5 @@ error: first Result type must be PreparedCommand - --> tests/compile_fail/application_command_wrong_handler.rs:12:13 + --> tests/compile_fail/application_command_wrong_handler.rs:13:13 | -12 | ) -> Result<(), distributed::microsvc::HandlerError> { +13 | ) -> Result<(), distributed::microsvc::HandlerError> { | ^^ diff --git a/js/scripts/pack-smoke.mjs b/js/scripts/pack-smoke.mjs index 71b9c845..0ff98f3e 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', @@ -463,6 +464,7 @@ import { createDistributedSvelteKitServer, createPageDataSessionSource, defineDistributedSvelteKitOperation, + matchDistributedRoute, provideDistributedSvelteKitClient, useDistributedSvelteKitClient, useDistributedSvelteKitCommands @@ -513,6 +515,7 @@ createDistributedSvelteKitServer({ getSession: async () => null, getRole: () => 'user' }); +void matchDistributedRoute('/todos', '/todos'); const compiler = { clients: [{ @@ -557,6 +560,7 @@ assert.deepEqual(Object.keys(sveltekitSurface).sort(), [ 'createDistributedSvelteKitServer', 'createPageDataSessionSource', 'defineDistributedSvelteKitOperation', + 'matchDistributedRoute', 'provideDistributedSvelteKitClient', 'registerDistributedRoute', 'sessionSourceFromPageData', diff --git a/js/src/replica/command-id.ts b/js/src/replica/command-id.ts index ee714ec3..7546a51d 100644 --- a/js/src/replica/command-id.ts +++ b/js/src/replica/command-id.ts @@ -1,5 +1,5 @@ -/** Create a browser/Node UUIDv7 command identity. */ -export function createReplicaCommandId(): string { +/** Create a browser/Node UUIDv7 identity for commands or generated record IDs. */ +export function createReplicaUuidV7(): string { const crypto = globalThis.crypto; if (!crypto || typeof crypto.getRandomValues !== 'function') { throw new Error('replica commands require crypto.getRandomValues'); @@ -19,3 +19,6 @@ export function createReplicaCommandId(): string { .slice(6, 8) .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`; } + +/** Package-internal semantic alias used while preparing command envelopes. */ +export const createReplicaCommandId = createReplicaUuidV7; diff --git a/js/src/replica/command-runtime/create.ts b/js/src/replica/command-runtime/create.ts index 517276bd..307e5a6c 100644 --- a/js/src/replica/command-runtime/create.ts +++ b/js/src/replica/command-runtime/create.ts @@ -555,8 +555,7 @@ export function createReplicaCommandRuntime< throw new Error('projection delta changed during command replay'); } return Object.freeze({ - requiresRevalidation: - actual.revalidate || actual.obligations.length === 0 + requiresRevalidation: actual.revalidate }); } assertActualProjectionCapabilities( @@ -572,14 +571,13 @@ export function createReplicaCommandRuntime< canonical, operations, revalidation: - actual.revalidate || actual.obligations.length === 0 + actual.revalidate ? actualProjectionRevalidation( prepared.revalidation, actual.delta ) : undefined, - requiresRevalidation: - actual.revalidate || actual.obligations.length === 0 + requiresRevalidation: actual.revalidate }); }; @@ -1022,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' && @@ -1681,10 +1691,17 @@ 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. Query/live frames have no command payload; + * settle those so Send/busy can clear. Frames that name a command + * still wait for overlay retirement or the command-state paths + * above (status regression must be able to reject `projected`). */ const remainsPending = replica.markOptimisticLayerAccepted(commandId); - if (!remainsPending) { + if (!remainsPending || command === undefined) { settleProjectionSuccess(controller); pending.delete(commandId); } diff --git a/js/src/replica/distributed-replica/impl-optimistic.ts b/js/src/replica/distributed-replica/impl-optimistic.ts index 8b37acc1..9ce9469e 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-protocol.ts b/js/src/replica/distributed-replica/impl-protocol.ts index 4449b776..79ef3b64 100644 --- a/js/src/replica/distributed-replica/impl-protocol.ts +++ b/js/src/replica/distributed-replica/impl-protocol.ts @@ -47,6 +47,11 @@ export type ProtocolHost = { readonly recordClocks: Map; readonly recordKeysByScope: Map; readonly projectedRecordFences: Map; + readonly membershipFences: Map< + string, + Map> + >; + readonly deferredMembershipConfirms: Set; readonly anonymousRecordClocks: Map< DistributedOpaqueString, AnonymousRecordProtocolClock @@ -192,6 +197,8 @@ export function purgeProtocolGeneration(host: ProtocolHost): void { host.recordClocks.clear(); host.recordKeysByScope.clear(); host.projectedRecordFences.clear(); + host.membershipFences.clear(); + host.deferredMembershipConfirms.clear(); host.anonymousRecordClocks.clear(); host.optimisticReceipts.clear(); host.diagnosticLayers?.clear(); diff --git a/js/src/replica/distributed-replica/impl.ts b/js/src/replica/distributed-replica/impl.ts index 0291bc99..075f2216 100644 --- a/js/src/replica/distributed-replica/impl.ts +++ b/js/src/replica/distributed-replica/impl.ts @@ -80,6 +80,7 @@ import type { ReplicaOperationArtifact, ReplicaOptimisticWriter, ReplicaRecordInspection, + ReplicaRecordPatch, ReplicaRevalidationPlan, ReplicaRevision, ReplicaResultEnvelope, @@ -133,6 +134,7 @@ import { import { diagnosticReceiptCounts } from './optimistic.js'; import { assertWriteSource, + baseWriter, indexKeyFromTarget, indexMaintenanceSnapshot, indexSemanticLayer, @@ -169,6 +171,7 @@ import { import { applyReceiptOnly as applyReceiptOnlyOn, confirmOptimisticLayerOn, + confirmOptimisticLayerWithCacheWriterOn, createOptimisticLayerOn, markOptimisticLayerAcceptedOn, planOptimisticReceipts as planOptimisticReceiptsOn, @@ -209,6 +212,17 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { readonly #recordClocks = new Map(); readonly #recordKeysByScope = new Map(); readonly #projectedRecordFences = new Map(); + /** + * Record keys inserted by Eventual projection-delta. A later complete + * query/live index that omits them is behind command confirmation and + * must not shrink the visible list. Atomic rows use projected-record + * fences only — they have no @live that would otherwise clear this map. + */ + readonly #membershipFences = new Map< + string, + Map> + >(); + readonly #deferredMembershipConfirms = new Set(); readonly #anonymousRecordClocks = new Map< DistributedOpaqueString, AnonymousRecordProtocolClock @@ -357,6 +371,12 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { get projectedRecordFences() { return self.#projectedRecordFences; }, + get membershipFences() { + return self.#membershipFences; + }, + get deferredMembershipConfirms() { + return self.#deferredMembershipConfirms; + }, get anonymousRecordClocks() { return self.#anonymousRecordClocks; }, @@ -670,14 +690,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); @@ -701,6 +768,11 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { * model necessarily catches up. Retain its complete row and causal * clock as a write fence until a query acknowledges it or a newer * record/tombstone supersedes it. + * + * Do not also take a membership fence: Atomic lists are @load, not + * @live. A membership fence would reject later complete snapshots + * until a live frame that never comes, stalling blob/new games and + * client-side navigations. */ this.#projectedRecordFences.set( recordKey, @@ -726,7 +798,7 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { return replaceReplicaOptimisticLayerOn( this.#optimisticHost(), commandId, - update, + (writer) => update(this.#capturingOptimisticWriter(commandId, writer)), semanticChanges ); } @@ -1337,8 +1409,9 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { let summary: ReturnType; try { const update = (writer: BaseCacheWriter) => { + const guarded = this.#guardIndexWriter(writer); this.#applyTombstoneEvidence( - writer, + guarded, recordEvidence.tombstones, operationState, pendingRecordClocks, @@ -1349,7 +1422,7 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { pendingProjectedRecordFenceClears ); const normalized = normalizeReplicaResult( - writer, + guarded, artifact, stableVariables, envelope, @@ -1363,7 +1436,7 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { } } this.#applyPathlessEvidence( - writer, + guarded, recordEvidence.pathless, recordEvidence.byPath, consumedRecordPaths, @@ -1481,6 +1554,7 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { this.#protocolGeneration = nextProtocolGeneration; this.#resumeLiveWatches(); this.#emitState(key, false); + this.#flushDeferredMembershipConfirms(); if (this.#diagnostics !== undefined) { const cache = this.#engine.extract(); this.#diagnosticEvent( @@ -1567,14 +1641,163 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { id: string, update: (writer: ReplicaBaseWriter) => T ): T { + if (this.#commandHasMembershipFence(id)) { + this.#deferredMembershipConfirms.add(id); + return this.#engine.batch((writer) => update(baseWriter(writer))); + } return confirmOptimisticLayerOn(this.#optimisticHost(), id, update); } rejectOptimisticLayer(id: string): boolean { + this.#clearMembershipFencesForCommand(id); + this.#deferredMembershipConfirms.delete(id); return rejectOptimisticLayerOn(this.#optimisticHost(), id); } - tombstoneRecord( + #capturingOptimisticWriter( + commandId: string, + writer: ReplicaOptimisticWriter + ): ReplicaOptimisticWriter { + const touchedRecords = new Set(); + return { + writeRecord: ( + model: ReplicaModelArtifact, + identity: ReplicaIdentity, + patch: ReplicaRecordPatch + ) => { + touchedRecords.add(replicaRecordKey(model, identity)); + writer.writeRecord(model, identity, patch); + }, + tombstoneRecord: (model, identity) => { + const recordKey = replicaRecordKey(model, identity); + touchedRecords.delete(recordKey); + this.#clearMembershipFenceOwner(commandId, recordKey); + writer.tombstoneRecord(model, identity); + }, + writeIndex: (target, records) => { + const indexKey = indexKeyFromTarget(target); + for (const recordKey of records) { + if (!touchedRecords.has(recordKey)) continue; + let recordsForIndex = this.#membershipFences.get(indexKey); + if (recordsForIndex === undefined) { + recordsForIndex = new Map(); + this.#membershipFences.set(indexKey, recordsForIndex); + } + let owners = recordsForIndex.get(recordKey); + if (owners === undefined) { + owners = new Set(); + recordsForIndex.set(recordKey, owners); + } + owners.add(commandId); + } + writer.writeIndex(target, records); + }, + deleteIndex: (target) => { + const indexKey = indexKeyFromTarget(target); + const recordsForIndex = this.#membershipFences.get(indexKey); + if (recordsForIndex !== undefined) { + for (const [recordKey, owners] of recordsForIndex) { + owners.delete(commandId); + if (owners.size === 0) recordsForIndex.delete(recordKey); + } + if (recordsForIndex.size === 0) { + this.#membershipFences.delete(indexKey); + } + } + writer.deleteIndex(target); + } + }; + } + + #commandHasMembershipFence(commandId: string): boolean { + for (const recordsForIndex of this.#membershipFences.values()) { + for (const owners of recordsForIndex.values()) { + if (owners.has(commandId)) return true; + } + } + return false; + } + + #clearMembershipFencesForCommand(commandId: string): void { + for (const [indexKey, recordsForIndex] of this.#membershipFences) { + for (const [recordKey, owners] of recordsForIndex) { + owners.delete(commandId); + if (owners.size === 0) recordsForIndex.delete(recordKey); + } + if (recordsForIndex.size === 0) { + this.#membershipFences.delete(indexKey); + } + } + } + + #clearMembershipFenceOwner(commandId: string, recordKey: string): void { + for (const [indexKey, recordsForIndex] of this.#membershipFences) { + const owners = recordsForIndex.get(recordKey); + if (owners === undefined) continue; + owners.delete(commandId); + if (owners.size === 0) recordsForIndex.delete(recordKey); + if (recordsForIndex.size === 0) { + this.#membershipFences.delete(indexKey); + } + } + } + + #guardIndexWriter(writer: BaseCacheWriter): BaseCacheWriter { + return { + recordClock: (key) => writer.recordClock(key), + writeRecord: (write) => writer.writeRecord(write), + tombstoneRecord: (key, revision, incarnation) => + writer.tombstoneRecord(key, revision, incarnation), + discardRecord: (key) => writer.discardRecord(key), + writeIndex: (write) => { + const recordsForIndex = this.#membershipFences.get(write.key); + if (write.complete === true && recordsForIndex !== undefined) { + const visible = + this.#engine.read( + (reader) => reader.index(write.key)?.records + ) ?? []; + for (const recordKey of recordsForIndex.keys()) { + if ( + visible.includes(recordKey) && + !write.records.includes(recordKey) + ) { + return false; + } + } + } + const wrote = writer.writeIndex(write); + if (wrote && recordsForIndex !== undefined) { + for (const recordKey of write.records) { + recordsForIndex.delete(recordKey); + } + if (recordsForIndex.size === 0) { + this.#membershipFences.delete(write.key); + } + } + return wrote; + }, + markIndexStale: (key, reason, revision) => + writer.markIndexStale(key, reason, revision), + deleteIndex: (key, revision) => writer.deleteIndex(key, revision) + }; + } + + #flushDeferredMembershipConfirms(): void { + for (const commandId of [...this.#deferredMembershipConfirms]) { + if (this.#commandHasMembershipFence(commandId)) continue; + this.#deferredMembershipConfirms.delete(commandId); + if (this.#engine.optimisticLayerState(commandId) === undefined) { + continue; + } + confirmOptimisticLayerOn( + this.#optimisticHost(), + commandId, + () => undefined + ); + } + } + + tombstoneRecord( model: ReplicaModelArtifact, identity: ReplicaIdentity, revision: ReplicaRevision @@ -1868,6 +2091,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/src/replica/index.ts b/js/src/replica/index.ts index 4637414e..f8a96fa9 100644 --- a/js/src/replica/index.ts +++ b/js/src/replica/index.ts @@ -36,6 +36,7 @@ export type { ReplicaOperationInjectedFieldInspection } from './diagnostics.js'; export { createReplicaGraphqlTransport } from './graphql-transport.js'; +export { createReplicaUuidV7 } from './command-id.js'; export type { ReplicaGraphqlTransport, ReplicaGraphqlTransportOptions diff --git a/js/src/replica/projection-delta/resolve.ts b/js/src/replica/projection-delta/resolve.ts index de9408c6..3a3dd2bd 100644 --- a/js/src/replica/projection-delta/resolve.ts +++ b/js/src/replica/projection-delta/resolve.ts @@ -32,7 +32,9 @@ export function prepareCommandProjection( return Object.freeze({ contract, preview: Object.freeze([...preview, ...pure]), - revalidate: contract.preview.recoveries.length !== 0 + revalidate: contract.preview.recoveries.some( + (recovery) => recovery.condition === 'always' + ) }); } catch { // Preview is only a convenience. Missing client authority must never be diff --git a/js/src/sveltekit/context.ts b/js/src/sveltekit/context.ts index 59c51540..1b3fe7f4 100644 --- a/js/src/sveltekit/context.ts +++ b/js/src/sveltekit/context.ts @@ -96,6 +96,11 @@ export function defineDistributedSvelteKitOperation< return useDistributedSvelteKitClient() .operation(artifact) .read(variables); + }, + prefetch(variables: TVariables): Promise { + return useDistributedSvelteKitClient() + .operation(artifact) + .prefetch(variables); } }); } diff --git a/js/src/sveltekit/index.ts b/js/src/sveltekit/index.ts index fab8b500..80af9c81 100644 --- a/js/src/sveltekit/index.ts +++ b/js/src/sveltekit/index.ts @@ -28,6 +28,7 @@ export { } from './replica.js'; export { createDistributedSvelteKitServer, + matchDistributedRoute, registerDistributedRoute, type CreateDistributedSvelteKitServerOptions, type DistributedRouteOperation, diff --git a/js/src/sveltekit/replica.ts b/js/src/sveltekit/replica.ts index 4a0773c5..7d32a4d7 100644 --- a/js/src/sveltekit/replica.ts +++ b/js/src/sveltekit/replica.ts @@ -166,6 +166,8 @@ export type SveltekitBoundOperation< ...args: UseOperationArguments ): SveltekitQueryStore; read(variables: TVariables): ReplicaSnapshot; + /** Client-side hover/nav warmup; no-ops when the replica already has a complete snapshot. */ + prefetch(variables: TVariables): Promise; }>; export type DistributedSvelteKitClient = Readonly<{ @@ -183,6 +185,10 @@ export type DistributedSvelteKitClient = Readonly<{ hydration: SveltekitReplicaHydration, authority: SveltekitReplicaAuthority ): boolean; + prefetch( + artifact: ReplicaOperationArtifact, + variables: GraphqlVariables + ): Promise; invalidateAuthorization(): void; destroy(): void; }>; @@ -300,6 +306,9 @@ export function createDistributedSvelteKit( return Object.freeze({ artifact, use, - read: (variables: TVariables) => replica.read(artifact, variables) + read: (variables: TVariables) => replica.read(artifact, variables), + prefetch: (variables: TVariables) => + prefetchReplicaOperation(replica, artifact, variables) }); } +function prefetchReplicaOperation( + replica: DistributedReplica, + artifact: ReplicaOperationArtifact, + variables: TVariables +): Promise { + const snapshot = replica.read(artifact, variables); + if (snapshot.complete && !snapshot.stale) return Promise.resolve(); + const watch = replica.watch(artifact, variables, { live: false }); + return watch.refresh().finally(() => watch.destroy()); +} + type SveltekitStoreLifecycle = Readonly<{ isAlive(): boolean; activateWatches(): boolean; diff --git a/js/src/sveltekit/server-replica.ts b/js/src/sveltekit/server-replica.ts index 427ef40b..c1891cad 100644 --- a/js/src/sveltekit/server-replica.ts +++ b/js/src/sveltekit/server-replica.ts @@ -31,6 +31,12 @@ export type SveltekitServerLoadEventLike = Readonly<{ route?: Readonly<{ id?: string | null }>; url?: URL; fetch?: FetchLike; + /** + * SvelteKit client-side navigation and hover preload (`__data.json`). + * Document SSR is `false`/`undefined`; those navigations must not wait on + * a fresh GraphQL replica — the browser replica already owns the cache. + */ + isDataRequest?: boolean; }>; export type DistributedRouteVariables< @@ -87,6 +93,12 @@ export function createDistributedSvelteKitServer< accessToken, engineRole }; + if (event.isDataRequest === true) { + return { + ...pageData, + gqlError: null + }; + } const auth = options.getAuth?.(pageData, event) ?? authFromPageData(pageData); const routeId = routeIdentity(event); @@ -219,6 +231,73 @@ function hydrationTransfer( }); } +/** + * Match a SvelteKit route id (`/blob/[[gameId]]`) to a browser pathname. + */ +export function matchDistributedRoute( + routeId: string, + pathname: string +): boolean { + const route = normalizeRoute(routeId); + const path = normalizePathname(pathname); + if (route === path) return true; + const routeParts = route + .split('/') + .filter(Boolean) + .filter((part) => !(part.startsWith('(') && part.endsWith(')'))); + const pathParts = path.split('/').filter(Boolean); + const failed = new Set(); + const matches = (routeIndex: number, pathIndex: number): boolean => { + const state = routeIndex + ':' + pathIndex; + if (failed.has(state)) return false; + if (routeIndex === routeParts.length) { + return pathIndex === pathParts.length; + } + const part = routeParts[routeIndex]!; + const optionalRest = + part.startsWith('[[...') && part.endsWith(']]'); + const rest = part.startsWith('[...') && part.endsWith(']'); + if (optionalRest || rest) { + for (let next = pathIndex; next <= pathParts.length; next += 1) { + if (matches(routeIndex + 1, next)) return true; + } + failed.add(state); + return false; + } + const optional = + part.startsWith('[[') && part.endsWith(']]'); + if (optional) { + if (matches(routeIndex + 1, pathIndex)) return true; + if ( + pathIndex < pathParts.length && + matches(routeIndex + 1, pathIndex + 1) + ) { + return true; + } + failed.add(state); + return false; + } + const parameter = part.startsWith('[') && part.endsWith(']'); + if ( + (parameter && pathIndex < pathParts.length) || + (!parameter && + pathIndex < pathParts.length && + pathParts[pathIndex] === part) + ) { + if (matches(routeIndex + 1, pathIndex + 1)) return true; + } + failed.add(state); + return false; + }; + return matches(0, 0); +} + +function normalizePathname(pathname: string): string { + if (typeof pathname !== 'string' || pathname.length === 0) return '/'; + const trimmed = pathname.replace(/\/+$/, ''); + return trimmed.length === 0 ? '/' : trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} + function validateRoutes( value: readonly DistributedRouteOperation[] ): readonly DistributedRouteOperation[] { diff --git a/js/tests/replica-command-artifacts.test.mjs b/js/tests/replica-command-artifacts.test.mjs index 918b0d22..b4a0f0f6 100644 --- a/js/tests/replica-command-artifacts.test.mjs +++ b/js/tests/replica-command-artifacts.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { + createReplicaUuidV7, prepareReplicaCommand, ReplicaCommandContractError, verifyReplicaCommandReceipt @@ -136,7 +137,7 @@ function projectionScope(model, ...fields) { }); } -function projectionArtifact(operations, capabilities) { +function projectionArtifact(operations, capabilities, recoveries = []) { return Object.freeze({ version: 2, deltaWireVersion: 1, @@ -178,7 +179,7 @@ function projectionArtifact(operations, capabilities) { }) ) ), - recoveries: Object.freeze([]) + recoveries: Object.freeze(recoveries) }), fallback: 'revalidate' }); @@ -385,15 +386,16 @@ test('explicit defaulted fields are retained and their generators never run', () }); test('compact generated preview patches canonicalize an omitted unset list', () => { + const scope = projectionScope( + 'Todo', + projectionField('id', inputValue(['id'])) + ); const artifact = baseArtifact({ projection: projectionArtifact( [ Object.freeze({ op: 'patch', - scope: projectionScope( - 'Todo', - projectionField('id', inputValue(['id'])) - ), + scope, set: Object.freeze([ projectionField('title', inputValue(['title'])) ]), @@ -411,6 +413,14 @@ test('compact generated preview patches canonicalize an omitted unset list', () patch: true, delete: false }) + ], + [ + Object.freeze({ + occurrence_ordinal: 0, + projection_refs: Object.freeze([0]), + condition: 'if_record_missing', + target: Object.freeze({ kind: 'record', scope }) + }) ] ) }); @@ -441,6 +451,11 @@ test('compact generated preview patches canonicalize an omitted unset list', () const unset = prepared.optimistic.operations[0].unset; assert.equal(Object.isFrozen(unset), true); assert.throws(() => unset.push('title'), TypeError); + assert.equal( + prepared.projection.revalidate, + false, + 'a conditional missing-record fallback is not unconditional revalidation' + ); }); test('real default generators produce canonical values', () => { @@ -454,6 +469,14 @@ test('real default generators produce canonical values', () => { assert.match(prepared.input.code, /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/); }); +test('public UUIDv7 generation supports caller-correlated optimistic record ids', () => { + const id = createReplicaUuidV7(); + assert.match( + id, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); +}); + test('none inputs and typed JSON fields produce exact canonical transport variables', () => { const noInput = prepareReplicaCommand( baseArtifact({ diff --git a/js/tests/replica-command-runtime.test.mjs b/js/tests/replica-command-runtime.test.mjs index 60dacb91..2e2645da 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; @@ -1558,7 +1606,7 @@ test('an allowed unpreviewed event arm can authoritatively replace the preview', runtime.dispose(); }); -test('zero-obligation revalidation includes actual unpreviewed target models', async () => { +test('explicit revalidation includes actual unpreviewed target models', async () => { const modeled = modeledArtifactWithAuditArm(); const replica = new TestReplica(); const runtime = createReplicaCommandRuntime( @@ -1568,6 +1616,7 @@ test('zero-obligation revalidation includes actual unpreviewed target models', a Promise.resolve( envelope(request, { obligations: 0, + revalidate: true, mutation: { op: 'upsert', scope: scope( @@ -1768,8 +1817,8 @@ test('zero, one, and many obligations are server-derived and never predicted key assert.equal(receipt.projected === undefined, count === 0); if (count === 0) { await tick(); - assert.equal(replica.revalidations.length, 1); - assert.equal(replica.layer(receipt.commandId), undefined); + assert.equal(replica.revalidations.length, 0); + assert.equal(replica.layer(receipt.commandId), 'accepted'); } runtime.dispose(); } diff --git a/js/tests/replica-protocol.test.mjs b/js/tests/replica-protocol.test.mjs index 86b1f3e4..d23eddf4 100644 --- a/js/tests/replica-protocol.test.mjs +++ b/js/tests/replica-protocol.test.mjs @@ -10,6 +10,10 @@ import { createDistributedReplica, replicaRecordKey } from '../dist/replica/index.js'; +import { + replicaCommandDirectProjection, + replicaCommandProjectionDelta +} from '../dist/replica/command-runtime.js'; import { COMMAND_CONSISTENCY, COMMAND_STATE, @@ -103,6 +107,34 @@ const TodosOtherLiveOperation = Object.freeze({ }) }); +const TodosOpen = Object.freeze({ + ...Todos, + id: 'query:todos-open', + document: 'query TodosOpen { todos(where: {status: {_eq: "open"}}) { id title } }', + protocol: Object.freeze({ + ...Todos.protocol, + operation: 'query:todos-open' + }), + live: Object.freeze({ + id: 'live:todos-open', + document: + 'subscription TodosOpenLive { todos(where: {status: {_eq: "open"}}) { id title } }' + }), + roots: Object.freeze([ + Object.freeze({ + ...Todos.roots[0], + arguments: Object.freeze({ + where: Object.freeze({ + kind: 'literal', + value: Object.freeze({ + status: Object.freeze({ _eq: 'open' }) + }) + }) + }) + }) + ]) +}); + /* * Mirrors the generated Todos artifact's material index semantics: an exact, * offset-backed collection whose authorization policy is server-only. @@ -191,6 +223,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 } } }', @@ -615,6 +665,405 @@ test('authoritative revalidation succeeds against confirmed data while a server- watch.destroy(); }); +test('comparable live snapshot cannot drop an Eventual list row after confirmation', () => { + const replica = createDistributedReplica(); + write(replica, { + position: '1', + rows: [{ id: 'todo-1', title: 'first' }] + }); + assert.deepEqual(replica.read(Todos, {}).data.todos, [ + { id: 'todo-1', title: 'first' } + ]); + + replica.createOptimisticLayer('cmd-eventual-post', (writer) => { + writer.writeRecord(Todo, 'todo-2', { + fields: { id: 'todo-2', title: 'posted' } + }); + writer.writeIndex( + { + field: 'todos', + arguments: {}, + dependencies: ['todos'], + complete: true + }, + [ + replicaRecordKey(Todo, 'todo-1'), + replicaRecordKey(Todo, 'todo-2') + ] + ); + }); + replica[replicaCommandProjectionDelta]( + 'cmd-eventual-post', + (writer) => { + writer.writeRecord(Todo, 'todo-2', { + fields: { id: 'todo-2', title: 'posted' } + }); + writer.writeIndex( + { + field: 'todos', + arguments: {}, + dependencies: ['todos'], + complete: true + }, + [ + replicaRecordKey(Todo, 'todo-1'), + replicaRecordKey(Todo, 'todo-2') + ] + ); + }, + [] + ); + assert.deepEqual(replica.read(Todos, {}).data.todos, [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ]); + + write( + replica, + { + position: '2', + operation: Todos.live.id, + rows: [{ id: 'todo-1', title: 'first' }], + live: { supported: true } + }, + 'live' + ); + replica.confirmOptimisticLayer('cmd-eventual-post', () => undefined); + + assert.equal(replica.read(Todos, {}).complete, true); + assert.deepEqual( + replica.read(Todos, {}).data.todos, + [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ], + 'SQL @live that has not projected the confirmed Eventual row must not shrink the list' + ); + + write( + replica, + { + position: '3', + operation: Todos.live.id, + rows: [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ], + live: { supported: true }, + records: [ + { + path: ['todos', '0'], + model: 'TodoView', + scopeToken: 'record:todo-1', + incarnation: '1', + revision: '3', + tombstone: false + }, + { + path: ['todos', '1'], + model: 'TodoView', + scopeToken: 'record:todo-2', + incarnation: '1', + revision: '3', + tombstone: false + } + ] + }, + 'live' + ); + assert.deepEqual(replica.read(Todos, {}).data.todos, [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ]); +}); + +test('Eventual membership fences are independent per index', () => { + const replica = createDistributedReplica(); + const baseRows = [{ id: 'todo-1', title: 'first' }]; + write(replica, { position: '1', rows: baseRows }); + write( + replica, + { + position: '1', + operation: TodosOpen.id, + projection: 'todos-open-projector', + indexScope: 'index:todos-open', + snapshotScope: 'snapshot:todos-open', + rows: baseRows + }, + 'network', + TodosOpen + ); + const allTarget = { + field: 'todos', + arguments: {}, + dependencies: ['todos'], + complete: true + }; + const openTarget = { + ...allTarget, + arguments: { where: { status: { _eq: 'open' } } } + }; + const recordKeys = [ + replicaRecordKey(Todo, 'todo-1'), + replicaRecordKey(Todo, 'todo-2') + ]; + replica.createOptimisticLayer('cmd-two-indexes', () => undefined); + replica[replicaCommandProjectionDelta]( + 'cmd-two-indexes', + (writer) => { + writer.writeRecord(Todo, 'todo-2', { + fields: { id: 'todo-2', title: 'posted' } + }); + writer.writeIndex(allTarget, recordKeys); + writer.writeIndex(openTarget, recordKeys); + }, + [] + ); + replica.confirmOptimisticLayer('cmd-two-indexes', () => undefined); + + write( + replica, + { + position: '2', + operation: Todos.live.id, + rows: [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ], + live: { supported: true } + }, + 'live' + ); + write( + replica, + { + position: '2', + operation: TodosOpen.live.id, + projection: 'todos-open-projector', + indexScope: 'index:todos-open', + snapshotScope: 'snapshot:todos-open', + rows: [{ id: 'todo-1', title: 'first' }], + live: { supported: true } + }, + 'live', + TodosOpen + ); + assert.deepEqual(replica.read(TodosOpen, {}).data.todos, [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ]); + + write( + replica, + { + position: '3', + operation: TodosOpen.live.id, + projection: 'todos-open-projector', + indexScope: 'index:todos-open', + snapshotScope: 'snapshot:todos-open', + rows: [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ], + live: { supported: true } + }, + 'live', + TodosOpen + ); + assert.deepEqual(replica.read(TodosOpen, {}).data.todos, [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ]); +}); + +test('overlapping Eventual commands retain every membership-fence owner', () => { + const replica = createDistributedReplica(); + write(replica, { + position: '1', + rows: [{ id: 'todo-1', title: 'first' }] + }); + const target = { + field: 'todos', + arguments: {}, + dependencies: ['todos'], + complete: true + }; + const records = [ + replicaRecordKey(Todo, 'todo-1'), + replicaRecordKey(Todo, 'todo-2') + ]; + for (const commandId of ['cmd-owner-a', 'cmd-owner-b']) { + replica.createOptimisticLayer(commandId, () => undefined); + replica[replicaCommandProjectionDelta]( + commandId, + (writer) => { + writer.writeRecord(Todo, 'todo-2', { + fields: { id: 'todo-2', title: 'posted' } + }); + writer.writeIndex(target, records); + }, + [] + ); + } + replica.rejectOptimisticLayer('cmd-owner-b'); + replica.confirmOptimisticLayer('cmd-owner-a', () => undefined); + write( + replica, + { + position: '2', + operation: Todos.live.id, + rows: [{ id: 'todo-1', title: 'first' }], + live: { supported: true } + }, + 'live' + ); + assert.deepEqual(replica.read(Todos, {}).data.todos, [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'posted' } + ]); +}); + +test('Atomic direct projection does not hold later complete @load membership', () => { + const replica = createDistributedReplica(); + write(replica, { + position: '1', + rows: [{ id: 'todo-1', title: 'first' }] + }); + replica.createOptimisticLayer('cmd-atomic-start', (writer) => { + writer.writeRecord(Todo, 'todo-2', { + fields: { id: 'todo-2', title: 'started' } + }); + writer.writeIndex( + { + field: 'todos', + arguments: {}, + dependencies: ['todos'], + complete: true + }, + [replicaRecordKey(Todo, 'todo-1'), replicaRecordKey(Todo, 'todo-2')] + ); + }); + replica[replicaCommandDirectProjection]('cmd-atomic-start', { + model: Todo, + identity: 'todo-2', + evidence: { + model: Todo.id, + scopeToken: 'record:todo-2', + incarnation: '1', + revision: '2', + tombstone: false + }, + fields: { id: 'todo-2', title: 'started', __typename: Todo.id } + }); + write(replica, { + position: '2', + rows: [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'started' } + ], + records: [ + { + path: ['todos', '0'], + model: Todo.id, + scopeToken: 'record:todo-1', + incarnation: '1', + revision: '2', + tombstone: false + }, + { + path: ['todos', '1'], + model: Todo.id, + scopeToken: 'record:todo-2', + incarnation: '1', + revision: '2', + tombstone: false + } + ] + }); + assert.deepEqual(replica.read(Todos, {}).data.todos, [ + { id: 'todo-1', title: 'first' }, + { id: 'todo-2', title: 'started' } + ]); +}); + +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/js/tests/sveltekit-ssr.test.mjs b/js/tests/sveltekit-ssr.test.mjs index ddb2e95d..73bc2a42 100644 --- a/js/tests/sveltekit-ssr.test.mjs +++ b/js/tests/sveltekit-ssr.test.mjs @@ -3,7 +3,8 @@ import test from 'node:test'; import { createDistributedSvelteKit, - createDistributedSvelteKitServer + createDistributedSvelteKitServer, + matchDistributedRoute } from '../dist/sveltekit/index.js'; import { REACT_FIXTURE_SCHEMA, @@ -166,6 +167,49 @@ test('static @load SSR is request-isolated and hydration avoids a duplicate firs client.destroy(); }); +test('client-side data requests skip GraphQL so navigation stays SPA', async () => { + const harness = serverHarness(); + const document = await harness.server.load(harness.event('alice')); + assert.equal(harness.calls.length, 1); + assert.ok(document.distributed); + + const dataNav = await harness.server.load({ + ...harness.event('alice'), + isDataRequest: true + }); + assert.equal(harness.calls.length, 1, 'SPA data request must not seed a new replica'); + assert.equal(dataNav.distributed, undefined); + assert.equal(dataNav.gqlError, null); + assert.equal(dataNav.accessToken, 'alice'); +}); + +test('matchDistributedRoute covers optional and required segments', () => { + assert.equal(matchDistributedRoute('/todos', '/todos'), true); + assert.equal(matchDistributedRoute('/todos', '/todos/'), true); + assert.equal(matchDistributedRoute('/todos', '/chat'), false); + assert.equal(matchDistributedRoute('/blob/[[gameId]]', '/blob'), true); + assert.equal(matchDistributedRoute('/blob/[[gameId]]', '/blob/abc'), true); + assert.equal(matchDistributedRoute('/blob/[[gameId]]', '/blob/abc/extra'), false); + assert.equal(matchDistributedRoute('/[[lang]]/home', '/home'), true); + assert.equal(matchDistributedRoute('/[[lang]]/home', '/en/home'), true); + assert.equal(matchDistributedRoute('/[[lang]]/home', '/en/other'), false); + assert.equal(matchDistributedRoute('/(app)/todos/[id]', '/todos/1'), true); + assert.equal(matchDistributedRoute('/docs/[...rest]/edit', '/docs/edit'), true); + assert.equal( + matchDistributedRoute('/docs/[...rest]/edit', '/docs/a/b/edit'), + true + ); + assert.equal( + matchDistributedRoute('/docs/[...rest]/edit', '/docs/a/b/view'), + false + ); + assert.equal( + matchDistributedRoute('/users/[id=uuid]', '/users/0190a000'), + true + ); + assert.equal(matchDistributedRoute('/chat', '/chat'), true); +}); + test('replica state never self-authorizes hydration scope', async () => { const harness = serverHarness(); const [alice, bob] = await Promise.all([ diff --git a/src/bus/nats.rs b/src/bus/nats.rs index 0e897aba..554c2d34 100644 --- a/src/bus/nats.rs +++ b/src/bus/nats.rs @@ -18,8 +18,10 @@ use async_nats::jetstream::stream::Config as StreamConfig; use async_nats::jetstream::{self, AckKind}; use futures::StreamExt; +use crate::projection_protocol::{ProjectionEpoch, ProjectionSource}; + use super::source::{MessageSource, ReceivedMessage}; -use super::{message_from_wire, strip_address_prefix, Message}; +use super::{message_from_wire, strip_address_prefix, Message, OrderedDelivery}; use super::{retryable, MessagePublisher, TransportError}; /// Header carrying the stable message id (and JetStream dedup key). @@ -103,6 +105,7 @@ pub struct NatsJetStreamSource { consumer: Consumer, fetch_timeout: Duration, strip_prefix: Option, + idle_poll: Duration, } impl NatsJetStreamSource { @@ -112,6 +115,7 @@ impl NatsJetStreamSource { consumer, fetch_timeout: Duration::from_millis(500), strip_prefix: None, + idle_poll: Duration::ZERO, } } @@ -132,6 +136,12 @@ impl NatsJetStreamSource { self } + /// Keep `recv` retrying after an empty fetch instead of draining to idle. + pub fn with_idle_poll(mut self, idle_poll: Duration) -> Self { + self.idle_poll = idle_poll; + self + } + /// Connect to a NATS server URL, then create/open the stream + consumer. pub async fn connect( url: &str, @@ -186,22 +196,27 @@ impl MessageSource for NatsJetStreamSource { } async fn recv(&mut self) -> Result, TransportError> { - let mut batch = self - .consumer - .batch() - .max_messages(1) - .expires(self.fetch_timeout) - .messages() - .await - .map_err(|err| retryable("nats fetch", err))?; - - match batch.next().await { - Some(Ok(message)) => Ok(Some(NatsReceived::from_jetstream( - message, - self.strip_prefix.as_deref(), - ))), - Some(Err(err)) => Err(retryable("nats batch message", err)), - None => Ok(None), + loop { + let mut batch = self + .consumer + .batch() + .max_messages(1) + .expires(self.fetch_timeout) + .messages() + .await + .map_err(|err| retryable("nats fetch", err))?; + + match batch.next().await { + Some(Ok(message)) => { + return Ok(Some(NatsReceived::from_jetstream( + message, + self.strip_prefix.as_deref(), + ))) + } + Some(Err(err)) => return Err(retryable("nats batch message", err)), + None if self.idle_poll.is_zero() => return Ok(None), + None => continue, + } } } } @@ -210,6 +225,7 @@ impl MessageSource for NatsJetStreamSource { pub struct NatsReceived { raw: jetstream::Message, message: Message, + ordered: Option, } impl NatsReceived { @@ -234,7 +250,12 @@ impl NatsReceived { MESSAGE_KIND_HEADER, headers, ); - Self { raw, message } + let ordered = jetstream_ordered(&raw); + Self { + raw, + message, + ordered, + } } async fn settle(self, kind: AckKind) -> Result<(), TransportError> { @@ -253,11 +274,22 @@ impl NatsReceived { } } +fn jetstream_ordered(raw: &jetstream::Message) -> Option { + let info = raw.info().ok()?; + let source = ProjectionSource::new("nats.jetstream", info.stream.as_bytes()).ok()?; + let epoch = ProjectionEpoch::new(format!("nats.{}", info.stream)).ok()?; + OrderedDelivery::new(source, epoch, info.stream_sequence, false).ok() +} + impl ReceivedMessage for NatsReceived { fn message(&self) -> &Message { &self.message } + fn ordered_delivery(&self) -> Option<&OrderedDelivery> { + self.ordered.as_ref() + } + async fn ack(self) -> Result<(), TransportError> { self.settle(AckKind::Ack).await } diff --git a/src/bus/nats_bus.rs b/src/bus/nats_bus.rs index c9ae5d5c..020ab981 100644 --- a/src/bus/nats_bus.rs +++ b/src/bus/nats_bus.rs @@ -44,6 +44,7 @@ pub struct NatsBus { evt_publisher: Arc, topology: BusTopologyConfig, fetch_timeout: Duration, + idle_poll: Duration, } /// Awaitable builder returned by [`NatsBus::connect`]. @@ -107,6 +108,7 @@ impl NatsBus { evt_publisher: Arc::new(evt_publisher), topology: BusTopologyConfig::default(), fetch_timeout: DEFAULT_FETCH_TIMEOUT, + idle_poll: Duration::ZERO, } } @@ -162,6 +164,13 @@ impl NatsBus { self } + /// Keep `listen`/`subscribe` running after an empty JetStream fetch. + /// Drain-to-idle is for tests; long-running hosts must set this. + pub fn with_idle_poll(mut self, idle_poll: Duration) -> Self { + self.idle_poll = idle_poll; + self + } + /// Sanitize the group into a valid NATS consumer-name token. Consumer names /// cannot contain `.`, `*`, `>`, or whitespace, so map them to `_`. fn durable_base(group: &str) -> String { @@ -231,7 +240,8 @@ impl NatsBus { .map_err(|err| retryable("nats get_or_create_consumer", err))?; Ok(NatsJetStreamSource::new(consumer) .with_fetch_timeout(self.fetch_timeout) - .with_strip_prefix(strip_prefix)) + .with_strip_prefix(strip_prefix) + .with_idle_poll(self.idle_poll)) } /// Shared consume path for `listen` (commands) and `subscribe` (events): diff --git a/src/bus/sql_bus_common.rs b/src/bus/sql_bus_common.rs index 5c3a689e..e2d7fe39 100644 --- a/src/bus/sql_bus_common.rs +++ b/src/bus/sql_bus_common.rs @@ -354,6 +354,9 @@ pub struct SqlBus { topology: BusTopologyConfig, lease: Duration, source_epoch: Option, + /// When non-zero, empty `listen`/`subscribe` polls instead of draining to + /// idle. Long-running hosts need this; tests keep the default (zero). + idle_poll: Duration, } impl SqlBus { @@ -363,6 +366,7 @@ impl SqlBus { topology: BusTopologyConfig::default(), lease: DEFAULT_LEASE, source_epoch: None, + idle_poll: Duration::ZERO, } } @@ -392,6 +396,16 @@ impl SqlBus { self } + /// Keep `listen`/`subscribe` running when the queue or log is empty. + /// + /// Drain-to-idle (`Duration::ZERO`, the default) is for tests. A playground + /// or worker that rebuilds `Service` after every idle drain pays seconds + /// of projector bootstrap on the next Eventual command. + pub fn with_idle_poll(mut self, idle_poll: Duration) -> Self { + self.idle_poll = idle_poll; + self + } + /// Create the bus tables (queue, log, log identity, and offsets) if absent. /// /// Called by `listen`/`subscribe`; producers must ensure the tables exist @@ -472,6 +486,7 @@ impl BusConsumer for SqlBus { names, lease_secs: self.lease.as_secs_f64(), buffer: VecDeque::new(), + idle_poll: self.idle_poll, }; run_source(router, source, options).await } @@ -502,6 +517,7 @@ impl BusConsumer for SqlBus { last_delivered: None, settled_seq: Arc::new(AtomicI64::new(0)), source_epoch, + idle_poll: self.idle_poll, }; run_source(router, source, options).await } @@ -520,6 +536,7 @@ struct SqlQueueSource { names: Vec, lease_secs: f64, buffer: VecDeque, + idle_poll: Duration, } impl MessageSource for SqlQueueSource { @@ -530,20 +547,28 @@ impl MessageSource for SqlQueueSource { } async fn recv(&mut self) -> Result, TransportError> { - if self.buffer.is_empty() { - let mut claimed = self - .dialect - .claim(&self.names, self.lease_secs, SOURCE_BATCH) - .await?; - // `UPDATE … RETURNING` row order is unspecified; restore seq order. - claimed.sort_by_key(|claim| claim.row.seq); - self.buffer.extend(claimed); + loop { + if self.buffer.is_empty() { + let mut claimed = self + .dialect + .claim(&self.names, self.lease_secs, SOURCE_BATCH) + .await?; + // `UPDATE … RETURNING` row order is unspecified; restore seq order. + claimed.sort_by_key(|claim| claim.row.seq); + self.buffer.extend(claimed); + } + if let Some(claimed) = self.buffer.pop_front() { + return Ok(Some(SqlQueueReceived { + dialect: self.dialect.clone(), + row: claimed.row, + claim_token: claimed.claim_token, + })); + } + if self.idle_poll.is_zero() { + return Ok(None); + } + tokio::time::sleep(self.idle_poll).await; } - Ok(self.buffer.pop_front().map(|claimed| SqlQueueReceived { - dialect: self.dialect.clone(), - row: claimed.row, - claim_token: claimed.claim_token, - })) } } @@ -612,6 +637,7 @@ struct SqlLogSource { /// Highest `seq` settled forward by this source's handles. settled_seq: Arc, source_epoch: ProjectionEpoch, + idle_poll: Duration, } impl MessageSource for SqlLogSource { @@ -633,42 +659,48 @@ impl MessageSource for SqlLogSource { self.buffer.clear(); } } - if self.buffer.is_empty() { - let rows = self - .dialect - .log_read( - &self.names, - &self.consumer, - SOURCE_BATCH, - &self.source_epoch, + loop { + if self.buffer.is_empty() { + let rows = self + .dialect + .log_read( + &self.names, + &self.consumer, + SOURCE_BATCH, + &self.source_epoch, + ) + .await?; + self.buffer.extend(rows); + } + let Some(row) = self.buffer.pop_front() else { + if self.idle_poll.is_zero() { + return Ok(None); + } + tokio::time::sleep(self.idle_poll).await; + continue; + }; + let position = u64::try_from(row.seq).map_err(|_| { + corrupt_row( + B::BACKEND, + format!( + "bus_log seq {} is outside the projection cursor domain", + row.seq + ), ) - .await?; - self.buffer.extend(rows); + })?; + let source = ProjectionSource::new(format!("{}.bus_log", B::BACKEND), b"global".to_vec()) + .map_err(|error| corrupt_row(B::BACKEND, error.to_string()))?; + let ordered = OrderedDelivery::new(source, self.source_epoch.clone(), position, false) + .map_err(|error| corrupt_row(B::BACKEND, error.to_string()))?; + self.last_delivered = Some(row.seq); + return Ok(Some(SqlLogReceived { + dialect: self.dialect.clone(), + consumer: self.consumer.clone(), + settled_seq: self.settled_seq.clone(), + row, + ordered, + })); } - let Some(row) = self.buffer.pop_front() else { - return Ok(None); - }; - let position = u64::try_from(row.seq).map_err(|_| { - corrupt_row( - B::BACKEND, - format!( - "bus_log seq {} is outside the projection cursor domain", - row.seq - ), - ) - })?; - let source = ProjectionSource::new(format!("{}.bus_log", B::BACKEND), b"global".to_vec()) - .map_err(|error| corrupt_row(B::BACKEND, error.to_string()))?; - let ordered = OrderedDelivery::new(source, self.source_epoch.clone(), position, false) - .map_err(|error| corrupt_row(B::BACKEND, error.to_string()))?; - self.last_delivered = Some(row.seq); - Ok(Some(SqlLogReceived { - dialect: self.dialect.clone(), - consumer: self.consumer.clone(), - settled_seq: self.settled_seq.clone(), - row, - ordered, - })) } } diff --git a/src/command_dispatch/host.rs b/src/command_dispatch/host.rs new file mode 100644 index 00000000..2eed4f9d --- /dev/null +++ b/src/command_dispatch/host.rs @@ -0,0 +1,505 @@ +//! Causal wait-path host used by GraphQL. Local in-process or HTTP loopback. + +use async_trait::async_trait; +use reqwest::redirect::Policy; +use serde_json::Value; +use std::sync::Arc; +use std::time::Duration; + +use crate::graphql::identity::VerifiedPrincipal; +use crate::graphql::protocol::ProtocolResponseAccumulator; +use crate::microsvc::cell_host::{ + InternalHttpSecret, CELL_INTERNAL_SECRET_HEADER, CELL_PRINCIPAL_PARTITION_HEADER, + CELL_SERVICE_ID_HEADER, +}; +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; + +pub(crate) fn validate_principal_session( + session: &Session, + principal: &VerifiedPrincipal, +) -> Result<(), CausalDispatchError> { + 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 an authenticated session subject".into(), + })?; + if !principal.subject_matches(subject) { + return Err(CausalDispatchError::Rejected { + code: "UNAUTHORIZED", + status: 401, + message: "session subject does not match the verified principal".into(), + }); + } + Ok(()) +} + +pub(crate) fn validate_principal_session_if_present( + session: &Session, + principal: &VerifiedPrincipal, +) -> Result<(), CausalDispatchError> { + match session + .user_id() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(subject) if principal.subject_matches(subject) => Ok(()), + Some(_) => Err(CausalDispatchError::Rejected { + code: "UNAUTHORIZED", + status: 401, + message: "session subject does not match the verified principal".into(), + }), + None => Ok(()), + } +} + +/// 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 { + validate_principal_session(&session, &principal)?; + 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 { + validate_principal_session_if_present(session, &principal)?; + 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 }`). +#[derive(Clone)] +pub struct HttpCommandHost { + base: reqwest::Url, + client: reqwest::Client, + internal_secret: Option, +} + +impl HttpCommandHost { + const MAX_JSON_BYTES: usize = 2 * 1024 * 1024; + + pub fn new(base: impl AsRef) -> Result { + Self::build(base.as_ref(), None) + } + + pub fn new_internal( + base: impl AsRef, + secret: InternalHttpSecret, + ) -> Result { + Self::build(base.as_ref(), Some(secret)) + } + + fn build( + base: &str, + internal_secret: Option, + ) -> Result { + let base = reqwest::Url::parse(base.trim_end_matches('/')).map_err(|error| { + CausalDispatchError::Internal(format!("invalid wait-path base URL: {error}")) + })?; + if !matches!(base.scheme(), "http" | "https") + || !base.username().is_empty() + || base.password().is_some() + || base.query().is_some() + || base.fragment().is_some() + || base.host_str().is_none() + { + return Err(CausalDispatchError::Internal( + "wait-path base URL must be http(s) with a host and without credentials, query, or fragment".into(), + )); + } + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(10)) + .redirect(Policy::none()) + .build() + .map_err(|error| { + CausalDispatchError::Internal(format!("wait-path HTTP client: {error}")) + })?; + Ok(Self { + base, + client, + internal_secret, + }) + } + + /// Same connection pool, different wait-path base (`{celld}/{shard}`). + /// `Client::new()` loads TLS roots; do not construct one per command. + pub fn retarget_segments(&self, segments: &[&str]) -> Result { + let mut base = self.base.clone(); + { + let mut path = base.path_segments_mut().map_err(|_| { + CausalDispatchError::Internal( + "wait-path base URL cannot contain path segments".into(), + ) + })?; + path.pop_if_empty(); + for segment in segments { + if segment.is_empty() { + return Err(CausalDispatchError::Internal( + "wait-path URL segment must not be empty".into(), + )); + } + path.push(segment); + } + } + Ok(Self { + base, + client: self.client.clone(), + internal_secret: self.internal_secret.clone(), + }) + } + + pub fn base(&self) -> &str { + self.base.as_str() + } + + fn request_url(&self, segment: &str) -> Result { + if segment.is_empty() { + return Err(CausalDispatchError::Internal( + "wait-path URL segment must not be empty".into(), + )); + } + let mut url = self.base.clone(); + url.path_segments_mut() + .map_err(|_| { + CausalDispatchError::Internal("wait-path URL cannot contain path segments".into()) + })? + .pop_if_empty() + .push(segment); + Ok(url) + } + + fn request_json( + &self, + segment: &str, + body: &Value, + ) -> Result { + let encoded = serde_json::to_vec(body).map_err(|error| { + CausalDispatchError::Internal(format!("wait-path request JSON: {error}")) + })?; + if encoded.len() > Self::MAX_JSON_BYTES { + return Err(CausalDispatchError::BadRequest( + "wait-path request exceeds 2 MiB".into(), + )); + } + let mut request = self + .client + .post(self.request_url(segment)?) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(encoded); + if let Some(secret) = &self.internal_secret { + request = request.header(CELL_INTERNAL_SECRET_HEADER, secret.header_value()); + } + Ok(request) + } + + async fn response_json( + mut response: reqwest::Response, + ) -> Result<(u16, Value), CausalDispatchError> { + let status = response.status().as_u16(); + if response + .content_length() + .is_some_and(|length| length > Self::MAX_JSON_BYTES as u64) + { + return Err(CausalDispatchError::Internal( + "wait-path response exceeds 2 MiB".into(), + )); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|error| { + CausalDispatchError::Internal(format!("wait-path HTTP body: {error}")) + })? { + if bytes.len().saturating_add(chunk.len()) > Self::MAX_JSON_BYTES { + return Err(CausalDispatchError::Internal( + "wait-path response exceeds 2 MiB".into(), + )); + } + bytes.extend_from_slice(&chunk); + } + let body = serde_json::from_slice(&bytes).map_err(|error| { + CausalDispatchError::Internal(format!("wait-path HTTP body is not valid JSON: {error}")) + })?; + Ok((status, body)) + } + + /// POST `{base}/{path}` with a JSON body (cell `outbox.complete`, alarms). + pub async fn post_json( + &self, + path: &str, + body: Value, + ) -> Result<(u16, Value), CausalDispatchError> { + let response = self + .request_json(path, &body)? + .send() + .await + .map_err(|err| CausalDispatchError::Internal(format!("cell HTTP failed: {err}")))?; + Self::response_json(response).await + } + + /// Authenticated GET of one encoded path segment with the same transport bounds. + pub async fn get_json(&self, path_segment: &str) -> Result<(u16, Value), CausalDispatchError> { + let mut request = self.client.get(self.request_url(path_segment)?); + if let Some(secret) = &self.internal_secret { + request = request.header(CELL_INTERNAL_SECRET_HEADER, secret.header_value()); + } + let response = request + .send() + .await + .map_err(|error| CausalDispatchError::Internal(format!("cell HTTP failed: {error}")))?; + Self::response_json(response).await + } + + /// POST `{base}/{command}` and return status + JSON, including 4xx with + /// cell `outbox` for drain retries. + pub async fn post_wait_path( + &self, + command: &str, + command_id: &str, + input: Value, + session: &Session, + ) -> Result<(u16, Value), CausalDispatchError> { + self.post_wait_path_inner(command, command_id, input, session, None) + .await + } + + /// POST a cell wait-path command with identity derived by the verified + /// GraphQL host. These headers are part of the trusted internal boundary, + /// not values copied from public request headers or command input. + pub async fn post_cell_wait_path( + &self, + command: &str, + command_id: &str, + input: Value, + session: &Session, + service_id: &str, + principal_partition: &str, + ) -> Result<(u16, Value), CausalDispatchError> { + self.post_wait_path_inner( + command, + command_id, + input, + session, + Some((service_id, principal_partition)), + ) + .await + } + + async fn post_wait_path_inner( + &self, + command: &str, + command_id: &str, + input: Value, + session: &Session, + cell_identity: Option<(&str, &str)>, + ) -> Result<(u16, Value), CausalDispatchError> { + let mut request = self.request_json( + command, + &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); + } + if let Some((service_id, principal_partition)) = cell_identity { + if self.internal_secret.is_none() { + return Err(CausalDispatchError::Internal( + "cell wait-path requires an internal HTTP secret".into(), + )); + } + request = request + .header(CELL_SERVICE_ID_HEADER, service_id) + .header(CELL_PRINCIPAL_PARTITION_HEADER, principal_partition); + } + let response = request.send().await.map_err(|err| { + CausalDispatchError::Internal(format!("wait-path HTTP failed: {err}")) + })?; + Self::response_json(response).await + } +} + +#[async_trait] +impl CommandHost for HttpCommandHost { + async fn invoke( + &self, + command: &str, + command_id: &str, + input: Value, + session: Session, + principal: VerifiedPrincipal, + _protocol: Option, + ) -> Result { + validate_principal_session(&session, &principal)?; + let (status, body) = self + .post_wait_path(command, command_id, input, &session) + .await?; + 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 { + validate_principal_session_if_present(session, &principal)?; + 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 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_host_rejects_unsafe_base_urls() { + assert!(HttpCommandHost::new("ftp://writer.example").is_err()); + assert!(HttpCommandHost::new("https://user:pass@writer.example").is_err()); + assert!(HttpCommandHost::new("https://writer.example/path?secret=value").is_err()); + assert!(HttpCommandHost::new("https://writer.example/path#fragment").is_err()); + assert!(HttpCommandHost::new("https://writer.example/path").is_ok()); + } + + #[test] + fn verified_principal_must_match_any_session_subject() { + let principal = + VerifiedPrincipal::test_oidc("https://issuer.example", "alice", &["distributed-tests"]); + let mut session = Session::new(); + session.set(USER_ID_KEY, "mallory"); + assert!(validate_principal_session(&session, &principal).is_err()); + assert!(validate_principal_session_if_present(&session, &principal).is_err()); + + session.set(USER_ID_KEY, "alice"); + assert!(validate_principal_session(&session, &principal).is_ok()); + } +} diff --git a/src/command_dispatch/mod.rs b/src/command_dispatch/mod.rs index 2fbffe3d..05098cb9 100644 --- a/src/command_dispatch/mod.rs +++ b/src/command_dispatch/mod.rs @@ -6,6 +6,8 @@ mod envelope; mod error; +#[cfg(feature = "graphql")] +mod host; mod local; mod remote; @@ -13,9 +15,14 @@ pub use envelope::{ CommandDispatchEnvelope, CommandDispatchReceipt, COMMAND_DISPATCH_ENVELOPE_VERSION, }; pub use error::CommandDispatchError; +#[cfg(feature = "graphql")] +pub(crate) use host::{validate_principal_session, validate_principal_session_if_present}; +#[cfg(feature = "graphql")] +pub use host::{CommandHost, HttpCommandHost, LocalCommandHost, SharedCommandHost}; pub use local::LocalCommandDispatcher; pub use remote::{ - RemoteCommandDispatcher, RemoteDispatchConfig, RemoteTrustMode, APPROVED_REMOTE_DISPATCH_PROFILE, + RemoteCommandDispatcher, RemoteDispatchConfig, RemoteTrustMode, + APPROVED_REMOTE_DISPATCH_PROFILE, }; use crate::microsvc::{CommandRequest, CommandResponse}; diff --git a/src/command_dispatch/remote.rs b/src/command_dispatch/remote.rs index 89bdb68a..ef43f5e7 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/command_ledger/record.rs b/src/command_ledger/record.rs index 6f62f15c..8ccc9629 100644 --- a/src/command_ledger/record.rs +++ b/src/command_ledger/record.rs @@ -1,7 +1,8 @@ -use std::time::{Duration, SystemTime}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::projection_protocol::{ResolvedProjectionObligation, SameTransactionProjectionEvidence}; @@ -62,6 +63,113 @@ impl CommandLedgerRecord { }) } + /// Stable opaque key used by a cell host's private SQLite table. + pub(crate) fn durable_cell_key(&self) -> String { + let material = format!( + "{}\0{}\0{}", + self.key.service_id(), + self.key.principal_partition(), + self.key.command_id() + ); + format!("v1.{}", URL_SAFE_NO_PAD.encode(material)) + } + + /// Versioned cell-storage representation. This is deliberately separate + /// from the public command receipt and retains the complete fenced row. + pub(crate) fn durable_cell_json(&self) -> Result { + self.validate_stored_shape()?; + let wire = DurableCellCommandRecordV1 { + version: 1, + service_id: self.key.service_id().to_string(), + principal_partition: self.key.principal_partition().to_string(), + command_id: self.key.command_id().to_string(), + command_name: self.command_name.clone(), + contract_fingerprint: self.contract_fingerprint.as_bytes().to_vec(), + input_hash: self.input_hash.as_bytes().to_vec(), + state: self.state.as_str().to_string(), + causation_id: self.causation_id.as_str().to_string(), + attempt_token: self + .attempt_token + .as_ref() + .map(|token| token.as_str().to_string()), + attempt_number: self.attempt_number, + lease_expires_at_ms: self + .lease_expires_at + .map(system_time_to_unix_millis) + .transpose()?, + outcome_json: self.outcome_json.clone(), + created_at_ms: system_time_to_unix_millis(self.created_at)?, + updated_at_ms: system_time_to_unix_millis(self.updated_at)?, + completed_at_ms: self + .completed_at + .map(system_time_to_unix_millis) + .transpose()?, + retention_expires_at_ms: system_time_to_unix_millis(self.retention_expires_at)?, + compacted_at_ms: self + .compacted_at + .map(system_time_to_unix_millis) + .transpose()?, + }; + serde_json::to_string(&wire).map_err(|error| { + CommandLedgerError::Corrupt(format!( + "cell command ledger row could not be encoded: {error}" + )) + }) + } + + pub(crate) fn from_durable_cell_json(body: &str) -> Result { + let wire: DurableCellCommandRecordV1 = serde_json::from_str(body).map_err(|error| { + CommandLedgerError::Corrupt(format!("cell command ledger row is invalid JSON: {error}")) + })?; + if wire.version != 1 { + return Err(CommandLedgerError::Corrupt(format!( + "cell command ledger row version `{}` is unsupported", + wire.version + ))); + } + let key = CommandLedgerKey::new( + wire.service_id, + super::PrincipalPartitionId::new(wire.principal_partition)?, + super::CommandId::parse(wire.command_id)?, + )?; + let record = Self { + key, + command_name: wire.command_name, + contract_fingerprint: CommandContractFingerprint::try_from_slice( + &wire.contract_fingerprint, + )?, + input_hash: CanonicalInputHash::try_from_slice(&wire.input_hash)?, + state: CommandLedgerState::parse(&wire.state)?, + causation_id: CausationId::parse_stored(wire.causation_id)?, + attempt_token: wire + .attempt_token + .map(AttemptToken::parse_stored) + .transpose()?, + attempt_number: wire.attempt_number, + lease_expires_at: wire + .lease_expires_at_ms + .map(system_time_from_unix_millis) + .transpose()?, + outcome_json: wire.outcome_json, + created_at: system_time_from_unix_millis(wire.created_at_ms)?, + updated_at: system_time_from_unix_millis(wire.updated_at_ms)?, + completed_at: wire + .completed_at_ms + .map(system_time_from_unix_millis) + .transpose()?, + retention_expires_at: system_time_from_unix_millis(wire.retention_expires_at_ms)?, + compacted_at: wire + .compacted_at_ms + .map(system_time_from_unix_millis) + .transpose()?, + }; + record.validate_stored_shape()?; + if record.state.is_replayable() { + record.replay()?; + } + Ok(record) + } + pub(crate) fn acquired_attempt(&self) -> Result { let token = self.attempt_token.as_ref().ok_or_else(|| { CommandLedgerError::Corrupt(format!( @@ -499,6 +607,51 @@ impl CommandLedgerRecord { } } +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct DurableCellCommandRecordV1 { + version: u16, + service_id: String, + principal_partition: String, + command_id: String, + command_name: String, + contract_fingerprint: Vec, + input_hash: Vec, + state: String, + causation_id: String, + attempt_token: Option, + attempt_number: u64, + lease_expires_at_ms: Option, + outcome_json: Option, + created_at_ms: u64, + updated_at_ms: u64, + completed_at_ms: Option, + retention_expires_at_ms: u64, + compacted_at_ms: Option, +} + +fn system_time_to_unix_millis(value: SystemTime) -> Result { + let millis = value + .duration_since(UNIX_EPOCH) + .map_err(|_| { + CommandLedgerError::Corrupt( + "cell command ledger timestamp precedes the Unix epoch".into(), + ) + })? + .as_millis(); + u64::try_from(millis).map_err(|_| { + CommandLedgerError::Corrupt("cell command ledger timestamp exceeds u64 millis".into()) + }) +} + +fn system_time_from_unix_millis(value: u64) -> Result { + UNIX_EPOCH + .checked_add(Duration::from_millis(value)) + .ok_or_else(|| { + CommandLedgerError::Corrupt("cell command ledger timestamp is out of range".into()) + }) +} + fn checked_deadline( now: SystemTime, duration: Duration, diff --git a/src/entity/entity.rs b/src/entity/entity.rs index 1c0fdff3..c435ef88 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 604962c4..8b90fae9 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/compile/mod.rs b/src/graphql/compile/mod.rs index 86361afb..d4ce551f 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)] @@ -36,3 +37,4 @@ pub(crate) use dialect::{ }; #[allow(unused_imports)] pub(crate) use evidence::{ExtractedQueryEvidence, QueryRecordEvidence, QueryResponsePathSegment}; +pub(crate) use projection::cell_row_matches; diff --git a/src/graphql/compile/projection.rs b/src/graphql/compile/projection.rs index d87fbbe2..d277d743 100644 --- a/src/graphql/compile/projection.rs +++ b/src/graphql/compile/projection.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use std::collections::BTreeMap; use async_graphql::Value; @@ -7,9 +8,10 @@ use crate::microsvc::Session; use crate::table::{ColumnType, TableSchema}; use super::super::engine::EngineInner; +use super::super::filter::{CmpOp, FilterExpr, LitValue, Operand}; use super::super::naming::{is_valid_graphql_name, scalar_type_name}; use super::super::permissions::ReadPermission; -use super::binds::{value_to_bind, BindValue}; +use super::binds::{operand_to_bind, value_to_bind, BindValue}; use super::dialect::{placeholder, SqlDialect}; use super::evidence::{ ExtractedQueryEvidence, QueryEvidenceFieldPlan, QueryEvidenceKeyPlan, QueryEvidenceNode, @@ -56,6 +58,444 @@ 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, + /// Role row policy with every claim resolved from the trusted session. + row_filter: Option, + }, +} + +/// 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, session, role, model_name, kind, selection) + } + } +} + +fn compile_cell_by_key( + inner: &EngineInner, + session: &Session, + role: &str, + model_name: &str, + kind: RootKind, + selection: &SelectionNode, +) -> Result { + let entry = inner + .catalog + .get(model_name) + .ok_or_else(|| format!("unknown model `{model_name}`"))?; + let permission = inner + .permissions + .get(&(model_name.to_string(), role.to_string())) + .map(|entry| &entry.permission) + .ok_or_else(|| format!("role `{role}` has no permission on `{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 row_filter = permission + .row_filter + .as_ref() + .map(|filter| resolve_cell_row_filter(&entry.schema, session, filter)) + .transpose()?; + 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, + row_filter, + }) +} + +/// Resolve a cell row policy before the remote GET so missing or malformed +/// claims cannot turn row existence into an authorization side channel. +fn resolve_cell_row_filter( + schema: &TableSchema, + session: &Session, + filter: &FilterExpr, +) -> Result { + Ok(match filter { + FilterExpr::And(items) => FilterExpr::And( + items + .iter() + .map(|item| resolve_cell_row_filter(schema, session, item)) + .collect::>()?, + ), + FilterExpr::Or(items) => FilterExpr::Or( + items + .iter() + .map(|item| resolve_cell_row_filter(schema, session, item)) + .collect::>()?, + ), + FilterExpr::Not(item) => { + FilterExpr::Not(Box::new(resolve_cell_row_filter(schema, session, item)?)) + } + FilterExpr::Cmp { column, op, rhs } => { + let column_schema = cell_policy_column(schema, column)?; + match op { + CmpOp::Eq | CmpOp::Neq + if matches!( + column_schema.column_type, + ColumnType::Text + | ColumnType::Timestamp + | ColumnType::Boolean + | ColumnType::Integer + | ColumnType::UnsignedInteger + | ColumnType::Float + ) => {} + CmpOp::Gt | CmpOp::Gte | CmpOp::Lt | CmpOp::Lte + if matches!( + column_schema.column_type, + ColumnType::Integer | ColumnType::UnsignedInteger | ColumnType::Float + ) => {} + _ => { + return Err(format!( + "cell-by-key row policy operator `{op:?}` is unsupported for column `{column}`" + )); + } + } + FilterExpr::Cmp { + column: column.clone(), + op: *op, + rhs: resolve_cell_operand(rhs, session, &column_schema.column_type)?, + } + } + FilterExpr::In { + column, + values, + negated, + } => { + let column_schema = cell_policy_column(schema, column)?; + if !matches!( + column_schema.column_type, + ColumnType::Text + | ColumnType::Timestamp + | ColumnType::Boolean + | ColumnType::Integer + | ColumnType::UnsignedInteger + | ColumnType::Float + ) { + return Err(format!( + "cell-by-key row policy IN is unsupported for column `{column}`" + )); + } + FilterExpr::In { + column: column.clone(), + values: values + .iter() + .map(|value| resolve_cell_operand(value, session, &column_schema.column_type)) + .collect::>()?, + negated: *negated, + } + } + FilterExpr::IsNull { column, is_null } => { + cell_policy_column(schema, column)?; + FilterExpr::IsNull { + column: column.clone(), + is_null: *is_null, + } + } + FilterExpr::Rel { field, .. } => { + return Err(format!( + "cell-by-key row policy cannot traverse relationship `{field}`" + )); + } + }) +} + +fn cell_policy_column<'a>( + schema: &'a TableSchema, + column: &str, +) -> Result<&'a crate::table::TableColumn, String> { + schema + .columns + .iter() + .find(|candidate| candidate.column_name == column) + .ok_or_else(|| format!("unknown cell row-policy column `{column}`")) +} + +fn resolve_cell_operand( + operand: &Operand, + session: &Session, + column_type: &ColumnType, +) -> Result { + let literal = match operand_to_bind(operand, session, column_type)? { + BindValue::Null => LitValue::Null, + BindValue::Bool(value) => LitValue::Bool(value), + BindValue::I64(value) => LitValue::I64(value), + BindValue::F64(value) if value.is_finite() => LitValue::F64(value), + BindValue::F64(value) => { + return Err(format!( + "cell-by-key row-policy float `{value}` must be finite" + )); + } + BindValue::Text(value) => LitValue::String(value), + BindValue::Json(value) => LitValue::Json(value), + BindValue::Bytes(_) => { + return Err("cell-by-key row policies do not support byte operands".into()); + } + }; + Ok(Operand::Lit(literal)) +} + +/// Apply the already-resolved scalar policy to a sealed cell row. Only an +/// exact SQL-style TRUE authorizes the row; FALSE, NULL/unknown, malformed +/// fields, and unsupported material all fail closed. +pub(crate) fn cell_row_matches(schema: &TableSchema, filter: &FilterExpr, row: &JsonValue) -> bool { + let JsonValue::Object(row) = row else { + return false; + }; + matches!(evaluate_cell_filter(schema, filter, row), CellTruth::True) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CellTruth { + True, + False, + Unknown, +} + +impl CellTruth { + fn not(self) -> Self { + match self { + Self::True => Self::False, + Self::False => Self::True, + Self::Unknown => Self::Unknown, + } + } +} + +fn evaluate_cell_filter( + schema: &TableSchema, + filter: &FilterExpr, + row: &serde_json::Map, +) -> CellTruth { + match filter { + FilterExpr::And(items) => { + let mut result = CellTruth::True; + for item in items { + match evaluate_cell_filter(schema, item, row) { + CellTruth::False => return CellTruth::False, + CellTruth::Unknown => result = CellTruth::Unknown, + CellTruth::True => {} + } + } + result + } + FilterExpr::Or(items) => { + let mut result = CellTruth::False; + for item in items { + match evaluate_cell_filter(schema, item, row) { + CellTruth::True => return CellTruth::True, + CellTruth::Unknown => result = CellTruth::Unknown, + CellTruth::False => {} + } + } + result + } + FilterExpr::Not(item) => evaluate_cell_filter(schema, item, row).not(), + FilterExpr::Cmp { column, op, rhs } => { + let Some((column_type, left)) = cell_row_value(schema, row, column) else { + return CellTruth::Unknown; + }; + let Operand::Lit(right) = rhs else { + return CellTruth::Unknown; + }; + evaluate_cell_comparison(&column_type, left, *op, right) + } + FilterExpr::In { + column, + values, + negated, + } => { + if values.is_empty() { + return if *negated { + CellTruth::True + } else { + CellTruth::False + }; + } + let Some((column_type, left)) = cell_row_value(schema, row, column) else { + return CellTruth::Unknown; + }; + let mut unknown = false; + for value in values { + let Operand::Lit(right) = value else { + unknown = true; + continue; + }; + match cell_values_equal(&column_type, left, right) { + Some(true) => { + return if *negated { + CellTruth::False + } else { + CellTruth::True + }; + } + Some(false) => {} + None => unknown = true, + } + } + if unknown { + CellTruth::Unknown + } else if *negated { + CellTruth::True + } else { + CellTruth::False + } + } + FilterExpr::IsNull { column, is_null } => { + let Some((_, value)) = cell_row_value(schema, row, column) else { + return CellTruth::Unknown; + }; + if value.is_null() == *is_null { + CellTruth::True + } else { + CellTruth::False + } + } + FilterExpr::Rel { .. } => CellTruth::Unknown, + } +} + +fn cell_row_value<'a>( + schema: &TableSchema, + row: &'a serde_json::Map, + column: &str, +) -> Option<(ColumnType, &'a JsonValue)> { + let column_schema = schema + .columns + .iter() + .find(|candidate| candidate.column_name == column)?; + let value = row + .get(&column_schema.field_name) + .or_else(|| row.get(&column_schema.column_name))?; + Some((column_schema.column_type.clone(), value)) +} + +fn evaluate_cell_comparison( + column_type: &ColumnType, + left: &JsonValue, + op: CmpOp, + right: &LitValue, +) -> CellTruth { + let matched = match op { + CmpOp::Eq => cell_values_equal(column_type, left, right), + CmpOp::Neq => cell_values_equal(column_type, left, right).map(|equal| !equal), + CmpOp::Gt => cell_values_order(column_type, left, right).map(|order| order.is_gt()), + CmpOp::Gte => cell_values_order(column_type, left, right).map(|order| order.is_ge()), + CmpOp::Lt => cell_values_order(column_type, left, right).map(|order| order.is_lt()), + CmpOp::Lte => cell_values_order(column_type, left, right).map(|order| order.is_le()), + CmpOp::Like | CmpOp::Ilike | CmpOp::Contains | CmpOp::ContainedIn | CmpOp::HasKey => None, + }; + match matched { + Some(true) => CellTruth::True, + Some(false) => CellTruth::False, + None => CellTruth::Unknown, + } +} + +fn cell_values_equal(column_type: &ColumnType, left: &JsonValue, right: &LitValue) -> Option { + if left.is_null() || matches!(right, LitValue::Null) { + return None; + } + Some(match (column_type, right) { + (ColumnType::Text | ColumnType::Timestamp, LitValue::String(right)) => { + left.as_str()? == right + } + (ColumnType::Boolean, LitValue::Bool(right)) => left.as_bool()? == *right, + (ColumnType::Integer, LitValue::I64(right)) => left.as_i64()? == *right, + (ColumnType::UnsignedInteger, LitValue::I64(right)) if *right >= 0 => { + left.as_u64()? == *right as u64 + } + (ColumnType::Float, LitValue::F64(right)) => left.as_f64()? == *right, + (ColumnType::Float, LitValue::I64(right)) => left.as_f64()? == *right as f64, + _ => return None, + }) +} + +fn cell_values_order( + column_type: &ColumnType, + left: &JsonValue, + right: &LitValue, +) -> Option { + match (column_type, right) { + (ColumnType::Integer, LitValue::I64(right)) => left.as_i64()?.partial_cmp(right), + (ColumnType::UnsignedInteger, LitValue::I64(right)) if *right >= 0 => { + left.as_u64()?.partial_cmp(&(*right as u64)) + } + (ColumnType::Float, LitValue::F64(right)) => left.as_f64()?.partial_cmp(right), + (ColumnType::Float, LitValue::I64(right)) => left.as_f64()?.partial_cmp(&(*right as f64)), + _ => None, + } +} + /// 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 95cc8197..a5aa250a 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 0ea7e051..472db32d 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/engine/request.rs b/src/graphql/engine/request.rs index 949376c0..f3eb5a98 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/engine/tests.rs b/src/graphql/engine/tests.rs index 43fe6104..d8ccbfec 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 1b22be81..4a5e1ea0 100644 --- a/src/graphql/http.rs +++ b/src/graphql/http.rs @@ -10,10 +10,11 @@ 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; +use crate::command_dispatch::{LocalCommandDispatcher, 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, } } @@ -204,52 +205,57 @@ 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}")); + graphql_router_with_host(engine, Arc::new(LocalCommandHost::new(service))) +} +/// GraphQL router whose mutations dispatch through a local +/// [`LocalCommandDispatcher`] as a [`crate::command_dispatch::CommandHost`]. +/// +/// 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, +) -> Router { + graphql_router_with_host(engine, dispatcher) +} + +/// GraphQL router that wait-dispatches through an explicit command host. +pub fn graphql_router_with_host(engine: Arc, host: SharedCommandHost) -> Router { let graphiql = engine.graphiql_enabled(); let state = GraphqlHttpState { engine, - service: Some(service), + 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) } -/// GraphQL router whose command mutations dispatch through a local -/// [`crate::command_dispatch::LocalCommandDispatcher`]. -/// -/// 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. -pub fn graphql_router_with_dispatcher( - engine: Arc, - dispatcher: Arc, -) -> Router { - graphql_router_with_service(engine, Arc::clone(dispatcher.service())) -} - #[derive(Clone)] struct GraphqlHttpState { engine: Arc, - service: Option>, + host: Option, } fn unauthorized_response() -> Response { @@ -301,8 +307,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 +334,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() } @@ -367,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; @@ -394,7 +422,7 @@ pub async fn microsvc_graphql_ws( Arc::clone(&engine), upgrade_session.clone(), upgrade_principal, - Some(Arc::clone(&service)), + host, ); let engine_for_init = Arc::clone(&engine); upgrade @@ -626,19 +654,38 @@ 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(&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 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(&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/identity/mod.rs b/src/graphql/identity/mod.rs index 47ad99f7..8d023a9a 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 0c9e7f5c..284567a2 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,19 @@ pub(crate) struct VerifiedPrincipal { } impl VerifiedPrincipal { - #[cfg(test)] - pub(crate) fn test_oidc(issuer: &str, subject: &str, audiences: &[&str]) -> Self { + /// 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!( !issuer.trim().is_empty(), "test OIDC issuer must not be empty" @@ -163,6 +175,10 @@ impl VerifiedPrincipal { &self.subject } + pub(crate) fn subject_matches(&self, subject: &str) -> bool { + self.subject == subject + } + /// Versioned, domain-separated partition for one exact service identity. pub(crate) fn partition_for_service(&self, service_id: &str) -> String { let mut audiences = self @@ -796,7 +812,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/mod.rs b/src/graphql/mod.rs index 7b5725a9..0710e856 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, @@ -71,7 +70,6 @@ pub use permissions::{ }; pub use types::{GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField}; -#[cfg(feature = "graphql")] pub(crate) mod command_input; #[cfg(feature = "graphql")] mod compile; @@ -86,10 +84,12 @@ 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")] +pub mod read_store; +#[cfg(feature = "graphql")] mod schema; #[cfg(feature = "graphql")] pub mod subscribe; @@ -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::{ @@ -108,7 +109,9 @@ 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 read_store::{CellByKeyGetter, HttpCellByKey, MapCellByKey, ReadStore}; +#[cfg(feature = "graphql")] pub use subscribe::ChangeHub; diff --git a/src/graphql/projection_delta/runtime.rs b/src/graphql/projection_delta/runtime.rs index 4f4d6bbc..3c271f7b 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/graphql/protocol/accumulator.rs b/src/graphql/protocol/accumulator.rs index 277ee4c0..aff28148 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/protocol/mod.rs b/src/graphql/protocol/mod.rs index 7bb04f9f..d70b8325 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/graphql/read_store.rs b/src/graphql/read_store.rs new file mode 100644 index 00000000..42c51110 --- /dev/null +++ b/src/graphql/read_store.rs @@ -0,0 +1,509 @@ +//! 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; + +use crate::command_dispatch::HttpCommandHost; +use crate::microsvc::cell_host::InternalHttpSecret; + +/// 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 { + http: HttpCommandHost, +} + +impl HttpCellByKey { + pub fn new(base: impl AsRef, internal_secret: InternalHttpSecret) -> Result { + Ok(Self { + http: HttpCommandHost::new_internal(base, internal_secret) + .map_err(|error| error.to_string())?, + }) + } +} + +#[async_trait] +impl CellByKeyGetter for HttpCellByKey { + async fn get_sealed_row( + &self, + primary_key: &BTreeMap, + ) -> Result, String> { + if primary_key.len() != 1 { + return Err("cell-by-key HTTP GET requires exactly one primary-key field".into()); + } + let id = primary_key.values().next().expect("length checked"); + let (status, body) = self + .http + .get_json(id) + .await + .map_err(|error| format!("cell GET failed: {error}"))?; + if status == 404 { + return Ok(None); + } + if !(200..300).contains(&status) { + return Err(format!("cell GET returned HTTP {status}")); + } + 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> { + if primary_key.len() != 1 { + return Err("cell-by-key map requires exactly one primary-key field".into()); + } + let id = primary_key.values().next().expect("length checked"); + 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() + .rows(col("owner_id").eq(claim("x-user-id"))), + ) + } + + 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_by_id_hides_cell_rows_outside_the_role_policy() { + let cells = MapCellByKey::new(); + cells.insert( + "game-bob", + json!({ "game_id": "game-bob", "owner_id": "bob", "score": 9 }), + ); + cells.insert( + "game-malformed", + json!({ "game_id": "game-malformed", "score": 10 }), + ); + let engine = GraphqlEngine::builder(pool()) + .roles(&["user"]) + .model::(blob_perms()) + .model::(user_perms()) + .read_store::(ReadStore::CellByKey(Arc::new(cells))) + .build() + .unwrap(); + + for game_id in ["game-bob", "game-malformed"] { + let response = engine + .execute( + &session_user(), + Request::new(format!( + "{{ blob_games_by_pk(game_id: \"{game_id}\") {{ game_id score }} }}" + )), + ) + .await; + assert!(response.errors.is_empty(), "{response:?}"); + let data = response.data.into_json().unwrap(); + assert!(data["blob_games_by_pk"].is_null(), "{data}"); + } + } + + #[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::extract::Path; + use axum::http::{HeaderMap, StatusCode}; + use axum::routing::get; + use axum::{Json, Router}; + + const SECRET: &str = "test-only-cell-by-key-secret-32-bytes"; + + 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(|headers: HeaderMap, Path(id): Path| async move { + if headers + .get(crate::microsvc::cell_host::CELL_INTERNAL_SECRET_HEADER) + .and_then(|value| value.to_str().ok()) + != Some(SECRET) + { + return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "no" }))); + } + (StatusCode::OK, Json(json!({ "game_id": id, "score": 3 }))) + }), + ); + axum::serve(listener, app).await.unwrap(); + }); + let getter = HttpCellByKey::new( + format!("http://{addr}/blob"), + InternalHttpSecret::new(SECRET).unwrap(), + ) + .unwrap(); + 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); + + pk.insert("tenant_id".into(), "tenant-1".into()); + assert!(getter.get_sealed_row(&pk).await.is_err()); + } +} diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index 0e69fc10..6e012a4f 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,52 @@ fn passthrough_key( Ok(lookup_key(value, key)) } +async fn execute_cell_by_key( + inner: &EngineInner, + model: &str, + pk: &BTreeMap, + row_filter: Option<&super::filter::FilterExpr>, + 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 row_object = row + .as_object() + .ok_or_else(|| "cell sealed row must be a JSON object".to_string())?; + for (key, expected) in pk { + if row_object.get(key).and_then(serde_json::Value::as_str) != Some(expected) { + return Err(format!( + "cell sealed row primary key {key} does not match request" + )); + } + } + if let Some(filter) = row_filter { + let schema = &inner + .catalog + .get(model) + .ok_or_else(|| format!("unknown model `{model}`"))? + .schema; + if !compile::cell_row_matches(schema, filter, &row) { + 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 +754,40 @@ 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) + let value = match plan { + QueryPlan::CellByKey { + model, + pk, + row_filter, + } => execute_cell_by_key(&inner, &model, &pk, row_filter.as_ref(), &selection) .await - .map_err(|e| client_error_for_execute_err(&e))? + .map_err(|_| client_error("INTERNAL", "cell read dependency failed"))?, + 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 +863,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 +990,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" + ); } } @@ -938,17 +1003,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 +1053,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 +1077,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 +1103,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 +1116,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()) @@ -1074,6 +1139,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, @@ -1268,7 +1334,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", diff --git a/src/graphql/subscribe.rs b/src/graphql/subscribe.rs index 3dca2fe4..4661084a 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); diff --git a/src/in_memory_repo/repository.rs b/src/in_memory_repo/repository.rs index 94498c50..defa9a17 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, @@ -137,6 +136,104 @@ 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(()) + } + + /// Clone outbox rows for Durable Object SQLite persistence. + pub fn clone_outbox(&self) -> Result, RepositoryError> { + Ok(self + .outbox_store + .read() + .map_err(|_| RepositoryError::LockPoisoned("outbox read"))? + .values() + .cloned() + .collect()) + } + + /// Replace outbox rows from Durable Object SQLite restore. + pub fn replace_outbox(&self, messages: Vec) -> Result<(), RepositoryError> { + let mut map = HashMap::new(); + for message in messages { + map.insert(message.id.clone(), message); + } + *self + .outbox_store + .write() + .map_err(|_| RepositoryError::LockPoisoned("outbox write"))? = map; + 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(()) + } + + pub(crate) fn clone_command_ledger(&self) -> Result, RepositoryError> { + Ok(self + .command_ledger + .read() + .map_err(|_| RepositoryError::LockPoisoned("command ledger read"))? + .values() + .cloned() + .collect()) + } + + pub(crate) fn replace_command_ledger( + &self, + records: Vec, + ) -> Result<(), RepositoryError> { + let mut ledger = HashMap::with_capacity(records.len()); + for record in records { + let key = record.key.clone(); + if ledger.insert(key, record).is_some() { + return Err(RepositoryError::Model( + "cell command ledger restore contains a duplicate key".into(), + )); + } + } + *self + .command_ledger + .write() + .map_err(|_| RepositoryError::LockPoisoned("command ledger write"))? = ledger; + 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 @@ -287,7 +384,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 +483,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()?; @@ -457,6 +554,37 @@ 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 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, prefix); + Ok(Some(entity)) + } + } } impl CausalGetStream for InMemoryRepository { @@ -480,7 +608,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 +645,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 +680,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 +692,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 d14501fa..6537460e 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; @@ -37,6 +39,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; @@ -77,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::{ @@ -364,7 +370,10 @@ pub use outbox_worker::{ feature = "kafka", test, ))] -pub use outbox_worker::{drain_worker_id, OutboxDrainHandle, OutboxDrainRunner}; +pub use outbox_worker::{ + drain_worker_id, OutboxDrainHandle, OutboxDrainRunner, OutboxPublishMailbox, + DEFAULT_OUTBOX_HINT_CAPACITY, +}; pub use queued_repo::{ // WithOpts + unlock traits for the queued repository variant. @@ -447,8 +456,8 @@ pub use microsvc::{ // mount); commands predict events via `.emits`/`.preview`. pub use distributed_macros::{ aggregate, application, command, command_input_defaults, digest, module, mutation, - mutation_file, sourced, DomainEvent, DomainState, GraphqlInput, GraphqlOutput, ReadModel, - Snapshot, + mutation_file, portable_command, sourced, DomainEvent, DomainState, GraphqlInput, + GraphqlOutput, ReadModel, Snapshot, }; // Re-export enqueue macro (requires "emitter" feature) diff --git a/src/microsvc/cell_host/causal.rs b/src/microsvc/cell_host/causal.rs new file mode 100644 index 00000000..a27a3ecd --- /dev/null +++ b/src/microsvc/cell_host/causal.rs @@ -0,0 +1,367 @@ +//! Feature-free causal receipt and recovery for aggregate-cell wait paths. +//! +//! GraphQL owns its richer projection receipt, but the cell itself still owns +//! the durable command reservation and event/outbox commit. Keeping this layer +//! free of the `graphql` Cargo feature lets workers-rs cells use the exact same +//! fenced command ledger without pulling an HTTP server runtime into wasm. + +use std::time::Duration; + +use serde_json::Value; + +use crate::command_ledger::{ + AttemptFence, CausalCommitBatch, CausalTransactionalCommit, CommandAttempt, CommandId, + CommandLedgerError, CommandLedgerKey, CommandLedgerState, CommandLedgerStore, CommandLookup, + CommandLookupScope, CommandReplay, PrincipalPartitionId, TerminalCommandState, +}; +use crate::microsvc::HandlerError; +use crate::repository::CommitBatch; + +/// Internal wait-path header carrying the executable service identity. +/// +/// Public ingress must strip this header. The GraphQL celld command host sets +/// it from the locally bound [`Service`](crate::microsvc::Service). +pub const CELL_SERVICE_ID_HEADER: &str = "x-distributed-service-id"; + +/// Internal wait-path header carrying the verified-principal partition. +/// +/// This is an opaque server-derived value, never a public command argument. +pub const CELL_PRINCIPAL_PARTITION_HEADER: &str = "x-distributed-principal-partition"; + +/// Trusted command-ledger identity supplied by the cell's authenticated host. +/// +/// `principal_partition` is the opaque, server-derived partition produced by +/// the verified ingress. A public client must never be allowed to choose it. +#[derive(Clone, Debug)] +pub struct CellCommandIdentity { + key: CommandLedgerKey, +} + +impl CellCommandIdentity { + pub fn new( + service_id: impl Into, + principal_partition: impl Into, + command_id: impl AsRef, + ) -> Result { + let command_id = CommandId::parse(command_id).map_err(internal_ledger_error)?; + let principal_partition = + PrincipalPartitionId::new(principal_partition).map_err(internal_ledger_error)?; + let key = CommandLedgerKey::new(service_id, principal_partition, command_id) + .map_err(internal_ledger_error)?; + Ok(Self { key }) + } + + pub fn service_id(&self) -> &str { + self.key.service_id() + } + + pub fn command_id(&self) -> &str { + self.key.command_id() + } + + pub(crate) fn key(&self) -> &CommandLedgerKey { + &self.key + } +} + +/// Exact terminal cell receipt recovered from the command ledger. +#[derive(Clone, Debug, PartialEq)] +pub struct CellDispatchResult { + payload: Value, + command_id: String, + causation_id: String, + state: String, + replayed: bool, +} + +impl CellDispatchResult { + pub fn payload(&self) -> &Value { + &self.payload + } + + pub fn command_id(&self) -> &str { + &self.command_id + } + + pub fn causation_id(&self) -> &str { + &self.causation_id + } + + pub fn state(&self) -> &str { + &self.state + } + + pub fn replayed(&self) -> bool { + self.replayed + } +} + +/// Stable error vocabulary for the feature-free cell wait path. +#[derive(Debug)] +pub enum CellDispatchError { + BadRequest(String), + Unauthorized, + Forbidden, + CommandIdReuse, + InProgress, + Expired, + Rejected { + code: &'static str, + status: u16, + message: String, + }, + Internal(String), +} + +impl CellDispatchError { + pub fn code(&self) -> &'static str { + match self { + Self::BadRequest(_) => "BAD_REQUEST", + Self::Unauthorized => "UNAUTHORIZED", + Self::Forbidden => "FORBIDDEN", + Self::CommandIdReuse => "COMMAND_ID_REUSE", + Self::InProgress => "COMMAND_IN_PROGRESS", + Self::Expired => "COMMAND_EXPIRED", + Self::Rejected { code, .. } => code, + Self::Internal(_) => "INTERNAL", + } + } + + pub fn status_code(&self) -> u16 { + match self { + Self::BadRequest(_) => 400, + Self::Unauthorized => 401, + Self::Forbidden => 403, + Self::CommandIdReuse | Self::InProgress => 409, + Self::Expired => 410, + Self::Rejected { status, .. } => *status, + Self::Internal(_) => 500, + } + } + + pub fn client_message(&self) -> String { + match self { + Self::BadRequest(message) => message.clone(), + Self::Unauthorized => "missing authenticated principal".into(), + Self::Forbidden => "command is not allowed".into(), + Self::CommandIdReuse => "command ID was already used for different input".into(), + Self::InProgress => "command is already in progress".into(), + Self::Expired => "command ID has expired".into(), + Self::Rejected { message, .. } => message.clone(), + Self::Internal(_) => "internal error".into(), + } + } +} + +impl std::fmt::Display for CellDispatchError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Internal(detail) => formatter.write_str(detail), + _ => formatter.write_str(&self.client_message()), + } + } +} + +impl std::error::Error for CellDispatchError {} + +pub(crate) fn handler_error_code(error: &HandlerError) -> &'static str { + match error.status_code() { + 400 => "BAD_REQUEST", + 401 => "UNAUTHORIZED", + 403 => "FORBIDDEN", + 404 => "NOT_FOUND", + _ => "REJECTED", + } +} + +pub(crate) fn internal_ledger_error(error: CommandLedgerError) -> CellDispatchError { + match error { + CommandLedgerError::Invalid(message) => CellDispatchError::BadRequest(message), + other => CellDispatchError::Internal(other.to_string()), + } +} + +pub(crate) fn replay_result( + replay: CommandReplay, + replayed: bool, +) -> Result { + match replay.state { + CommandLedgerState::Succeeded + | CommandLedgerState::SucceededPendingProjection + | CommandLedgerState::Atomic + | CommandLedgerState::ProjectionFailed => Ok(CellDispatchResult { + payload: replay.outcome, + command_id: replay.command_id.as_str().to_string(), + causation_id: replay.causation_id.as_str().to_string(), + state: replay.state.as_str().to_string(), + replayed, + }), + CommandLedgerState::Rejected => replay_rejection(replay.outcome), + CommandLedgerState::InProgress + | CommandLedgerState::RetryableUnknown + | CommandLedgerState::Expired => Err(CellDispatchError::Internal( + "stored cell replay has a non-terminal state".into(), + )), + } +} + +fn replay_rejection(outcome: Value) -> Result { + let error = outcome + .get("error") + .and_then(Value::as_object) + .ok_or_else(|| CellDispatchError::Internal("stored cell rejection is malformed".into()))?; + let code = match error.get("code").and_then(Value::as_str) { + Some("BAD_REQUEST") => "BAD_REQUEST", + Some("UNAUTHORIZED") => "UNAUTHORIZED", + Some("FORBIDDEN") => "FORBIDDEN", + Some("NOT_FOUND") => "NOT_FOUND", + Some("REJECTED") => "REJECTED", + _ => { + return Err(CellDispatchError::Internal( + "stored cell rejection code is invalid".into(), + )); + } + }; + let status = error + .get("status") + .and_then(Value::as_u64) + .and_then(|status| u16::try_from(status).ok()) + .filter(|status| (400..500).contains(status)) + .ok_or_else(|| { + CellDispatchError::Internal("stored cell rejection status is invalid".into()) + })?; + let message = error + .get("message") + .and_then(Value::as_str) + .ok_or_else(|| { + CellDispatchError::Internal("stored cell rejection message is invalid".into()) + })? + .to_string(); + Err(CellDispatchError::Rejected { + code, + status, + message, + }) +} + +pub(crate) async fn commit_rejection( + repository: &R, + attempt: CommandAttempt, + retention: Duration, + code: &'static str, + status: u16, + message: String, +) -> Result +where + R: CommandLedgerStore + CausalTransactionalCommit + Send + Sync, +{ + let outcome = serde_json::json!({ + "error": { + "code": code, + "status": status, + "message": message, + } + }); + let fence = attempt.fence(); + let completion = attempt + .complete(TerminalCommandState::Rejected, outcome, retention) + .map_err(internal_ledger_error)?; + match repository + .commit_causal_batch(CausalCommitBatch::new(CommitBatch::empty(), completion)) + .await + { + Ok(()) => Err(CellDispatchError::Rejected { + code, + status, + message, + }), + Err(error) => recover_commit_error(repository, fence, error.to_string()).await, + } +} + +pub(crate) async fn load_committed_result( + repository: &R, + fence: &AttemptFence, + replayed: bool, +) -> Result +where + R: CommandLedgerStore + Send + Sync, +{ + match repository + .lookup_command(fence.key(), CommandLookupScope::Attempt(fence)) + .await + .map_err(internal_ledger_error)? + { + CommandLookup::Replay(replay) => replay_result(replay, replayed), + CommandLookup::Expired => Err(CellDispatchError::Expired), + CommandLookup::InProgress { .. } + | CommandLookup::RetryableUnknown { .. } + | CommandLookup::Unknown => Err(CellDispatchError::Internal( + "committed cell command has no exact durable replay receipt".into(), + )), + } +} + +pub(crate) async fn abandon_attempt( + repository: &R, + attempt: CommandAttempt, + detail: String, +) -> Result +where + R: CommandLedgerStore + Send + Sync, +{ + let fence = attempt.fence(); + match repository.mark_retryable_unknown(fence.clone()).await { + Ok(()) => Err(CellDispatchError::Internal(detail)), + Err(CommandLedgerError::AttemptFenced { .. }) => { + resolve_ambiguous_lookup(repository, fence, detail).await + } + Err(error) => Err(CellDispatchError::Internal(format!( + "{detail}; failed to mark cell command retryable: {error}" + ))), + } +} + +pub(crate) async fn recover_commit_error( + repository: &R, + fence: AttemptFence, + detail: String, +) -> Result +where + R: CommandLedgerStore + Send + Sync, +{ + resolve_ambiguous_lookup(repository, fence, detail).await +} + +async fn resolve_ambiguous_lookup( + repository: &R, + fence: AttemptFence, + detail: String, +) -> Result +where + R: CommandLedgerStore + Send + Sync, +{ + match repository + .lookup_command(fence.key(), CommandLookupScope::Attempt(&fence)) + .await + { + Ok(CommandLookup::Replay(replay)) => replay_result(replay, false), + Ok(CommandLookup::Expired) => Err(CellDispatchError::Expired), + Ok(CommandLookup::RetryableUnknown { .. }) => Err(CellDispatchError::Internal(detail)), + Ok(CommandLookup::InProgress { .. }) => { + match repository.mark_retryable_unknown(fence).await { + Ok(()) => Err(CellDispatchError::Internal(detail)), + Err(CommandLedgerError::AttemptFenced { .. }) => Err(CellDispatchError::InProgress), + Err(error) => Err(CellDispatchError::Internal(format!( + "{detail}; cell command recovery failed: {error}" + ))), + } + } + Ok(CommandLookup::Unknown) => Err(CellDispatchError::Internal(format!( + "{detail}; cell command ledger row disappeared" + ))), + Err(error) => Err(CellDispatchError::Internal(format!( + "{detail}; cell command outcome lookup failed: {error}" + ))), + } +} diff --git a/src/microsvc/cell_host/cell.rs b/src/microsvc/cell_host/cell.rs new file mode 100644 index 00000000..7d3e0b3c --- /dev/null +++ b/src/microsvc/cell_host/cell.rs @@ -0,0 +1,293 @@ +//! 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::causal::{CellCommandIdentity, CellDispatchError, CellDispatchResult}; +use super::store::{CellStreamStore, DurableCellCommand, 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, SnapshotStore, StreamIdentity}; +use crate::snapshot::{SnapshotRecord, Snapshottable}; + +/// 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 + } + + /// Dispatch a wait-path command through this cell's fenced command ledger. + /// + /// Same principal/command ID plus the same canonical typed input replays + /// the original payload without invoking the handler. Reusing the ID for a + /// different command or input fails before domain effects can commit. + pub async fn dispatch_idempotent( + &self, + command: &str, + identity: &CellCommandIdentity, + input: Value, + session: Session, + ) -> Result { + self.routes + .dispatch_cell_causal(command, identity, 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 + } + + /// 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) + } + + /// Outbox rows committed with the aggregate (same cell SQLite). + pub fn durable_outbox(&self) -> Result, RepositoryError> { + self.routes.repo().repo().durable_outbox() + } + + /// Restore outbox rows from Durable Object SQLite. + pub fn restore_durable_outbox( + &self, + messages: Vec, + ) -> Result<(), RepositoryError> { + self.routes.repo().repo().restore_durable_outbox(messages) + } + + /// 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) + } + + /// Command-ledger rows for Durable Object SQLite. + pub fn durable_commands(&self) -> Result, RepositoryError> { + self.routes.repo().repo().durable_commands() + } + + /// Restore command-ledger rows before accepting another wait-path request. + pub fn restore_durable_commands( + &self, + commands: Vec, + ) -> Result<(), RepositoryError> { + self.routes.repo().repo().restore_durable_commands(commands) + } + + /// 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 + } + + /// 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 +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))`. +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()) +} + +/// 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/command.rs b/src/microsvc/cell_host/command.rs new file mode 100644 index 00000000..7060d0e9 --- /dev/null +++ b/src/microsvc/cell_host/command.rs @@ -0,0 +1,295 @@ +//! GraphQL [`CommandHost`] for celld wait-path + shared cell outbox drain. +//! +//! Aggregate crates supply a [`CelldRoute`] (kind, shard, payload map). Outbox +//! publish, complete, extra drain, and wait-path protocol seal are the same +//! for every cell. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use serde_json::Value; + +use super::outbox::{outbox_alarm_handler, CellOutboxDrainHandler, CellOutboxScheduler}; +use super::{CellOutboxHint, InternalHttpSecret}; +use crate::bus::MessagePublisher; +use crate::command_dispatch::{ + validate_principal_session, validate_principal_session_if_present, CommandHost, + HttpCommandHost, LocalCommandHost, +}; +use crate::graphql::identity::VerifiedPrincipal; +use crate::graphql::protocol::ProtocolResponseAccumulator; +use crate::microsvc::{ + CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult, Service, Session, +}; + +const COMPLETED_STATUS_CACHE_LIMIT: usize = 4_096; +const COMPLETED_STATUS_CACHE_TTL: Duration = Duration::from_secs(15 * 60); + +type CompletedStatusKey = (String, String); + +#[derive(Default)] +struct CompletedStatusCache { + entries: HashMap, + order: VecDeque, +} + +impl CompletedStatusCache { + fn insert(&mut self, key: CompletedStatusKey, status: CausalCommandPublicStatus) { + self.purge_expired(); + if self.entries.contains_key(&key) { + self.order.retain(|existing| existing != &key); + self.order.push_back(key.clone()); + self.entries.insert(key, (Instant::now(), status)); + return; + } + while self.entries.len() >= COMPLETED_STATUS_CACHE_LIMIT { + let Some(evicted) = self.order.pop_front() else { + break; + }; + self.entries.remove(&evicted); + } + self.order.push_back(key.clone()); + self.entries.insert(key, (Instant::now(), status)); + } + + fn get(&mut self, key: &CompletedStatusKey) -> Option { + self.purge_expired(); + self.entries.get(key).map(|(_, status)| status.clone()) + } + + fn purge_expired(&mut self) { + let now = Instant::now(); + while self.order.front().is_some_and(|key| { + self.entries.get(key).is_none_or(|(inserted, _)| { + now.duration_since(*inserted) >= COMPLETED_STATUS_CACHE_TTL + }) + }) { + if let Some(expired) = self.order.pop_front() { + self.entries.remove(&expired); + } + } + } +} + +/// One aggregate's cell wait-path: command names, URL kind, shard id, payload. +#[derive(Clone, Copy)] +pub struct CelldRoute { + pub commands: &'static [&'static str], + /// Path segment `{CELLD_URL}/{kind}/{shard}/{command}`. + pub kind: &'static str, + pub shard: fn(&Value) -> Option, + pub payload: fn(command: &str, input: &Value, remote: &Value, session: &Session) -> Value, +} + +impl CelldRoute { + pub const fn new( + commands: &'static [&'static str], + kind: &'static str, + shard: fn(&Value) -> Option, + payload: fn(command: &str, input: &Value, remote: &Value, session: &Session) -> Value, + ) -> Self { + Self { + commands, + kind, + shard, + payload, + } + } +} + +/// Routes selected commands to celld; everything else stays on [`LocalCommandHost`]. +pub struct CelldCommandHost

{ + http: HttpCommandHost, + scheduler: CellOutboxScheduler, + local: LocalCommandHost, + routes: Vec, + completed: Arc>, + _publisher: std::marker::PhantomData

, +} + +impl

CelldCommandHost

+where + P: MessagePublisher + Clone + Send + Sync + 'static, +{ + pub fn new( + celld_url: impl Into, + service: Arc, + publisher: P, + internal_secret: InternalHttpSecret, + ) -> Result { + let celld_url = celld_url.into().trim_end_matches('/').to_string(); + let http = HttpCommandHost::new_internal(&celld_url, internal_secret)?; + let scheduler = CellOutboxScheduler::spawn(http.clone(), publisher); + Ok(Self { + http, + scheduler, + local: LocalCommandHost::new(service), + routes: Vec::new(), + completed: Arc::new(Mutex::new(CompletedStatusCache::default())), + _publisher: std::marker::PhantomData, + }) + } + + pub fn route(mut self, route: CelldRoute) -> Self { + self.routes.push(route); + self + } + + pub fn outbox_alarm_handler(&self) -> CellOutboxDrainHandler { + outbox_alarm_handler(self.scheduler.clone()) + } + + fn route_for(&self, command: &str) -> Option<&CelldRoute> { + self.routes + .iter() + .find(|route| route.commands.contains(&command)) + } + + fn service_id(&self) -> Result<&str, CausalDispatchError> { + self.local.service().name().ok_or_else(|| { + CausalDispatchError::Internal( + "celld command host requires a named executable service".into(), + ) + }) + } + + fn remember_completed(&self, key: (String, String), status: CausalCommandPublicStatus) { + let Ok(mut completed) = self.completed.lock() else { + return; + }; + completed.insert(key, status); + } +} + +fn remote_dispatch_error(status: u16, body: &Value) -> CausalDispatchError { + let message = body + .get("error") + .and_then(Value::as_str) + .unwrap_or("wait-path rejected") + .to_string(); + match body.get("code").and_then(Value::as_str) { + Some("BAD_REQUEST") => CausalDispatchError::BadRequest(message), + Some("FORBIDDEN") => CausalDispatchError::Forbidden, + Some("COMMAND_ID_REUSE") => CausalDispatchError::CommandIdReuse, + Some("COMMAND_IN_PROGRESS") => CausalDispatchError::InProgress, + Some("COMMAND_EXPIRED") => CausalDispatchError::Expired, + Some("INTERNAL") => { + CausalDispatchError::Internal(format!("cell wait-path failed with HTTP {status}")) + } + Some("UNAUTHORIZED") => CausalDispatchError::Rejected { + code: "UNAUTHORIZED", + status, + message, + }, + Some("NOT_FOUND") => CausalDispatchError::Rejected { + code: "NOT_FOUND", + status, + message, + }, + _ => CausalDispatchError::Rejected { + code: "REJECTED", + status, + message, + }, + } +} + +#[async_trait] +impl

CommandHost for CelldCommandHost

+where + P: MessagePublisher + Clone + Send + Sync + 'static, +{ + async fn invoke( + &self, + command: &str, + command_id: &str, + input: Value, + session: Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result { + validate_principal_session(&session, &principal)?; + let Some(route) = self.route_for(command).copied() else { + return self + .local + .invoke(command, command_id, input, session, principal, protocol) + .await; + }; + let shard = (route.shard)(&input) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + CausalDispatchError::BadRequest(format!( + "{} id required for celld wait-path", + route.kind + )) + })?; + let service_id = self.service_id()?.to_string(); + let principal_partition = principal.partition_for_service(&service_id); + let http = self.http.retarget_segments(&[route.kind, &shard])?; + let (status, body) = http + .post_cell_wait_path( + command, + command_id, + input.clone(), + &session, + &service_id, + &principal_partition, + ) + .await?; + let outbox = CausalDispatchResult::outbox_from_wait_path(&body)?; + if !outbox.is_empty() { + let hint = CellOutboxHint::new(route.kind, shard.clone()) + .map_err(CausalDispatchError::Internal)?; + if let Err(error) = self.scheduler.schedule(hint) { + eprintln!( + "cell outbox schedule deferred for {}/{}: {error}; durable cell alarm remains armed", + route.kind, shard + ); + } + } + if status >= 400 { + return Err(remote_dispatch_error(status, &body)); + } + let remote = CausalDispatchResult::from_wait_path_wire(body) + .map_err(|error| CausalDispatchError::Internal(format!("wait-path decode: {error}")))?; + let payload = (route.payload)(command, &input, remote.payload(), &session); + let mut remote = remote.with_payload(payload); + if let Some(protocol) = protocol { + remote = self + .local + .service() + .seal_wait_path_dispatch(command, &protocol, remote)?; + } + self.remember_completed( + (principal_partition, command_id.to_string()), + remote.public_status(), + ); + Ok(remote) + } + + async fn status( + &self, + command_id: &str, + session: &Session, + principal: VerifiedPrincipal, + protocol: Option, + ) -> Result { + validate_principal_session_if_present(session, &principal)?; + let service_id = self.service_id()?; + let principal_partition = principal.partition_for_service(service_id); + let key = (principal_partition, command_id.to_string()); + if let Some(status) = self + .completed + .lock() + .ok() + .and_then(|mut guard| guard.get(&key)) + { + return Ok(status); + } + self.local + .status(command_id, session, principal, protocol) + .await + } +} diff --git a/src/microsvc/cell_host/internal_auth.rs b/src/microsvc/cell_host/internal_auth.rs new file mode 100644 index 00000000..6eda44df --- /dev/null +++ b/src/microsvc/cell_host/internal_auth.rs @@ -0,0 +1,91 @@ +//! Authentication for the private HTTP boundary between a command host and cells. + +use std::sync::Arc; + +use sha2::{Digest, Sha256}; + +/// Environment variable containing the internal cell HTTP secret. +pub const CELL_INTERNAL_SECRET_ENV: &str = "DISTRIBUTED_INTERNAL_SECRET"; +/// Header used only on authenticated host-to-cell HTTP requests. +pub const CELL_INTERNAL_SECRET_HEADER: &str = "x-distributed-internal-secret"; + +const MIN_SECRET_LEN: usize = 32; +const MAX_SECRET_LEN: usize = 512; + +/// Validated secret for the private command-host/cell HTTP boundary. +/// +/// The value is redacted from Debug output; comparisons use fixed-size digests and +/// constant-time byte accumulation. +#[derive(Clone)] +pub struct InternalHttpSecret { + value: Arc, + digest: [u8; 32], +} + +impl InternalHttpSecret { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if !(MIN_SECRET_LEN..=MAX_SECRET_LEN).contains(&value.len()) { + return Err(format!( + "internal HTTP secret must be {MIN_SECRET_LEN}..={MAX_SECRET_LEN} bytes" + )); + } + if !value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { + return Err( + "internal HTTP secret must contain only visible ASCII without whitespace".into(), + ); + } + let digest: [u8; 32] = Sha256::digest(value.as_bytes()).into(); + Ok(Self { + value: Arc::from(value), + digest, + }) + } + + /// Header value for an outbound request. Never log this value. + pub fn header_value(&self) -> &str { + &self.value + } + + /// Verify an inbound header without early-exit comparison of secret bytes. + pub fn matches(&self, candidate: &str) -> bool { + let candidate: [u8; 32] = Sha256::digest(candidate.as_bytes()).into(); + candidate + .iter() + .zip(self.digest.iter()) + .fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) + == 0 + } +} + +impl std::fmt::Debug for InternalHttpSecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("InternalHttpSecret([redacted])") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "test-only-internal-secret-32-bytes"; + + #[test] + fn validates_and_matches_exact_secret() { + let secret = InternalHttpSecret::new(SECRET).expect("valid secret"); + assert!(secret.matches(SECRET)); + assert!(!secret.matches("test-only-internal-secret-32-bytez")); + assert!(!secret.matches("")); + assert_eq!(format!("{secret:?}"), "InternalHttpSecret([redacted])"); + } + + #[test] + fn rejects_short_long_or_header_ambiguous_values() { + assert!(InternalHttpSecret::new("short").is_err()); + assert!(InternalHttpSecret::new("x".repeat(MAX_SECRET_LEN + 1)).is_err()); + assert!(InternalHttpSecret::new(format!("{SECRET}\n")).is_err()); + assert!(InternalHttpSecret::new(format!(" {SECRET}")).is_err()); + } +} diff --git a/src/microsvc/cell_host/mod.rs b/src/microsvc/cell_host/mod.rs new file mode 100644 index 00000000..10bd1930 --- /dev/null +++ b/src/microsvc/cell_host/mod.rs @@ -0,0 +1,49 @@ +//! 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. +//! +//! GraphQL wait-path + cell SQLite outbox drain (`CelldCommandHost`) is +//! the same for every aggregate: routes only supply kind, shard, and payload. + +pub(crate) mod causal; +mod cell; +#[cfg(feature = "graphql")] +mod command; +mod internal_auth; +#[cfg(feature = "graphql")] +mod outbox; +mod store; +mod wire; + +pub use causal::{ + CellCommandIdentity, CellDispatchError, CellDispatchResult, CELL_PRINCIPAL_PARTITION_HEADER, + CELL_SERVICE_ID_HEADER, +}; +pub use cell::{instance_name, parent_cell_name, AggregateCell, CellNamespace}; +#[cfg(feature = "graphql")] +pub use command::{CelldCommandHost, CelldRoute}; +pub use internal_auth::{ + InternalHttpSecret, CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, +}; +#[cfg(feature = "graphql")] +pub use outbox::{ + accept_outbox_drain, outbox_alarm_handler, CellOutboxDrainHandler, CellOutboxScheduler, + CELL_OUTBOX_DRAIN_PATH, +}; +pub use store::{CellStreamStore, DurableCellCommand, DurableCellEvents, DurableCellSnapshot}; +pub(crate) use wire::validate_cell_outbox_messages; +pub use wire::{ + parse_cell_outbox, parse_claimed_cell_outbox, CellOutboxHint, CellOutboxWireItem, + CellWaitPathRequest, MAX_CELL_OUTBOX_ITEMS, MAX_CELL_OUTBOX_PAYLOAD_BYTES, + MAX_CELL_OUTBOX_WIRE_BYTES, +}; + +#[cfg(test)] +mod tests; diff --git a/src/microsvc/cell_host/outbox.rs b/src/microsvc/cell_host/outbox.rs new file mode 100644 index 00000000..9d3bde95 --- /dev/null +++ b/src/microsvc/cell_host/outbox.rs @@ -0,0 +1,259 @@ +//! Bounded cell SQLite outbox claim/publish/settle scheduler. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::future::BoxFuture; +use futures_util::{stream, StreamExt}; +use serde_json::{json, Value}; +use tokio::sync::mpsc; + +use crate::bus::{Message, MessagePublisher}; +use crate::command_dispatch::HttpCommandHost; + +use super::{parse_claimed_cell_outbox, CellOutboxHint}; + +/// GraphQL process path a cell alarm POSTs a cell-address hint to. +pub const CELL_OUTBOX_DRAIN_PATH: &str = "/internal/outbox/drain"; + +const SCHEDULER_CAPACITY: usize = 1_024; +const MAX_TRACKED_CELLS: usize = 4_096; +const CLAIM_LIMIT: usize = 64; +const CLAIM_LEASE: Duration = Duration::from_secs(30); +const PUBLISH_TIMEOUT: Duration = Duration::from_secs(5); +const DRAIN_TIMEOUT: Duration = Duration::from_secs(25); + +pub type CellOutboxDrainHandler = + Arc BoxFuture<'static, Result<(), String>> + Send + Sync>; + +/// Non-blocking ingress to the one shared outbox claim/publish/settle loop. +#[derive(Clone)] +pub struct CellOutboxScheduler { + tx: mpsc::Sender, +} + +impl CellOutboxScheduler { + pub fn spawn

(http: HttpCommandHost, publisher: P) -> Self + where + P: MessagePublisher + Clone + Send + Sync + 'static, + { + let (tx, rx) = mpsc::channel(SCHEDULER_CAPACITY); + tokio::spawn(run_scheduler(http, publisher, rx)); + Self { tx } + } + + /// Queue a durable cell address without waiting for HTTP or the broker. + pub fn schedule(&self, hint: CellOutboxHint) -> Result<(), String> { + hint.validate()?; + self.tx.try_send(hint).map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => { + "cell outbox scheduler is temporarily at capacity".to_string() + } + mpsc::error::TrySendError::Closed(_) => { + "cell outbox scheduler is not running".to_string() + } + }) + } +} + +async fn run_scheduler

( + http: HttpCommandHost, + publisher: P, + mut rx: mpsc::Receiver, +) where + P: MessagePublisher + Clone + Send + Sync + 'static, +{ + let worker_id = format!("cell-host-{}", uuid::Uuid::now_v7()); + let mut tracked = HashSet::new(); + let mut ticker = tokio::time::interval(Duration::from_secs(5)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ticker.tick().await; + + loop { + let hints = tokio::select! { + hint = rx.recv() => { + let Some(hint) = hint else { + return; + }; + vec![hint] + } + _ = ticker.tick() => { + tracked.iter().take(32).cloned().collect::>() + } + }; + + for hint in hints { + if tracked.len() < MAX_TRACKED_CELLS || tracked.contains(&hint) { + tracked.insert(hint.clone()); + } + let drained = tokio::time::timeout( + DRAIN_TIMEOUT, + drain_one_cell(&http, &publisher, &worker_id, &hint), + ) + .await; + match drained { + Ok(Ok(true)) => { + tracked.remove(&hint); + } + Ok(Ok(false)) => {} + Ok(Err(error)) => eprintln!( + "cell outbox drain failed for {}/{}: {error}", + hint.kind, hint.id + ), + Err(_) => eprintln!( + "cell outbox drain timed out for {}/{}; its durable lease will expire", + hint.kind, hint.id + ), + } + } + } +} + +/// Returns true only after the cell confirms that no claimable rows remain. +async fn drain_one_cell

( + http: &HttpCommandHost, + publisher: &P, + worker_id: &str, + hint: &CellOutboxHint, +) -> Result +where + P: MessagePublisher + Clone + Send + Sync + 'static, +{ + let shard = http + .retarget_segments(&[&hint.kind, &hint.id]) + .map_err(|error| error.to_string())?; + let (status, body) = shard + .post_json( + "outbox.claim", + json!({ + "workerId": worker_id, + "limit": CLAIM_LIMIT, + "leaseMs": CLAIM_LEASE.as_millis() as u64, + }), + ) + .await + .map_err(|error| error.to_string())?; + if status != 200 { + return Err(format!("cell outbox claim returned HTTP {status}")); + } + let rows = parse_claimed_cell_outbox(&body)?; + if rows.is_empty() { + return Ok(true); + } + if rows.iter().any(|row| !row.is_claimed_by(worker_id)) { + return Err("cell returned an outbox claim owned by another worker".into()); + } + + let results = stream::iter(rows.into_iter()) + .map(|row| { + let publisher = publisher.clone(); + async move { + let id = row.id.clone(); + let published = + tokio::time::timeout(PUBLISH_TIMEOUT, publisher.publish(Message::from(row))) + .await + .is_ok_and(|result| result.is_ok()); + (id, published) + } + }) + .buffer_unordered(8) + .collect::>() + .await; + let mut published = Vec::new(); + let mut failed = Vec::new(); + for (id, succeeded) in results { + if succeeded { + published.push(id); + } else { + failed.push(id); + } + } + + if !published.is_empty() { + let (status, _) = shard + .post_json( + "outbox.complete", + json!({ "workerId": worker_id, "ids": published }), + ) + .await + .map_err(|error| error.to_string())?; + if status != 200 { + return Err(format!("cell outbox completion returned HTTP {status}")); + } + } + if !failed.is_empty() { + let (status, _) = shard + .post_json( + "outbox.release", + json!({ + "workerId": worker_id, + "ids": failed, + "error": "broker publish failed or timed out", + }), + ) + .await + .map_err(|error| error.to_string())?; + if status != 200 { + return Err(format!("cell outbox release returned HTTP {status}")); + } + } + + Ok(false) +} + +/// Validate an alarm hint and submit it to the shared bounded scheduler. +pub fn accept_outbox_drain(scheduler: &CellOutboxScheduler, body: Value) -> Result<(), String> { + let hint: CellOutboxHint = serde_json::from_value(body) + .map_err(|error| format!("invalid cell outbox hint: {error}"))?; + scheduler.schedule(hint) +} + +/// Handler for the authenticated internal alarm route. +pub fn outbox_alarm_handler(scheduler: CellOutboxScheduler) -> CellOutboxDrainHandler { + Arc::new(move |body: Value| { + let scheduler = scheduler.clone(); + Box::pin(async move { accept_outbox_drain(&scheduler, body) }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn handler_rejects_payload_injection_and_unsafe_addresses() { + let (tx, mut rx) = mpsc::channel(1); + let scheduler = CellOutboxScheduler { tx }; + let handler = outbox_alarm_handler(scheduler); + + assert!(handler(json!({ "kind": "todo", "id": "1", "outbox": [] })) + .await + .is_err()); + assert!(handler(json!({ "kind": "todo", "id": "../chat" })) + .await + .is_err()); + handler(json!({ "kind": "todo", "id": "safe-id" })) + .await + .expect("safe hint"); + assert_eq!( + rx.recv().await, + Some(CellOutboxHint { + kind: "todo".into(), + id: "safe-id".into(), + }) + ); + } + + #[tokio::test] + async fn scheduler_ingress_is_bounded_and_nonblocking() { + let (tx, _rx) = mpsc::channel(1); + let scheduler = CellOutboxScheduler { tx }; + scheduler + .schedule(CellOutboxHint::new("todo", "1").expect("hint")) + .expect("first"); + assert!(scheduler + .schedule(CellOutboxHint::new("todo", "2").expect("hint")) + .is_err()); + } +} diff --git a/src/microsvc/cell_host/store.rs b/src/microsvc/cell_host/store.rs new file mode 100644 index 00000000..3564085d --- /dev/null +++ b/src/microsvc/cell_host/store.rs @@ -0,0 +1,624 @@ +//! 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 std::sync::{Arc, Mutex}; + +use serde_json::Value; + +use crate::command_ledger::{ + AttemptFence, CausalCommitBatch, CausalGetStream, CausalRepositoryIdentity, + CausalStorageIdentity, CausalTransactionalCommit, CommandLedgerError, CommandLedgerKey, + CommandLedgerStore, CommandLookup, CommandLookupScope, CommandReservation, ReservationOutcome, +}; +use crate::entity::{Entity, EventRecord}; +use crate::microsvc::HasOutboxStore; +use crate::outbox::OutboxMessage; +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, GetStream, RepositoryError, SnapshotStore, SnapshotWrite, StreamIdentity, + TransactionalCommit, +}; +use crate::snapshot::SnapshotRecord; +use crate::{InMemoryOutboxStore, InMemoryRepository}; +use serde::{Deserialize, Serialize}; + +#[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, + owns: Arc bool + Send + Sync>, + }, +} + +/// Private SQLite stand-in for one cell instance (`{aggregate_type}:{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); +/// } +/// ``` + +/// 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, +} + +/// 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, +} + +/// One versioned command-ledger row for Durable Object SQLite persistence. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DurableCellCommand { + pub id: String, + pub body: String, +} + +#[derive(Clone)] +pub struct CellStreamStore { + ownership: CellOwnership, + inner: InMemoryRepository, + sealed_row: Arc>>, +} + +impl CellStreamStore { + /// Bind a store to one exact stream identity. + pub fn for_identity(identity: StreamIdentity) -> Self { + Self { + ownership: CellOwnership::Exclusive(identity), + inner: InMemoryRepository::new(), + sealed_row: Arc::new(Mutex::new(None)), + } + } + + /// 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, + owns: impl Fn(&StreamIdentity) -> bool + Send + Sync + 'static, + ) -> Result { + Ok(Self { + ownership: CellOwnership::Parent { + name: StreamIdentity::new(parent_type, parent_id)?, + owns: Arc::new(owns), + }, + inner: InMemoryRepository::new(), + sealed_row: Arc::new(Mutex::new(None)), + }) + } + + /// Named exclusive-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 { + match &self.ownership { + CellOwnership::Exclusive(identity) | CellOwnership::Parent { name: identity, .. } => { + identity.to_string() + } + } + } + + /// 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> { + match &self.ownership { + CellOwnership::Parent { owns, .. } if owns(identity) => Ok(()), + CellOwnership::Parent { name, .. } => Err(RepositoryError::Model(format!( + "parent cell {name} does not own stream {identity}" + ))), + CellOwnership::Exclusive(owned) if identity == owned => Ok(()), + CellOwnership::Exclusive(owned) => Err(RepositoryError::Model(format!( + "cell `{owned}` cannot access stream `{identity}`" + ))), + } + } + + /// 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 + .inner + .clone_events()? + .into_iter() + .map(|(stream, events)| DurableCellEvents { stream, events }) + .collect()) + } + + /// Outbox rows committed with this cell's events (same private SQLite). + pub fn durable_outbox(&self) -> Result, RepositoryError> { + self.inner.clone_outbox() + } + + /// Restore outbox rows from Durable Object SQLite. + pub fn restore_durable_outbox( + &self, + messages: Vec, + ) -> Result<(), RepositoryError> { + self.inner.replace_outbox(messages) + } + + /// 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(), + ) + } + + /// 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(), + ) + } + + /// Fenced command rows committed with this cell's domain effects. + pub fn durable_commands(&self) -> Result, RepositoryError> { + self.inner + .clone_command_ledger()? + .into_iter() + .map(|record| { + let id = record.durable_cell_key(); + let body = record + .durable_cell_json() + .map_err(|error| RepositoryError::Model(error.to_string()))?; + Ok(DurableCellCommand { id, body }) + }) + .collect() + } + + /// Restore the complete command ledger before accepting another request. + pub fn restore_durable_commands( + &self, + commands: Vec, + ) -> Result<(), RepositoryError> { + let mut records = Vec::with_capacity(commands.len()); + for command in commands { + let record = + crate::command_ledger::CommandLedgerRecord::from_durable_cell_json(&command.body) + .map_err(|error| RepositoryError::Model(error.to_string()))?; + if record.durable_cell_key() != command.id { + return Err(RepositoryError::Model( + "cell command ledger row key does not match its body".into(), + )); + } + records.push(record); + } + self.inner.replace_command_ledger(records) + } + + 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 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 + } + } + + 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 { + 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 00000000..f7e2c957 --- /dev/null +++ b/src/microsvc/cell_host/tests.rs @@ -0,0 +1,530 @@ +use super::{ + instance_name, parent_cell_name, AggregateCell, CellCommandIdentity, CellDispatchError, + 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::{ + CommitBatch, GetStream, RepositoryError, StreamIdentity, StreamWrite, TransactionalCommit, +}; +use crate::sourced; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use super::super::causal::{CausalWorkspace, CausalWorkspaceError}; + +#[derive(Clone, Default, Serialize, Deserialize, crate::Snapshot)] +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_with_snapshots("item-1", 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 loaded = cell.load().await.expect("load"); + assert_eq!(loaded.expect("resident").title, "ship"); + + 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); + + let snap = cell + .cached_snapshot() + .await + .expect("snapshot") + .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()); + 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 events"); + 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 + .expect("load restored") + .expect("durable"); + assert_eq!(loaded.title, "ship"); + assert!(loaded.done); + assert_eq!(loaded.entity.snapshot_version(), 2); +} + +#[tokio::test] +async fn cell_wait_path_replays_the_same_command_without_new_domain_effects() { + let cell = AggregateCell::::new("item-ledger") + .unwrap() + .mount(Create) + .mount(Complete); + let identity = CellCommandIdentity::new( + "cell-test-service", + "principal-alice", + "0190a000-0000-7000-8000-000000000401", + ) + .unwrap(); + let input = json!({ "id": "item-ledger", "title": "once" }); + + let first = cell + .dispatch_idempotent( + "cell_item.create", + &identity, + input.clone(), + owner_session(), + ) + .await + .expect("first dispatch"); + let replay = cell + .dispatch_idempotent("cell_item.create", &identity, input, owner_session()) + .await + .expect("same-input replay"); + + assert!(!first.replayed()); + assert!(replay.replayed()); + assert_eq!(replay.payload(), first.payload()); + assert_eq!(replay.causation_id(), first.causation_id()); + let events = cell.durable_events().unwrap(); + assert_eq!( + events + .iter() + .map(|stream| stream.events.len()) + .sum::(), + 1, + "replay must not invoke the handler or append another event" + ); + assert_eq!( + events[0].events[0].causation_id(), + Some(first.causation_id()) + ); + + let durable_commands = cell.durable_commands().expect("export command ledger"); + assert_eq!(durable_commands.len(), 1); + let restored = AggregateCell::::new("item-ledger") + .unwrap() + .mount(Create) + .mount(Complete); + restored + .restore_durable_events(events) + .expect("restore domain events"); + restored + .restore_durable_commands(durable_commands) + .expect("restore command ledger"); + let replay_after_restart = restored + .dispatch_idempotent( + "cell_item.create", + &identity, + json!({ "id": "item-ledger", "title": "once" }), + owner_session(), + ) + .await + .expect("durable replay after restart"); + assert!(replay_after_restart.replayed()); + assert_eq!(replay_after_restart.causation_id(), first.causation_id()); + assert_eq!( + restored + .durable_events() + .unwrap() + .iter() + .map(|stream| stream.events.len()) + .sum::(), + 1 + ); +} + +#[tokio::test] +async fn cell_wait_path_rejects_command_id_reuse_with_different_input() { + let cell = AggregateCell::::new("item-conflict") + .unwrap() + .mount(Create) + .mount(Complete); + let identity = CellCommandIdentity::new( + "cell-test-service", + "principal-alice", + "0190a000-0000-7000-8000-000000000402", + ) + .unwrap(); + cell.dispatch_idempotent( + "cell_item.create", + &identity, + json!({ "id": "item-conflict", "title": "first" }), + owner_session(), + ) + .await + .unwrap(); + + let error = cell + .dispatch_idempotent( + "cell_item.create", + &identity, + json!({ "id": "item-conflict", "title": "different" }), + owner_session(), + ) + .await + .unwrap_err(); + assert!(matches!(error, CellDispatchError::CommandIdReuse)); + assert_eq!(error.code(), "COMMAND_ID_REUSE"); + assert_eq!(error.status_code(), 409); +} + +#[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()); +} + +#[tokio::test] +async fn parent_cell_commits_sibling_streams_in_one_batch() { + let store = CellStreamStore::for_parent_shard("game", "game-1", |identity| { + matches!(identity.aggregate_type(), "GameMap" | "Player" | "Bomb") + }) + .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()); + + let foreign = StreamIdentity::new("Foreign", "foreign-1").unwrap(); + assert!(GetStream::get_stream(&store, &foreign).await.is_err()); +} + +#[tokio::test] +async fn parent_cells_are_isolated_and_have_no_cross_cell_commit() { + let game_1 = CellStreamStore::for_parent_shard("game", "g1", |identity| { + identity.aggregate_id().starts_with("g1:") + }) + .unwrap(); + let game_2 = CellStreamStore::for_parent_shard("game", "g2", |identity| { + identity.aggregate_id().starts_with("g2:") + }) + .unwrap(); + + let mut player = Entity::with_id("g1:player:1"); + player.digest_empty("joined").unwrap(); + let player_id = StreamIdentity::new("Player", "g1: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.is_err()); +} + +#[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/cell_host/wire.rs b/src/microsvc/cell_host/wire.rs new file mode 100644 index 00000000..97e30efc --- /dev/null +++ b/src/microsvc/cell_host/wire.rs @@ -0,0 +1,424 @@ +//! Strict, size-bounded wire values shared by cell workers and command hosts. + +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; + +use serde::{Deserialize, Serialize}; + +use crate::{OutboxMessage, OutboxMessageStatus}; + +pub const MAX_CELL_OUTBOX_ITEMS: usize = 256; +pub const MAX_CELL_OUTBOX_PAYLOAD_BYTES: usize = 1024 * 1024; +pub const MAX_CELL_OUTBOX_WIRE_BYTES: usize = 1536 * 1024; +const MAX_IDENTIFIER_BYTES: usize = 512; +const MAX_CODEC_BYTES: usize = 128; +const MAX_METADATA_ENTRIES: usize = 64; +const MAX_METADATA_VALUE_BYTES: usize = 1024; +const MAX_CLAIM_LEASE_AHEAD: Duration = Duration::from_secs(5 * 60); +const CLAIM_WIRE_RESERVE_PER_ITEM: usize = 768; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CellOutboxWireItem { + pub id: String, + pub event_type: String, + pub payload: Vec, + pub payload_codec: String, + pub payload_codec_version: u16, + pub status: String, + #[serde(default)] + pub attempts: u32, + #[serde(default)] + pub last_error: Option, + #[serde(default)] + pub worker_id: Option, + #[serde(default)] + pub leased_until_unix_ms: Option, + #[serde(default)] + pub metadata: HashMap, + pub source_aggregate_type: Option, + pub source_aggregate_id: Option, + pub source_sequence: Option, +} + +impl CellOutboxWireItem { + pub fn from_message(message: &OutboxMessage) -> Self { + Self { + id: message.id.clone(), + event_type: message.event_type.clone(), + payload: message.payload.clone(), + payload_codec: message.payload_codec.clone(), + payload_codec_version: message.payload_codec_version, + status: message.status.as_str().to_string(), + attempts: message.attempts, + last_error: message.last_error.clone(), + worker_id: message.worker_id.clone(), + leased_until_unix_ms: message.leased_until.and_then(|time| { + time.duration_since(SystemTime::UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + }), + metadata: message.metadata.clone(), + source_aggregate_type: message.source_aggregate_type.clone(), + source_aggregate_id: message.source_aggregate_id.clone(), + source_sequence: message.source_sequence, + } + } + + pub fn try_into_message(self) -> Result { + self.try_into_message_with_status(&[OutboxMessageStatus::Pending]) + } + + pub fn try_into_claimed_message(self) -> Result { + let message = self.try_into_message_with_status(&[OutboxMessageStatus::InFlight])?; + let now = crate::time::now(); + let valid_deadline = message.leased_until.is_some_and(|deadline| { + deadline + .duration_since(now) + .is_ok_and(|remaining| remaining <= MAX_CLAIM_LEASE_AHEAD) + }); + if message.attempts == 0 || !valid_deadline { + return Err("claimed cell outbox row has an invalid or expired lease".into()); + } + Ok(message) + } + + pub fn try_into_stored_message(self) -> Result { + self.try_into_message_with_status(&[ + OutboxMessageStatus::Pending, + OutboxMessageStatus::InFlight, + OutboxMessageStatus::Published, + OutboxMessageStatus::Failed, + ]) + } + + fn try_into_message_with_status( + self, + allowed_statuses: &[OutboxMessageStatus], + ) -> Result { + validate_identifier("outbox id", &self.id)?; + validate_identifier("event type", &self.event_type)?; + if self.payload.len() > MAX_CELL_OUTBOX_PAYLOAD_BYTES { + return Err("cell outbox payload exceeds 1 MiB".into()); + } + if self.payload_codec.is_empty() + || self.payload_codec.len() > MAX_CODEC_BYTES + || self.payload_codec_version == 0 + { + return Err("cell outbox payload codec is invalid".into()); + } + let status = self + .status + .parse::() + .map_err(|_| "cell outbox status is invalid")?; + if !allowed_statuses.contains(&status) { + return Err("cell outbox row has an invalid delivery status for this response".into()); + } + match status { + OutboxMessageStatus::Pending + if self.worker_id.is_some() || self.leased_until_unix_ms.is_some() => + { + return Err("pending cell outbox row must not carry lease ownership".into()) + } + OutboxMessageStatus::InFlight + if self.worker_id.is_none() || self.leased_until_unix_ms.is_none() => + { + return Err("claimed cell outbox row is missing lease ownership".into()) + } + _ => {} + } + validate_metadata(&self.metadata)?; + if let Some(worker_id) = &self.worker_id { + validate_identifier("outbox worker id", worker_id)?; + } + if self + .last_error + .as_ref() + .is_some_and(|error| error.len() > MAX_METADATA_VALUE_BYTES) + { + return Err("cell outbox last error is too large".into()); + } + if let Some(value) = &self.source_aggregate_type { + validate_identifier("source aggregate type", value)?; + } + if let Some(value) = &self.source_aggregate_id { + validate_identifier("source aggregate id", value)?; + } + let source_fields = [ + self.source_aggregate_type.is_some(), + self.source_aggregate_id.is_some(), + self.source_sequence.is_some(), + ]; + if source_fields.iter().any(|present| *present) + && source_fields.iter().any(|present| !*present) + { + return Err("cell outbox source identity must be complete or absent".into()); + } + if self.source_sequence == Some(0) { + return Err("cell outbox source sequence must be nonzero".into()); + } + let mut message = OutboxMessage::create_with_metadata( + self.id, + self.event_type, + self.payload, + self.metadata, + ) + .map_err(|error| error.to_string())?; + message.payload_codec = self.payload_codec; + message.payload_codec_version = self.payload_codec_version; + message.status = status; + message.attempts = self.attempts; + message.last_error = self.last_error; + message.worker_id = self.worker_id; + message.leased_until = self + .leased_until_unix_ms + .map(|millis| { + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_millis(millis)) + .ok_or_else(|| "cell outbox lease timestamp is out of range".to_string()) + }) + .transpose()?; + message.source_aggregate_type = self.source_aggregate_type; + message.source_aggregate_id = self.source_aggregate_id; + message.source_sequence = self.source_sequence; + Ok(message) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CellOutboxHint { + pub kind: String, + pub id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CellWaitPathRequest { + pub command_id: String, + #[serde(default)] + pub input: serde_json::Value, +} + +impl CellWaitPathRequest { + pub fn parse(value: serde_json::Value) -> Result { + let request: Self = serde_json::from_value(value) + .map_err(|error| format!("invalid cell wait-path envelope: {error}"))?; + if request.command_id.trim().is_empty() + || request.command_id.len() > MAX_IDENTIFIER_BYTES + || request.command_id.chars().any(char::is_control) + { + return Err("cell wait-path commandId is invalid".into()); + } + Ok(request) + } +} + +impl CellOutboxHint { + pub fn new(kind: impl Into, id: impl Into) -> Result { + let hint = Self { + kind: kind.into(), + id: id.into(), + }; + hint.validate()?; + Ok(hint) + } + + pub fn validate(&self) -> Result<(), String> { + validate_path_segment("cell kind", &self.kind)?; + validate_path_segment("cell id", &self.id) + } +} + +pub fn parse_cell_outbox(value: &serde_json::Value) -> Result, String> { + parse_cell_outbox_with(value, CellOutboxWireItem::try_into_message) +} + +pub fn parse_claimed_cell_outbox(value: &serde_json::Value) -> Result, String> { + parse_cell_outbox_with(value, CellOutboxWireItem::try_into_claimed_message) +} + +/// Reject a cell command's outbox before commit if its bounded HTTP form could +/// not later be claimed and published by the host. +pub(crate) fn validate_cell_outbox_messages(messages: &[OutboxMessage]) -> Result<(), String> { + let value = serde_json::json!({ + "outbox": messages + .iter() + .map(CellOutboxWireItem::from_message) + .collect::>() + }); + parse_cell_outbox(&value)?; + let encoded = serde_json::to_vec(&value) + .map_err(|error| format!("cell outbox could not be encoded: {error}"))?; + let claimed_size_upper_bound = encoded + .len() + .saturating_add(messages.len().saturating_mul(CLAIM_WIRE_RESERVE_PER_ITEM)); + if claimed_size_upper_bound > MAX_CELL_OUTBOX_WIRE_BYTES { + return Err("cell outbox wire envelope exceeds 1.5 MiB".into()); + } + Ok(()) +} + +fn parse_cell_outbox_with( + value: &serde_json::Value, + convert: fn(CellOutboxWireItem) -> Result, +) -> Result, String> { + let Some(items) = value.get("outbox") else { + return Ok(Vec::new()); + }; + let items: Vec = + serde_json::from_value(items.clone()).map_err(|error| format!("cell outbox: {error}"))?; + if items.len() > MAX_CELL_OUTBOX_ITEMS { + return Err(format!( + "cell outbox contains more than {MAX_CELL_OUTBOX_ITEMS} rows" + )); + } + let mut total_payload = 0_usize; + let mut ids = std::collections::HashSet::new(); + items + .into_iter() + .map(|item| { + if !ids.insert(item.id.clone()) { + return Err("cell outbox response contains duplicate ids".into()); + } + total_payload = total_payload.saturating_add(item.payload.len()); + if total_payload > MAX_CELL_OUTBOX_PAYLOAD_BYTES { + return Err("cell outbox payloads exceed 1 MiB in total".into()); + } + convert(item) + }) + .collect() +} + +fn validate_identifier(label: &str, value: &str) -> Result<(), String> { + if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.chars().any(char::is_control) + { + return Err(format!("{label} is invalid")); + } + Ok(()) +} + +fn validate_path_segment(label: &str, value: &str) -> Result<(), String> { + validate_identifier(label, value)?; + if matches!(value, "." | "..") || value.contains('/') || value.contains('\\') { + return Err(format!("{label} is not a safe path segment")); + } + Ok(()) +} + +fn validate_metadata(metadata: &HashMap) -> Result<(), String> { + if metadata.len() > MAX_METADATA_ENTRIES { + return Err("cell outbox metadata has too many entries".into()); + } + for (key, value) in metadata { + if key.is_empty() + || key.len() > MAX_METADATA_VALUE_BYTES + || value.len() > MAX_METADATA_VALUE_BYTES + || key.chars().any(char::is_control) + || value.chars().any(char::is_control) + { + return Err("cell outbox metadata is invalid".into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn valid_item() -> serde_json::Value { + json!({ + "id": "event-1", + "eventType": "todo.created", + "payload": [0, 127, 255], + "payloadCodec": "bytes", + "payloadCodecVersion": 1, + "status": "pending", + "metadata": {}, + "sourceAggregateType": "todo", + "sourceAggregateId": "todo-1", + "sourceSequence": 1 + }) + } + + #[test] + fn parses_strict_pending_item_without_numeric_truncation() { + let rows = parse_cell_outbox(&json!({ "outbox": [valid_item()] })).expect("valid"); + assert_eq!(rows[0].payload, vec![0, 127, 255]); + + let mut invalid = valid_item(); + invalid["payload"] = json!([256]); + assert!(parse_cell_outbox(&json!({ "outbox": [invalid] })).is_err()); + } + + #[test] + fn rejects_unknown_fields_status_and_unsafe_hints() { + let mut unknown = valid_item(); + unknown["forged"] = json!(true); + assert!(parse_cell_outbox(&json!({ "outbox": [unknown] })).is_err()); + + let mut published = valid_item(); + published["status"] = json!("published"); + assert!(parse_cell_outbox(&json!({ "outbox": [published] })).is_err()); + + assert!(CellOutboxHint::new("todo", "../other").is_err()); + assert!(CellOutboxHint::new("todo", "valid-id").is_ok()); + assert!(CellWaitPathRequest::parse(json!({ + "commandId": "command-1", + "input": {}, + "roles": ["admin"] + })) + .is_err()); + } + + #[test] + fn enforces_row_and_total_payload_limits() { + let items = (0..=MAX_CELL_OUTBOX_ITEMS) + .map(|index| { + let mut item = valid_item(); + item["id"] = json!(format!("event-{index}")); + item + }) + .collect::>(); + assert!(parse_cell_outbox(&json!({ "outbox": items })).is_err()); + + let mut oversized = valid_item(); + oversized["payload"] = json!(vec![0_u8; MAX_CELL_OUTBOX_PAYLOAD_BYTES + 1]); + assert!(parse_cell_outbox(&json!({ "outbox": [oversized] })).is_err()); + } + + #[test] + fn rejects_expired_or_unreasonably_distant_claim_leases_without_panicking() { + let mut expired = valid_item(); + expired["status"] = json!("in_flight"); + expired["attempts"] = json!(1); + expired["workerId"] = json!("worker-1"); + expired["leasedUntilUnixMs"] = json!(1); + assert!(parse_claimed_cell_outbox(&json!({ "outbox": [expired] })).is_err()); + + let mut overflowing = valid_item(); + overflowing["status"] = json!("in_flight"); + overflowing["attempts"] = json!(1); + overflowing["workerId"] = json!("worker-1"); + overflowing["leasedUntilUnixMs"] = json!(u64::MAX); + assert!(parse_claimed_cell_outbox(&json!({ "outbox": [overflowing] })).is_err()); + } + + #[test] + fn precommit_validation_enforces_the_encoded_wire_budget() { + let rows = (0..MAX_CELL_OUTBOX_ITEMS) + .map(|index| { + OutboxMessage::create_with_metadata( + format!("event-{index}"), + "todo.created", + vec![255; 4_096], + HashMap::new(), + ) + .expect("message") + }) + .collect::>(); + assert!(validate_cell_outbox_messages(&rows).is_err()); + } +} diff --git a/src/microsvc/dependencies.rs b/src/microsvc/dependencies.rs index 0cdb2633..d618e2d2 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 @@ -105,8 +109,8 @@ where /// immediately. /// /// `Service::with_bus` installs an [`OutboxPublisherConfig`] through this so that -/// `repo.outbox(msg).commit(agg)` publishes the row right after commit. Without -/// it, commits leave the row `pending` for the polling worker. +/// `repo.outbox(msg).commit(agg)` enqueues the pending row for the bounded +/// worker. Without it, commits leave the row `pending` for a polling worker. pub trait ConfigurableOutboxPublisher { /// Install the outbox publisher. fn configure_outbox_publisher(&mut self, config: OutboxPublisherConfig); diff --git a/src/microsvc/grpc.rs b/src/microsvc/grpc.rs index 8499b679..23bc31aa 100644 --- a/src/microsvc/grpc.rs +++ b/src/microsvc/grpc.rs @@ -169,6 +169,44 @@ impl CommandService for GrpcHandler { // [`build_session`] and the `Session` trust-boundary docs. let session = build_session(&metadata, req.session_variables); + #[cfg(feature = "graphql")] + let wait_path = match super::wait_path::parse_wait_path_body(&input) { + Ok(wait_path) => wait_path, + Err(error) => { + return Ok(Response::new(GrpcResponse { + status: 400, + body: json!({ "code": "BAD_REQUEST", "error": error }).to_string(), + })); + } + }; + #[cfg(feature = "graphql")] + if let Some((command_id, wait_input)) = wait_path { + 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 45ac65c1..ce71f665 100644 --- a/src/microsvc/http.rs +++ b/src/microsvc/http.rs @@ -124,14 +124,55 @@ 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")] + let wait_path = match super::wait_path::parse_wait_path_body(&body) { + Ok(wait_path) => wait_path, + Err(error) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "code": "BAD_REQUEST", "error": error })), + ) + .into_response(); + } + }; + #[cfg(feature = "graphql")] + if let Some((command_id, input)) = wait_path { + 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 e6f1f6dc..55e8798d 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; @@ -112,19 +113,25 @@ pub use service::GraphqlServiceBindError; feature = "rabbitmq", feature = "kafka", ))] -pub use workers::{spawn_outbox_publish_loop, spawn_service_consumer_loop}; +pub use workers::{ + spawn_outbox_publish_loop, spawn_service_consumer_loop, CONSUMER_IDLE_POLL, +}; pub use service::{ direct_read_model, invoke_transition, require_loaded, CausalCommandContext, CausalCommitBuilder, CausalRepository, CommandRequest, CommandResponse, DeliveryKind, DirectReadModelProjection, - HandlerNames, HandlerSpec, PreparedCausalCommit, PreparedCommandHandler, RouteBuilder, Routes, - Service, ThinCommandBuilder, ThinCommandInvoked, ThinCommandLoaded, TypedRouteBuilder, + HandlerNames, HandlerSpec, PortableCommand, PreparedCausalCommit, PreparedCommandHandler, + RouteBuilder, Routes, Service, ThinCommandBuilder, ThinCommandInvoked, ThinCommandLoaded, + TypedRouteBuilder, }; #[cfg(feature = "graphql")] +pub use service::{CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult}; +#[cfg(feature = "graphql")] pub(crate) use service::{ - CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalDispatchError, CausalDispatchResult, + CausalCommandProjectionObligation, CausalCommandPublicState, 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/runtime.rs b/src/microsvc/runtime.rs index edfb8618..25b77d9d 100644 --- a/src/microsvc/runtime.rs +++ b/src/microsvc/runtime.rs @@ -30,17 +30,16 @@ impl Service { /// /// Two effects, both composing with the rest of the builder: /// - installs an outbox publisher on the repository, so - /// `repo.outbox(msg).commit(agg)` claims the row in the commit transaction - /// and publishes it immediately after commit through this bus (a polling - /// worker may be operated as the crash/retry backstop); + /// `repo.outbox(msg).commit(agg)` writes pending rows and enqueues their + /// ids on a bounded worker after commit (overflow wakes `dispatch_batch`); /// - captures how to consume, so [`run`](Self::run) listens for the /// registered command names (competing) and subscribes to the event names /// (fan-out). /// - /// `with_bus` and [`run`](Self::run) do not start the drain loop. Spawn - /// [`crate::OutboxDrainRunner`] (or `microsvc::spawn_outbox_publish_loop`) - /// next to `run` when durable recovery after a commit-to-publish crash is - /// required. + /// The worker also polls pending rows, so a crash between commit and + /// publish is recovered in-process. Spawn a separate + /// [`crate::OutboxDrainRunner`] next to `run` only when you want an extra + /// competing drainer. pub fn with_bus(mut self, bus: B) -> Self where B: Bus + BusConsumer + 'static, @@ -166,6 +165,25 @@ mod tests { use crate::bus::{Bus, InMemoryBus, RunOptions}; use crate::microsvc::{Context, HandlerError, Routes, Service, Session}; use crate::outbox_worker::OutboxStore; + + async fn wait_until_published(store: &impl OutboxStore, count: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if store + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) + .await + .unwrap() + .len() + >= count + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("immediate publish should settle outbox rows"); + } use crate::{ sourced, AggregateBuilder, AggregateRepository, Entity, InMemoryRepository, OutboxMessage, OutboxMessageStatus, Queueable, QueuedRepository, Snapshot, TransactionalCommit, @@ -231,6 +249,9 @@ mod tests { .await .unwrap(); + wait_until_published(&store_a, 1).await; + wait_until_published(&store_b, 1).await; + let published_a = store_a .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await @@ -303,13 +324,14 @@ mod tests { ) .with_bus(InMemoryBus::new()); - // The handler runs `outbox().commit()`: claim-in-transaction, then - // immediate publish through the attached bus. + // The handler runs `outbox().commit()`: pending row, then the + // bounded worker publishes through the attached bus. service .dispatch("dummy.touch", json!({}), Session::new()) .await .unwrap(); + wait_until_published(&store, 1).await; let published = store .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await @@ -338,6 +360,7 @@ mod tests { // `run` returns once the queue is empty (InMemoryBus yields `None`). bus.send("dummy.touch", b"{}".to_vec()).await.unwrap(); service.run(RunOptions::idempotent()).await.unwrap(); + wait_until_published(&store, 1).await; let published = store .messages_by_status(OutboxMessageStatus::Published, usize::MAX) @@ -433,6 +456,7 @@ mod tests { .dispatch("snap.touch", json!({}), Session::new()) .await .unwrap(); + wait_until_published(&store, 1).await; let published = store .messages_by_status(OutboxMessageStatus::Published, usize::MAX) diff --git a/src/microsvc/service/causal.rs b/src/microsvc/service/causal.rs index 19b78468..ec02c170 100644 --- a/src/microsvc/service/causal.rs +++ b/src/microsvc/service/causal.rs @@ -8,7 +8,7 @@ use serde_json::Value; use crate::command_ledger::CausalTransactionalCommit; #[cfg(feature = "graphql")] use crate::command_ledger::{ - AttemptFence, CausalCommitBatch, CommandAttempt, CommandId, CommandLedgerError, + AttemptFence, CausalCommitBatch, CausationId, CommandAttempt, CommandId, CommandLedgerError, CommandLedgerState, CommandLedgerStore, CommandLookup, CommandLookupScope, CommandReplay, TerminalCommandState, }; @@ -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,9 +214,250 @@ 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, + /// Cell wait-path outbox rows to drain onto the bus. Empty for local hosts. + pub(crate) outbox: Vec, +} + +#[cfg(feature = "graphql")] +impl CausalDispatchResult { + /// Handler payload returned to the wait-path caller. + pub fn payload(&self) -> &Value { + &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 + } + + /// Outbox rows the wait-path cell committed with the aggregate. + pub fn outbox(&self) -> &[crate::OutboxMessage] { + &self.outbox + } + + /// Parse cell wait-path `outbox` even when the HTTP status is 409. + pub fn outbox_from_wait_path( + body: &Value, + ) -> Result, CausalDispatchError> { + crate::microsvc::cell_host::parse_cell_outbox(body).map_err(|error| { + CausalDispatchError::Internal(format!("invalid cell outbox response: {error}")) + }) + } + + /// 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() + } + + /// Fill Eventual modeled projection metadata from cell-committed outbox + /// rows. Wait-path JSON only has `{ commandId, state }`; the generated + /// replica still requires the same projection delta local dispatch records. + pub(crate) fn seal_wait_path_protocol( + mut self, + protocol: &crate::graphql::protocol::ProtocolResponseAccumulator, + contract: &TypedCommandContract, + replay_retention: Duration, + ) -> Result { + self.receipt.command_name = contract.name.clone(); + self.receipt.consistency = contract.consistency; + if contract.consistency == CommandConsistency::Atomic + || contract.projections.selectors.is_empty() + || self.outbox.is_empty() + { + return Ok(self); + } + let occurrences = self + .outbox + .iter() + .map(|row| { + row.domain_event_occurrence().map_err(|error| { + CausalDispatchError::Internal(format!( + "wait-path outbox is not a domain occurrence: {error}" + )) + }) + }) + .collect::, _>>()?; + let causation = occurrences + .iter() + .find_map(|occurrence| occurrence.metadata().get("causation_id").cloned()) + .or_else(|| { + self.outbox + .iter() + .find_map(|row| row.metadata.get("causation_id").cloned()) + }) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + CausalDispatchError::Internal( + "wait-path outbox missing causation_id for modeled projection".into(), + ) + })?; + let causation_id = CausationId::parse_stored(causation).map_err(|error| { + CausalDispatchError::Internal(format!("wait-path causation id: {error}")) + })?; + self.receipt.causation_id = causation_id.as_str().to_string(); + let metadata = protocol + .projection_metadata_for_actual( + causation_id, + replay_retention, + &occurrences, + &contract.projections.selectors, + ) + .map_err(|error| { + CausalDispatchError::Internal(format!("wait-path projection metadata: {error}")) + })?; + // Cell wait-path has no GraphQL command-ledger observations. Keep the + // modeled delta so the replica can apply it, but drop expects so + // `projected` does not wait on live/status observations this process + // cannot emit. Preserve the delta's own recovery disposition: a fully + // resolved actual delta is sufficient local authority and must not turn + // every successful cell command into a full-query revalidation. + let metadata = if metadata.obligations.is_empty() { + metadata + } else { + let revalidate = metadata.revalidate; + crate::graphql::protocol::CommandProjectionMetadataV1::try_new( + metadata.issued_at_unix_ms, + metadata.expires_at_unix_ms, + metadata.delta, + metadata.lifecycle_proofs, + Vec::new(), + revalidate, + ) + .map_err(|error| { + CausalDispatchError::Internal(format!( + "wait-path projection metadata without ledger observations: {error}" + )) + })? + }; + self.receipt.state = CommandLedgerState::Succeeded; + self.receipt.projection_metadata = Some(metadata); + Ok(self) + } + + /// Status envelope matching this wait-path receipt so commandStatus can + /// complete Eventual expects without a local command-ledger row. + pub fn public_status(&self) -> CausalCommandPublicStatus { + let state = match self.receipt.state { + CommandLedgerState::InProgress | CommandLedgerState::RetryableUnknown => { + CausalCommandPublicState::InProgress + } + CommandLedgerState::Succeeded => CausalCommandPublicState::Succeeded, + CommandLedgerState::SucceededPendingProjection => { + CausalCommandPublicState::SucceededPendingProjection + } + CommandLedgerState::Atomic => CausalCommandPublicState::Atomic, + CommandLedgerState::Rejected => CausalCommandPublicState::Rejected, + CommandLedgerState::ProjectionFailed => CausalCommandPublicState::ProjectionFailed, + CommandLedgerState::Expired => CausalCommandPublicState::Expired, + }; + let evidence = self + .receipt + .projection_metadata + .as_ref() + .map(|metadata| { + metadata + .obligations + .iter() + .enumerate() + .map(|(index, _)| CausalCommandProjectionEvidence { + obligation_index: index, + state: CausalProjectionEvidenceState::Observed, + incarnation: None, + revision: None, + }) + .collect() + }) + .unwrap_or_default(); + CausalCommandPublicStatus { + state, + command_id: self.receipt.command_id.clone(), + command_name: (!self.receipt.command_name.is_empty()) + .then(|| self.receipt.command_name.clone()), + causation_id: (!self.receipt.causation_id.is_empty()) + .then(|| self.receipt.causation_id.clone()), + consistency: Some(self.receipt.consistency), + outcome: Some(self.payload.clone()), + obligations: self.receipt.obligations.clone(), + projection_metadata: self.receipt.projection_metadata.clone(), + projection_revalidate: false, + evidence, + direct_projection: self.receipt.direct_projection.clone(), + } + } + + /// Rebuild a receipt from the HTTP/gRPC wait-path JSON envelope. + pub fn from_wait_path_wire(body: Value) -> Result { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct ReceiptWire { + command_id: String, + causation_id: String, + state: String, + #[serde(default)] + replayed: Option, + } + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct ResponseWire { + #[serde(default)] + payload: Value, + receipt: ReceiptWire, + #[serde(default)] + outbox: Vec, + } + + let wire: ResponseWire = serde_json::from_value(body).map_err(|error| { + CausalDispatchError::Internal(format!("invalid wait-path response: {error}")) + })?; + if wire.receipt.command_id.trim().is_empty() + || wire.receipt.command_id.len() > 512 + || wire.receipt.causation_id.len() > 512 + { + return Err(CausalDispatchError::Internal( + "wait-path receipt contains an invalid identifier".into(), + )); + } + let state = crate::command_ledger::CommandLedgerState::parse(&wire.receipt.state).map_err( + |err| CausalDispatchError::Internal(format!("wait-path receipt state: {err}")), + )?; + let _ = wire.receipt.replayed; + let outbox = crate::microsvc::cell_host::parse_cell_outbox(&serde_json::json!({ + "outbox": wire.outbox + })) + .map_err(|error| { + CausalDispatchError::Internal(format!("invalid cell outbox response: {error}")) + })?; + Ok(Self { + payload: wire.payload, + outbox, + receipt: CausalCommandReceiptSource { + command_id: wire.receipt.command_id, + command_name: String::new(), + causation_id: wire.receipt.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. @@ -278,7 +519,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. @@ -297,7 +538,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(), @@ -313,7 +554,7 @@ impl CausalCommandPublicStatus { } } - pub(super) fn is_unknown(&self) -> bool { + pub fn is_unknown(&self) -> bool { self.state == CausalCommandPublicState::Unknown } } @@ -349,9 +590,7 @@ pub(super) fn ensure_causal_grant( ) { Ok(()) => Ok(()), Err("unauthenticated") => Err(CausalDispatchError::Handler( - crate::microsvc::HandlerError::Unauthorized( - "missing authenticated principal".into(), - ), + crate::microsvc::HandlerError::Unauthorized("missing authenticated principal".into()), )), Err(_) => Err(CausalDispatchError::Forbidden), } @@ -388,6 +627,7 @@ pub(super) fn replay_result( Ok(CausalDispatchResult { payload: receipt.outcome.clone(), receipt, + outbox: Vec::new(), }) } CommandLedgerState::Rejected => replay_rejection(replay.outcome), diff --git a/src/microsvc/service/handlers.rs b/src/microsvc/service/handlers.rs index cc3d892d..b64fa38e 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/mod.rs b/src/microsvc/service/mod.rs index 92970027..034e187f 100644 --- a/src/microsvc/service/mod.rs +++ b/src/microsvc/service/mod.rs @@ -41,9 +41,10 @@ pub(crate) use causal::CausalCommandProjectionEvidence; #[cfg(feature = "graphql")] pub use causal::GraphqlServiceBindError; #[cfg(feature = "graphql")] +pub use causal::{CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult}; +#[cfg(feature = "graphql")] pub(crate) use causal::{ - CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalDispatchError, CausalDispatchResult, + CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandReceiptSource, CausalProjectionEvidenceState, }; #[allow(unused_imports)] // public API surface for handler-owned projected commits @@ -56,8 +57,8 @@ pub use invoke::{invoke_transition, require_loaded}; pub use request::{CommandRequest, CommandResponse}; pub(crate) use routes::DynBusPublisher; pub use routes::{ - DeliveryKind, HandlerNames, HandlerSpec, RouteBuilder, Routes, ThinCommandBuilder, - ThinCommandInvoked, ThinCommandLoaded, TypedRouteBuilder, + DeliveryKind, HandlerNames, HandlerSpec, PortableCommand, RouteBuilder, Routes, + ThinCommandBuilder, ThinCommandInvoked, ThinCommandLoaded, TypedRouteBuilder, }; pub use runtime::Service; diff --git a/src/microsvc/service/routes.rs b/src/microsvc/service/routes.rs index 4e3abbaa..d4e74e7c 100644 --- a/src/microsvc/service/routes.rs +++ b/src/microsvc/service/routes.rs @@ -23,27 +23,25 @@ use super::handlers::{ use crate::aggregate::Aggregate; use crate::application::{CommandMount, CommandMountRegistrar, CommandSpec}; use crate::bus::{Bus, Message, MessageKind, MessagePublisher, OrderedDelivery, TransportError}; -#[cfg(feature = "graphql")] use crate::command_ledger::{ - CanonicalInputHash, CausalCommitBatch, CausalRepositoryIdentity, CausalTransactionalCommit, - CommandContractFingerprint, CommandId, CommandLedgerKey, CommandLedgerStore, CommandLookup, - CommandLookupScope, CommandReservation, PrincipalPartitionId, ReservationOutcome, - TerminalCommandState, + CanonicalInputHash, CausalCommitBatch, CausalTransactionalCommit, CommandContractFingerprint, + CommandLedgerStore, CommandReservation, ReservationOutcome, TerminalCommandState, }; #[cfg(feature = "graphql")] +use crate::command_ledger::{ + CausalRepositoryIdentity, CommandId, CommandLedgerKey, CommandLookup, CommandLookupScope, + PrincipalPartitionId, +}; use crate::graphql::command_contract::CommandConsistency; use crate::graphql::command_contract::{ CommandEventSet, CommandOutcome, CompiledInputDefaults, TypedCommandContract, }; -#[cfg(feature = "graphql")] 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::graphql::{command_transition, GraphqlInputType, SurfaceProjector, TypedCommand}; use crate::microsvc::causal::CausalWorkspace; +use crate::microsvc::cell_host::{CellCommandIdentity, CellDispatchError, CellDispatchResult}; use crate::microsvc::context::Context; use crate::microsvc::dependencies::{ CausalProjectionRouteDependencies, CausalRouteDependencies, ConfigurableOutboxPublisher, @@ -59,10 +57,25 @@ use crate::microsvc::projector::{ModeledProjectorHandlerFn, ModeledProjectorRout use crate::microsvc::session::Session; use crate::outbox::OutboxPublisherConfig; use crate::outbox_worker::BusOutboxPublishHook; +#[cfg(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", + test, +))] +use crate::outbox_worker::{ + OutboxDispatcher, OutboxDrainRunner, OutboxPublishMailbox, DEFAULT_DRAIN_LEASE, + DEFAULT_OUTBOX_HINT_CAPACITY, +}; #[cfg(feature = "graphql")] 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. @@ -176,6 +189,8 @@ pub(super) type CausalHandlerFuture<'a> = pub(super) type CausalStatusFuture<'a> = Pin< Box> + Send + 'a>, >; +pub(super) type CellCausalHandlerFuture<'a> = + Pin> + Send + 'a>>; pub(super) trait ErasedCausalHandler: Send + Sync { fn contract(&self) -> &TypedCommandContract; @@ -200,6 +215,15 @@ pub(super) trait ErasedCausalHandler: Send + Sync { protocol: Option, ) -> CausalHandlerFuture<'a>; + fn dispatch_cell_causal<'a>( + &'a self, + dependencies: &'a D, + identity: &'a CellCommandIdentity, + input: Value, + session: Session, + shard: &'a StreamIdentity, + ) -> CellCausalHandlerFuture<'a>; + #[cfg(feature = "graphql")] #[allow(dead_code)] fn lookup<'a>( @@ -221,6 +245,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 @@ -229,9 +262,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 @@ -413,13 +444,77 @@ pub(super) fn configure_outbox_for( D: HasOutboxStore + ConfigurableOutboxPublisher, D::OutboxStore: 'static, { - let hook = BusOutboxPublishHook::new(dependencies.outbox_store(), publisher, max_attempts) - .with_service(service_name); - dependencies.configure_outbox_publisher(OutboxPublisherConfig::new( - Arc::new(hook), - worker_id, - lease, - )); + let hook = + BusOutboxPublishHook::new(dependencies.outbox_store(), publisher.clone(), max_attempts) + .with_service(service_name.clone()); + + #[cfg(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", + test, + ))] + let config = { + let (mailbox, rx, wake) = OutboxPublishMailbox::channel(DEFAULT_OUTBOX_HINT_CAPACITY); + let mut dispatcher = OutboxDispatcher::new( + dependencies.outbox_store(), + publisher, + worker_id.clone(), + DEFAULT_DRAIN_LEASE, + max_attempts, + ); + if let Some(name) = service_name { + dispatcher = dispatcher.with_service(name); + } + OutboxDrainRunner::new(dispatcher) + .with_hints(rx, wake) + .spawn(); + OutboxPublisherConfig::new(Arc::new(hook), worker_id, lease) + .with_schedule(Arc::new(move |ids| mailbox.try_submit(ids))) + }; + + #[cfg(not(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", + test, + )))] + let config = { + let _ = publisher; + OutboxPublisherConfig::new(Arc::new(hook), worker_id, lease) + }; + + dependencies.configure_outbox_publisher(config); +} + +/// Domain-owned command that can install itself onto a host [`Routes`] bundle. +/// +/// Command declarations live next to the aggregate. SOA and later cell hosts +/// call [`Routes::mount`] with the same value; the declaration must not name +/// sqlx, celld, or `QueuedRepository`. +/// +/// Prefer [`crate::portable_command!`] (`PCH-DEC-001`) for shard + invoke + +/// Eventual. The fluent builder on [`Routes`] remains the expansion target. +pub trait PortableCommand { + /// Register this command on `routes` and return the bundle. + fn install(self, routes: Routes) -> Routes; +} + +impl PortableCommand for F +where + F: FnOnce(Routes) -> Routes, +{ + fn install(self, routes: Routes) -> Routes { + self(routes) + } } /// Builder returned by [`Routes::command`], [`Routes::event`], @@ -636,16 +731,9 @@ where { /// Apply a domain method. The third argument is the session principal /// when `.roles` require one. - pub fn invoke( - mut self, - transition: T, - ) -> ThinCommandBuilder + pub fn invoke(mut self, transition: T) -> ThinCommandBuilder where - T: Fn( - &mut crate::microsvc::AggregateCheckout, - &I, - &str, - ) -> Result<(), E> + T: Fn(&mut crate::microsvc::AggregateCheckout, &I, &str) -> Result<(), E> + Send + Sync + 'static, @@ -759,11 +847,7 @@ where >, >; - fn call( - &self, - ctx: &'a CausalCommandContext<'a, A>, - input: I, - ) -> Self::Future { + fn call(&self, ctx: &'a CausalCommandContext<'a, A>, input: I) -> Self::Future { let load_id = self.load_id.clone(); let create = self.create; let transition = self.transition.clone(); @@ -824,11 +908,7 @@ where >, >; - fn call( - &self, - ctx: &'a CausalCommandContext<'a, A>, - input: I, - ) -> Self::Future { + fn call(&self, ctx: &'a CausalCommandContext<'a, A>, input: I) -> Self::Future { let load_id = self.load_id.clone(); let create = self.create; let transition = self.transition.clone(); @@ -928,6 +1008,68 @@ impl Routes { self.handler(HandlerSpec::command(name)) } + /// Install a domain-owned command declaration onto this host bundle. + pub fn mount(self, command: impl PortableCommand) -> Self { + 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())), + } + } + + /// Dispatch a typed cell command through the same fenced ledger contract + /// as the in-process causal wait path. + pub(in crate::microsvc) async fn dispatch_cell_causal( + &self, + command: &str, + identity: &CellCommandIdentity, + 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 + .dispatch_cell_causal(&self.dependencies, identity, input, session, shard) + .await + } + Some(_) | None => Err(CellDispatchError::BadRequest(format!( + "`{command}` is not a typed causal command" + ))), + } + } + + 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 @@ -1175,9 +1317,8 @@ impl Routes { ); let spec = CommandSpec::from_contract(&contract) .unwrap_or_else(|error| panic!("typed command contract cannot compile: {error}")); - let mount = declared_mount.unwrap_or_else(|| { - CommandMount::from_typed_route(spec.clone(), route_name) - }); + let mount = declared_mount + .unwrap_or_else(|| CommandMount::from_typed_route(spec.clone(), route_name)); assert_eq!( mount.spec().id, spec.id, @@ -1386,6 +1527,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, @@ -1394,6 +1536,235 @@ where &self.contract } + fn dispatch_cell_causal<'a>( + &'a self, + dependencies: &'a D, + identity: &'a CellCommandIdentity, + input: Value, + session: Session, + shard: &'a StreamIdentity, + ) -> CellCausalHandlerFuture<'a> { + Box::pin(async move { + match crate::application::admit_command_session( + &self.contract.roles, + session.user_id(), + &session.roles(), + ) { + Ok(()) => {} + Err("unauthenticated") => return Err(CellDispatchError::Unauthorized), + Err(_) => return Err(CellDispatchError::Forbidden), + } + if self.contract.consistency == CommandConsistency::Atomic { + return Err(CellDispatchError::BadRequest( + "atomic typed commands require a same-transaction relational projection host" + .into(), + )); + } + + let canonical = canonicalize_command_input(&self.contract.input, input) + .map_err(|error| CellDispatchError::BadRequest(error.to_string()))?; + let typed = canonical + .decode::() + .map_err(|error| CellDispatchError::BadRequest(error.to_string()))?; + let (input, wire, input_digest) = typed.into_parts(); + let policy = CausalCommandPolicy::default(); + let reservation = CommandReservation::new( + identity.key().clone(), + self.contract.name.clone(), + CommandContractFingerprint::new(self.contract.fingerprint_bytes()), + CanonicalInputHash::new(input_digest), + policy.attempt_lease, + policy.replay_retention, + ) + .map_err(crate::microsvc::cell_host::causal::internal_ledger_error)?; + + let aggregate_repository = dependencies.__causal_aggregate_repository(); + let repository = aggregate_repository.repo(); + let attempt = match repository + .reserve_command(reservation) + .await + .map_err(crate::microsvc::cell_host::causal::internal_ledger_error)? + { + ReservationOutcome::Acquired(attempt) => attempt, + ReservationOutcome::InProgress { .. } => { + return Err(CellDispatchError::InProgress); + } + ReservationOutcome::Replay(replay) => { + return crate::microsvc::cell_host::causal::replay_result(replay, true); + } + ReservationOutcome::Conflict => return Err(CellDispatchError::CommandIdReuse), + ReservationOutcome::Expired => return Err(CellDispatchError::Expired), + }; + + let payload = serde_json::to_vec(&wire).map_err(|error| { + CellDispatchError::Internal(format!( + "canonical cell command input could not be encoded: {error}" + )) + })?; + let mut metadata = session + .variables() + .iter() + .filter(|(name, _)| !name.eq_ignore_ascii_case(crate::trace_context::CAUSATION_ID)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>(); + metadata.push(( + crate::trace_context::CAUSATION_ID.to_string(), + attempt.causation_id().as_str().to_string(), + )); + let message = Message { + id: Some(identity.command_id().to_string()), + name: self.contract.name.clone(), + kind: MessageKind::Command, + payload, + content_type: "application/json".into(), + metadata, + }; + + 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 crate::microsvc::cell_host::causal::commit_rejection( + repository, + attempt, + policy.replay_retention, + "REJECTED", + 422, + format!("guard rejected command: {}", self.contract.name), + ) + .await; + } + + let mut prepared = match (self.handle)(&context, input).await { + Ok(prepared) => prepared, + Err(error) if error.status_code() < 500 => { + return crate::microsvc::cell_host::causal::commit_rejection( + repository, + attempt, + policy.replay_retention, + crate::microsvc::cell_host::causal::handler_error_code(&error), + error.status_code(), + error.client_facing_message(), + ) + .await; + } + Err(error) => { + return crate::microsvc::cell_host::causal::abandon_attempt( + repository, + attempt, + error.to_string(), + ) + .await; + } + }; + + let mut parts = match workspace.into_parts() { + Ok(parts) => parts, + Err(error) => { + return crate::microsvc::cell_host::causal::abandon_attempt( + repository, + attempt, + error.to_string(), + ) + .await; + } + }; + if let Err(error) = parts.prepare_domain_publications(attempt.causation_id().as_str()) { + return crate::microsvc::cell_host::causal::abandon_attempt( + repository, + attempt, + error.to_string(), + ) + .await; + } + if let Err(error) = parts.validate_prepared(&self.contract, &mut prepared) { + return crate::microsvc::cell_host::causal::abandon_attempt( + repository, + attempt, + error.to_string(), + ) + .await; + } + + let replay_payload = prepared.serialized_payload().clone(); + let batch = match parts.prepare_commit_batch() { + Ok(batch) => batch, + Err(error) => { + return crate::microsvc::cell_host::causal::abandon_attempt( + repository, + attempt, + format!("cell causal commit batch preparation failed: {error}"), + ) + .await; + } + }; + if let Err(error) = + crate::microsvc::cell_host::validate_cell_outbox_messages(&batch.outbox_messages) + { + drop(batch); + return crate::microsvc::cell_host::causal::abandon_attempt( + repository, + attempt, + format!("cell outbox violates its transport bounds: {error}"), + ) + .await; + } + let foreign_stream = batch + .streams + .iter() + .find(|stream| stream.identity != *shard) + .map(|stream| stream.identity.to_string()); + if let Some(foreign_stream) = foreign_stream { + drop(batch); + return crate::microsvc::cell_host::causal::abandon_attempt( + repository, + attempt, + format!("cell `{shard}` cannot commit stream `{foreign_stream}`"), + ) + .await; + } + + let fence = attempt.fence(); + let completion = attempt + .complete( + TerminalCommandState::Succeeded, + replay_payload.clone(), + policy.replay_retention, + ) + .map_err(crate::microsvc::cell_host::causal::internal_ledger_error)?; + match repository + .commit_causal_batch(CausalCommitBatch::new(batch, completion)) + .await + { + Ok(()) => { + parts.mark_committed_state().map_err(|error| { + CellDispatchError::Internal(format!( + "committed cell workspace cleanup failed: {error}" + )) + })?; + let (_committed, serialized) = prepared.finalize_after_commit(); + let result = crate::microsvc::cell_host::causal::load_committed_result( + repository, &fence, false, + ) + .await?; + if result.payload() != &serialized { + return Err(CellDispatchError::Internal( + "durable cell replay differs from the committed handler payload".into(), + )); + } + Ok(result) + } + Err(error) => { + crate::microsvc::cell_host::causal::recover_commit_error( + repository, + fence, + error.to_string(), + ) + .await + } + } + }) + } + #[cfg(feature = "graphql")] fn contract_mut(&mut self) -> &mut TypedCommandContract { &mut self.contract @@ -1714,26 +2085,22 @@ where } }; - // Match the ordinary aggregate commit path: when Service::with_bus - // installed an immediate publisher, make each fresh outbox row - // InFlight inside the same fenced transaction and publish it only - // after that transaction succeeds. A crash or publish failure leaves - // the durable lease for a separately operated polling worker to - // recover. - let mut claimed = Vec::new(); + // Stamp causation, leave rows pending for the bounded worker, and + // only claim in-transaction when there is no mailbox (hook fallback). + let mut fallback_rows = Vec::new(); + let mut outbox_ids = Vec::new(); if let Some(config) = publisher { - let now = 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 { - // The post-commit hook receives clones of this staged - // batch. Stamp before cloning so the broker copy and the - // persisted row carry the same authoritative causation. message.overwrite_causation_id(attempt.causation_id().as_str()); - if let Err(error) = message.claim_at(&config.worker_id, config.lease, now) { - claim_error = Some(error.to_string()); - break; + outbox_ids.push(message.id().to_string()); + if let Some(now) = claim_now { + if let Err(error) = message.claim_at(&config.worker_id, config.lease, now) { + claim_error = Some(error.to_string()); + break; + } } - claimed.push(message.clone()); } if let Some(error) = claim_error { drop(batch); @@ -1745,6 +2112,9 @@ where ) .await; } + if config.schedule.is_none() { + fallback_rows = batch.outbox_messages.clone(); + } } let fence = attempt.fence(); @@ -1780,7 +2150,8 @@ where )) })?; if let Some(config) = publisher { - let _ = config.hook.publish_claimed(claimed).await; + crate::outbox::start_immediate_publish(config, outbox_ids, fallback_rows) + .await; } let (_committed, serialized) = prepared.finalize_after_commit(); let result = load_committed_dispatch_result( @@ -1880,6 +2251,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/runtime.rs b/src/microsvc/service/runtime.rs index d7c79f2f..9113405e 100644 --- a/src/microsvc/service/runtime.rs +++ b/src/microsvc/service/runtime.rs @@ -581,6 +581,31 @@ impl Service { Ok(specs) } + /// Attach Eventual projection metadata to a cell wait-path result using this + /// process's command contract (no second aggregate write). + #[cfg(feature = "graphql")] + pub fn seal_wait_path_dispatch( + &self, + command: &str, + protocol: &crate::graphql::protocol::ProtocolResponseAccumulator, + result: CausalDispatchResult, + ) -> Result { + let contract = self + .typed_command_contracts() + .into_iter() + .find(|contract| contract.name == command) + .ok_or_else(|| { + CausalDispatchError::BadRequest(format!( + "unknown typed command `{command}` for wait-path protocol" + )) + })?; + result.seal_wait_path_protocol( + protocol, + &contract, + self.causal_command_policy.replay_retention, + ) + } + pub(crate) fn typed_command_binding(&self) -> Result { let service_id = self .name() @@ -591,8 +616,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 +632,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, @@ -687,7 +711,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/src/microsvc/service/tests.rs b/src/microsvc/service/tests.rs index 39cc1cc5..7cf9340b 100644 --- a/src/microsvc/service/tests.rs +++ b/src/microsvc/service/tests.rs @@ -764,10 +764,17 @@ fn causal_test_input(id: &str, label: &str) -> Value { fn session_with_role(role: &str) -> Session { let mut session = Session::new(); session.set(crate::microsvc::ROLE_KEY, role); - session.set(crate::microsvc::USER_ID_KEY, "causal-test-user"); + session.set(crate::microsvc::USER_ID_KEY, "causal-test-subject"); 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 { @@ -1008,6 +1015,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>( @@ -1217,8 +1234,7 @@ async fn generated_mount_registers_and_executes_original_handler_through_causal_ input: json!({"id": "generated-1", "label": "mounted"}), session_variables: HashMap::new(), }; - let result = service - .registered_command_mounts()[0] + let result = service.registered_command_mounts()[0] .invoke_with( &service, &request, @@ -1229,8 +1245,8 @@ async fn generated_mount_registers_and_executes_original_handler_through_causal_ }, ) .await; - let crate::application::CommandMountExecutionResult::Causal(result) = result - .expect("authenticated causal mount dispatch should commit") + let crate::application::CommandMountExecutionResult::Causal(result) = + result.expect("authenticated causal mount dispatch should commit") else { panic!("typed mount must use the causal execution result"); }; @@ -2006,6 +2022,25 @@ async fn causal_dispatch_uses_the_configured_immediate_outbox_publisher() { .expect("causal dispatch should commit before immediate publication"); let outbox = repository.outbox_store(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if !outbox.pending(usize::MAX).await.unwrap().is_empty() { + tokio::task::yield_now().await; + continue; + } + if !outbox + .messages_by_status(crate::outbox::OutboxMessageStatus::Published, usize::MAX) + .await + .unwrap() + .is_empty() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("immediate publish should settle the causal outbox row"); assert!(outbox.pending(usize::MAX).await.unwrap().is_empty()); let published = outbox .messages_by_status(crate::outbox::OutboxMessageStatus::Published, usize::MAX) @@ -2376,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; @@ -2413,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; @@ -2457,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; @@ -2500,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; @@ -2537,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/src/microsvc/wait_path.rs b/src/microsvc/wait_path.rs new file mode 100644 index 00000000..cb95f825 --- /dev/null +++ b/src/microsvc/wait_path.rs @@ -0,0 +1,102 @@ +//! 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::service::{CausalDispatchError, CausalDispatchResult, Service}; +use super::session::Session; +use crate::graphql::identity::VerifiedPrincipal; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct WaitPathBody { + command_id: String, + #[serde(default)] + input: Value, +} + +/// Parse a wait-path body. Identity-shaped JSON fields are rejected rather than +/// ignored so a caller cannot depend on ambiguous authorization behavior. +pub(crate) fn parse_wait_path_body(value: &Value) -> Result, &'static str> { + if value.get("commandId").is_none() { + return Ok(None); + } + let parsed: WaitPathBody = + serde_json::from_value(value.clone()).map_err(|_| "invalid wait-path envelope")?; + if parsed.command_id.trim().is_empty() + || parsed.command_id.len() > 512 + || parsed.command_id.chars().any(char::is_control) + { + return Err("invalid wait-path commandId"); + } + let input = if parsed.input.is_null() { + json!({}) + } else { + parsed.input + }; + Ok(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 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strict_envelope_does_not_fall_back_or_accept_identity_smuggling() { + assert!(parse_wait_path_body(&json!({ "title": "legacy" })) + .expect("legacy") + .is_none()); + let parsed = parse_wait_path_body(&json!({ + "commandId": "command-1", + "input": { "title": "safe" } + })) + .expect("valid") + .expect("wait path"); + assert_eq!(parsed.0, "command-1"); + assert_eq!(parsed.1, json!({ "title": "safe" })); + + assert!(parse_wait_path_body(&json!({ + "commandId": "command-1", + "input": {}, + "roles": ["admin"] + })) + .is_err()); + assert!(parse_wait_path_body(&json!({ "commandId": " " })).is_err()); + } +} diff --git a/src/microsvc/workers.rs b/src/microsvc/workers.rs index 3a4b5a98..1b41e68d 100644 --- a/src/microsvc/workers.rs +++ b/src/microsvc/workers.rs @@ -40,7 +40,20 @@ pub fn spawn_outbox_publish_loop( .spawn(); } -/// Spawn a service consumer loop that re-runs the bus handler continuously. +/// Idle poll for long-running SQL `listen`/`subscribe` hosts. +/// +/// Drain-to-idle is for tests. A host that lets `Service::run` return `Ok(())` +/// would otherwise reconstruct routes and bootstrap projectors on every quiet +/// stretch — seconds of delay on the next Eventual command. +pub const CONSUMER_IDLE_POLL: Duration = Duration::from_millis(25); + +/// Spawn the bus consumer for a long-running host. +/// +/// `build_service` constructs the heavy route/projector graph **once**, then +/// again only after `run` fails. A successful return means the bus drained to +/// idle; that is a host bug for SQL buses (use `with_idle_poll` / +/// [`CONSUMER_IDLE_POLL`]). We log and stop instead of reconstructing, so an +/// idle drain cannot hide behind a rebuild storm. pub fn spawn_service_consumer_loop(build_service: F) where F: Fn() -> Service + Send + Sync + 'static, @@ -49,7 +62,13 @@ where loop { let service = build_service(); match service.run(RunOptions::idempotent()).await { - Ok(()) => tokio::time::sleep(Duration::from_millis(25)).await, + Ok(()) => { + eprintln!( + "consumer: bus drained to idle; not reconstructing Service. \ + Long-running SQL hosts must call with_idle_poll({CONSUMER_IDLE_POLL:?})" + ); + return; + } Err(e) => { eprintln!("consumer: {e}"); tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/src/outbox/commit.rs b/src/outbox/commit.rs index 93b5fdd1..26e47fd8 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}; @@ -12,16 +12,12 @@ use crate::repository::{ }; use crate::table::TableWritePlan; -/// Publishes already-committed, claimed outbox rows and settles their claims. +/// Publishes already-committed outbox rows and settles their claims. /// -/// Implemented by the outbox → bus bridge and installed on an -/// [`AggregateRepository`] (by `Service::with_bus`) so that -/// `repo.outbox(msg).commit(agg)` publishes immediately — no separate call. The -/// hook owns the publisher and the outbox store; it is given the claimed -/// messages the commit just wrote (in insertion order), publishes them, and -/// completes each claim (or releases it for the polling worker on failure). It -/// is object-safe so the repository can hold it without naming the -/// transport/store types. +/// Implemented by the outbox → bus bridge. `Service::with_bus` prefers a +/// bounded worker mailbox over this hook; the hook is the fallback when no +/// mailbox is installed. It is object-safe so the repository can hold it +/// without naming the transport/store types. pub trait OutboxPublishHook: Send + Sync { /// Publish committed, claimed outbox rows and settle their claims. Publish /// failures are absorbed (the rows stay retryable for the worker); only a @@ -37,11 +33,69 @@ pub struct OutboxPublisherConfig { pub(crate) hook: Arc, pub(crate) worker_id: String, pub(crate) lease: Duration, + /// Bounded worker mailbox. When set, commit `try_send`s ids and returns; + /// the worker claims and publishes. When unset, the hook is the fallback + /// (tests, no-runtime builds). + pub(crate) schedule: Option) + Send + Sync>>, +} + +/// Detach immediate publish from command completion. +/// +/// Prefer `config.schedule`: enqueue ids onto the bounded worker and return. +/// Without a mailbox, the hook runs on a spawned task when a Tokio runtime is +/// current, or inline when it is not, so publish is not dropped. +pub(crate) async fn start_immediate_publish( + config: &OutboxPublisherConfig, + ids: Vec, + fallback_rows: Vec, +) { + if let Some(schedule) = &config.schedule { + if !ids.is_empty() { + schedule(ids); + } + return; + } + if fallback_rows.is_empty() { + return; + } + let hook = Arc::clone(&config.hook); + #[cfg(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", + test, + ))] + { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let _ = handle.spawn(async move { + let _ = hook.publish_claimed(fallback_rows).await; + }); + return; + } + let _ = hook.publish_claimed(fallback_rows).await; + } + #[cfg(not(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", + test, + )))] + { + let _ = hook.publish_claimed(fallback_rows).await; + } } impl OutboxPublisherConfig { - /// Build the config from a publish hook, the worker id used to scope the - /// in-transaction claim, and the publish lease. + /// Build the config from a publish hook, the worker id used to scope + /// claims, and the publish lease. pub fn new( hook: Arc, worker_id: impl Into, @@ -51,8 +105,15 @@ impl OutboxPublisherConfig { hook, worker_id: worker_id.into(), lease, + schedule: None, } } + + /// Install a non-blocking after-commit scheduler (bounded worker mailbox). + pub fn with_schedule(mut self, schedule: Arc) + Send + Sync>) -> Self { + self.schedule = Some(schedule); + self + } } /// Outcome of an outbox-bearing commit. @@ -158,17 +219,15 @@ where A: Aggregate + Send, { /// Commit the aggregate together with the staged outbox rows, read-model - /// writes, and a snapshot (when due) in one transaction — and, when the - /// repository has a bus configured (via `Service::with_bus`), publish the - /// outbox rows immediately. + /// writes, and a snapshot (when due) in one transaction. Command completion + /// is this commit. When the repository has a bus (`Service::with_bus`), + /// pending outbox ids are handed to a bounded worker after commit and do + /// not delay the returned receipt. /// - /// With a bus configured, each outbox row is **claimed in this same - /// transaction** (born `InFlight` under a short lease) and published right - /// after commit, so publication needs no separate claim and cannot race the - /// polling worker; a crash before publish hands the row back to an - /// independently running worker at lease expiry, and a publish failure - /// leaves it retryable. Without a bus, rows are committed `pending` for the - /// worker to publish. + /// Rows stay **pending**. The worker claims them via `dispatch_ids` (or + /// `dispatch_batch` on overflow/poll). A crash before publish leaves them + /// pending for the drain loop. A publish failure leaves them retryable. + /// Without a bus, rows stay pending for a separately operated worker. /// /// Returns a [`CommitReceipt`] carrying the inserted outbox message ids. pub async fn commit(mut self, aggregate: &mut A) -> Result { @@ -190,16 +249,15 @@ where .map(|message| message.id().to_string()) .collect(); - // When a bus is configured, claim the rows in this transaction so they - // can be published immediately after commit; otherwise leave them - // `pending`. let publisher = self.repo.outbox_publisher(); - let mut claimed = Vec::new(); + let mut fallback_rows = Vec::new(); if let Some(config) = publisher { - let now = SystemTime::now(); - for message in &mut self.outbox_messages { - message.claim_at(&config.worker_id, config.lease, now)?; - claimed.push(message.clone()); + if config.schedule.is_none() { + let now = crate::time::now(); + for message in &mut self.outbox_messages { + message.claim_at(&config.worker_id, config.lease, now)?; + } + fallback_rows = self.outbox_messages.clone(); } } @@ -224,11 +282,10 @@ where .mark_domain_events_committed() .map_err(domain_event_guard_repository_error)?; - // Best-effort immediate publish. A failure leaves the claimed rows for - // an independently running polling worker and never fails the - // already-committed command. + // Best-effort after-commit publish. Command completion is this commit; + // publish must not delay the caller. if let Some(config) = publisher { - let _ = config.hook.publish_claimed(claimed).await; + start_immediate_publish(config, outbox_message_ids.clone(), fallback_rows).await; } Ok(CommitReceipt { outbox_message_ids }) @@ -366,6 +423,69 @@ mod tests { } } + struct HoldingHook { + started: tokio::sync::Notify, + gate: tokio::sync::Notify, + finished: Mutex, + } + + impl OutboxPublishHook for HoldingHook { + fn publish_claimed<'a>( + &'a self, + claimed: Vec, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let _ = claimed; + self.started.notify_waiters(); + self.gate.notified().await; + *self.finished.lock().unwrap() = true; + Ok(()) + }) + } + } + + #[tokio::test] + async fn immediate_publish_does_not_delay_commit() { + let hook = Arc::new(HoldingHook { + started: tokio::sync::Notify::new(), + gate: tokio::sync::Notify::new(), + finished: Mutex::new(false), + }); + let mut repo = InMemoryRepository::new().aggregate::(); + repo.set_outbox_publisher(OutboxPublisherConfig::new( + Arc::clone(&hook) as Arc, + "immediate:test", + Duration::from_secs(5), + )); + + let mut aggregate = Dummy::default(); + aggregate.touch().unwrap(); + let event = OutboxMessage::create("msg-hold", "DummyTouched", b"{}".to_vec()).unwrap(); + + let started = hook.started.notified(); + let receipt = repo.outbox(event).commit(&mut aggregate).await.unwrap(); + assert_eq!(receipt.outbox_message_ids(), ["msg-hold".to_string()]); + assert!( + !*hook.finished.lock().unwrap(), + "commit must return before the holding publish hook finishes" + ); + + tokio::time::timeout(Duration::from_secs(1), started) + .await + .expect("immediate publish should start"); + hook.gate.notify_one(); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if *hook.finished.lock().unwrap() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("immediate publish should finish after the gate opens"); + } + #[tokio::test] async fn outbox_helper_commits_both_entities() { let repo = InMemoryRepository::new().aggregate::(); diff --git a/src/outbox/message.rs b/src/outbox/message.rs index f93578ba..c14e1bf6 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, @@ -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/mod.rs b/src/outbox/mod.rs index 5afdf19c..e17fdfd5 100644 --- a/src/outbox/mod.rs +++ b/src/outbox/mod.rs @@ -50,4 +50,6 @@ pub use table::{ }; // Commit helpers +#[allow(unused_imports)] // used by graphql causal commit +pub(crate) use commit::start_immediate_publish; pub use commit::{AggregateCommit, CommitReceipt, OutboxPublishHook, OutboxPublisherConfig}; diff --git a/src/outbox/table.rs b/src/outbox/table.rs index a71c972a..2383df67 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/drain.rs b/src/outbox_worker/drain.rs index e8c19705..c2b02504 100644 --- a/src/outbox_worker/drain.rs +++ b/src/outbox_worker/drain.rs @@ -1,18 +1,19 @@ //! Cancellable background drain over [`OutboxDispatcher::dispatch_batch`]. //! -//! Immediate after-commit publish stays the fast path. This loop is the -//! safety net: it only claims rows that are still pending (released after a -//! publish failure, or never claimed because the process crashed after -//! commit). It does not resurrect the old in-memory `OutboxWorker`. +//! After-commit publish is a hint onto this same loop (`dispatch_ids`), not a +//! spawn per command. Polling `dispatch_batch` is the crash/overflow net for +//! pending rows. It does not resurrect the old in-memory `OutboxWorker`. use std::future::Future; +use std::sync::Arc; use std::time::Duration; +use tokio::sync::{mpsc, Notify}; use tokio::task::JoinHandle; use crate::bus::{MessagePublisher, TransportError}; -use super::{OutboxDispatchOutcome, OutboxDispatcher, OutboxStore}; +use super::{OutboxDispatcher, OutboxStore}; /// Default time between empty drain passes. pub const DEFAULT_DRAIN_POLL_INTERVAL: Duration = Duration::from_secs(1); @@ -38,6 +39,45 @@ pub fn drain_worker_id() -> String { format!("drain:{}", std::process::id()) } +/// How many commit-id batches the after-commit mailbox will hold before +/// overflowing to a wake + `dispatch_batch` of pending rows. +pub const DEFAULT_OUTBOX_HINT_CAPACITY: usize = 256; + +/// Bounded after-commit mailbox: `try_send` ids, or `notify_one` on overflow +/// so the same worker claims pending rows instead of spawning a task. +#[derive(Clone)] +pub struct OutboxPublishMailbox { + tx: mpsc::Sender>, + wake: Arc, +} + +impl OutboxPublishMailbox { + /// Create a mailbox, the worker's hint receiver, and the shared wake. + pub fn channel(capacity: usize) -> (Self, mpsc::Receiver>, Arc) { + let (tx, rx) = mpsc::channel(capacity.max(1)); + let wake = Arc::new(Notify::new()); + ( + Self { + tx, + wake: Arc::clone(&wake), + }, + rx, + wake, + ) + } + + /// Enqueue ids for `dispatch_ids`. If the channel is full or closed, wake + /// the worker so `dispatch_batch` can claim the pending rows. Never waits. + pub fn try_submit(&self, ids: Vec) { + if ids.is_empty() { + return; + } + if self.tx.try_send(ids).is_err() { + self.wake.notify_one(); + } + } +} + /// Repeatedly [`OutboxDispatcher::dispatch_batch`] until cancelled. pub struct OutboxDrainRunner { dispatcher: OutboxDispatcher, @@ -45,6 +85,8 @@ pub struct OutboxDrainRunner { poll_interval: Duration, error_backoff: Duration, max_error_backoff: Duration, + hint_rx: Option>>, + wake: Option>, } impl OutboxDrainRunner @@ -61,6 +103,8 @@ where poll_interval: DEFAULT_DRAIN_POLL_INTERVAL, error_backoff: DEFAULT_DRAIN_ERROR_BACKOFF, max_error_backoff: DEFAULT_DRAIN_MAX_ERROR_BACKOFF, + hint_rx: None, + wake: None, } } @@ -95,6 +139,13 @@ where self } + /// Receive after-commit id hints and overflow wakes on this loop. + pub fn with_hints(mut self, hint_rx: mpsc::Receiver>, wake: Arc) -> Self { + self.hint_rx = Some(hint_rx); + self.wake = Some(wake); + self + } + /// The dispatcher this runner drives. pub fn dispatcher(&self) -> &OutboxDispatcher { &self.dispatcher @@ -102,37 +153,51 @@ where /// Drain until `shutdown` resolves. Store errors back off; they do not /// terminate the loop. Empty passes sleep `poll_interval`. A full batch - /// is followed immediately by another pass. + /// is followed immediately by another pass. After-commit hints run + /// `dispatch_ids` on this same worker. pub async fn run(self, shutdown: impl Future) -> Result<(), TransportError> { tokio::pin!(shutdown); let mut backoff = self.error_backoff; + let mut hint_rx = self.hint_rx; + let wake = self.wake; + let mut sleep_for = Duration::ZERO; loop { - let pass = async { - match self.dispatcher.dispatch_batch(self.batch_size).await { - Ok(outcome) => Pass::Drained(outcome), - Err(error) => Pass::StoreError(error), - } - }; + // Hints must beat sleep(0) / poll. Unbiased select can run + // dispatch_batch on scrape backlog while a command's Eventual + // `projected` waits for its id still sitting in the mailbox. tokio::select! { + biased; _ = &mut shutdown => return Ok(()), - result = pass => match result { - Pass::Drained(outcome) => { - backoff = self.error_backoff; - if outcome.claimed < self.batch_size { - tokio::select! { - _ = &mut shutdown => return Ok(()), - _ = tokio::time::sleep(self.poll_interval) => {} + hint = next_hint(&mut hint_rx) => { + if let Some(ids) = hint { + let ids = coalesce_hints(&mut hint_rx, ids, self.batch_size); + match self.dispatcher.dispatch_ids(&ids).await { + Ok(_) => backoff = self.error_backoff, + Err(error) => { + eprintln!("outbox drain: {error}"); + backoff = backoff.saturating_mul(2).min(self.max_error_backoff); + sleep_for = backoff; } } } - Pass::StoreError(error) => { - eprintln!("outbox drain: {error}"); - tokio::select! { - _ = &mut shutdown => return Ok(()), - _ = tokio::time::sleep(backoff) => {} - } - backoff = backoff.saturating_mul(2).min(self.max_error_backoff); - } + continue; + } + _ = wake_notified(&wake) => {} + _ = tokio::time::sleep(sleep_for) => {} + } + match self.dispatcher.dispatch_batch(self.batch_size).await { + Ok(outcome) => { + backoff = self.error_backoff; + sleep_for = if outcome.claimed >= self.batch_size { + Duration::ZERO + } else { + self.poll_interval + }; + } + Err(error) => { + eprintln!("outbox drain: {error}"); + sleep_for = backoff; + backoff = backoff.saturating_mul(2).min(self.max_error_backoff); } } } @@ -150,9 +215,60 @@ where } } -enum Pass { - Drained(OutboxDispatchOutcome), - StoreError(TransportError), +async fn next_hint(hint_rx: &mut Option>>) -> Option> { + loop { + match hint_rx.as_mut() { + None => std::future::pending::<()>().await, + Some(rx) => match rx.recv().await { + Some(ids) => return Some(ids), + None => { + *hint_rx = None; + } + }, + } + } +} + +fn coalesce_hints( + hint_rx: &mut Option>>, + ids: Vec, + limit: usize, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut bounded = Vec::with_capacity(limit.min(ids.len())); + for id in ids { + if bounded.len() == limit { + break; + } + if seen.insert(id.clone()) { + bounded.push(id); + } + } + if let Some(rx) = hint_rx.as_mut() { + while bounded.len() < limit { + match rx.try_recv() { + Ok(more) => { + for id in more { + if bounded.len() == limit { + break; + } + if seen.insert(id.clone()) { + bounded.push(id); + } + } + } + Err(_) => break, + } + } + } + bounded +} + +async fn wake_notified(wake: &Option>) { + match wake { + Some(wake) => wake.notified().await, + None => std::future::pending().await, + } } /// Handle for a spawned [`OutboxDrainRunner`]. Abort-only on [`stop`]; drop @@ -181,6 +297,17 @@ mod tests { use crate::bus::Message; use crate::outbox_worker::{ClaimOutboxMessages, OutboxClaimRef, OutboxStore}; use crate::repository::RepositoryError; + + #[test] + fn hint_coalescing_deduplicates_and_never_exceeds_batch_limit() { + let (tx, rx) = mpsc::channel(4); + tx.try_send(vec!["b".into(), "c".into(), "d".into()]) + .unwrap(); + tx.try_send(vec!["e".into(), "f".into()]).unwrap(); + let mut rx = Some(rx); + let ids = coalesce_hints(&mut rx, vec!["a".into(), "a".into(), "b".into()], 4); + assert_eq!(ids, vec!["a", "b", "c", "d"]); + } use crate::{ CommitBatch, InMemoryOutboxStore, InMemoryRepository, OutboxMessage, OutboxMessageStatus, TransactionalCommit, @@ -346,6 +473,73 @@ mod tests { ); } + #[tokio::test] + async fn hint_publishes_without_waiting_for_poll_interval() { + let repo = InMemoryRepository::new(); + let id = store_message(&repo, outbox("evt-hint")); + let publisher = RecordingPublisher::new(); + let (mailbox, rx, wake) = OutboxPublishMailbox::channel(8); + let handle = OutboxDrainRunner::new(OutboxDispatcher::new( + repo.outbox_store(), + publisher.clone(), + "immediate:test", + Duration::from_secs(30), + 3, + )) + .with_poll_interval(Duration::from_secs(30)) + .with_hints(rx, wake) + .spawn(); + + mailbox.try_submit(vec![id]); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if publisher.ids() == ["evt-hint"] { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("hint should publish without waiting for the 30s poll"); + handle.stop().await.unwrap(); + assert!(repo.outbox_store().pending(8).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn overflow_wake_publishes_pending_rows() { + let repo = InMemoryRepository::new(); + store_message(&repo, outbox("evt-a")); + store_message(&repo, outbox("evt-b")); + let publisher = RecordingPublisher::new(); + let (mailbox, rx, wake) = OutboxPublishMailbox::channel(1); + mailbox.try_submit(vec!["evt-a".to_string()]); + mailbox.try_submit(vec!["evt-b".to_string()]); + let handle = OutboxDrainRunner::new(OutboxDispatcher::new( + repo.outbox_store(), + publisher.clone(), + "immediate:test", + Duration::from_secs(30), + 3, + )) + .with_poll_interval(Duration::from_secs(30)) + .with_hints(rx, wake) + .spawn(); + + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let mut ids = publisher.ids(); + ids.sort(); + if ids == ["evt-a", "evt-b"] { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("overflow wake should drain the pending row the mailbox could not hold"); + handle.stop().await.unwrap(); + } + struct FlakyClaimStore { inner: InMemoryOutboxStore, fail_remaining: AtomicU32, diff --git a/src/outbox_worker/mod.rs b/src/outbox_worker/mod.rs index dae637da..3abd9520 100644 --- a/src/outbox_worker/mod.rs +++ b/src/outbox_worker/mod.rs @@ -65,9 +65,9 @@ pub use bus_publisher::BusPublisher; test, ))] pub use drain::{ - drain_worker_id, OutboxDrainHandle, OutboxDrainRunner, DEFAULT_DRAIN_BATCH_SIZE, - DEFAULT_DRAIN_ERROR_BACKOFF, DEFAULT_DRAIN_LEASE, DEFAULT_DRAIN_MAX_ERROR_BACKOFF, - DEFAULT_DRAIN_POLL_INTERVAL, + drain_worker_id, OutboxDrainHandle, OutboxDrainRunner, OutboxPublishMailbox, + DEFAULT_DRAIN_BATCH_SIZE, DEFAULT_DRAIN_ERROR_BACKOFF, DEFAULT_DRAIN_LEASE, + DEFAULT_DRAIN_MAX_ERROR_BACKOFF, DEFAULT_DRAIN_POLL_INTERVAL, DEFAULT_OUTBOX_HINT_CAPACITY, }; pub use outbox_dispatch::{OutboxDispatchOutcome, OutboxDispatcher, SOURCED_METADATA_PREFIX}; pub use outbox_source::{ diff --git a/src/outbox_worker/outbox_dispatch.rs b/src/outbox_worker/outbox_dispatch.rs index 89ee6ec6..34d6b78f 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); } } @@ -333,10 +333,9 @@ pub(crate) struct SettleOutcome { /// /// This is the one publish-then-settle path, shared by the dispatcher /// (background polling and after-commit `dispatch_ids`) and by the -/// after-commit publish hook. It never claims: callers must already hold the -/// claims — the dispatcher claims first, and the hook is handed rows that were -/// claimed inside the commit transaction (re-claiming there would bump -/// attempts and race the lease). +/// fallback publish hook. It never claims: callers must already hold the +/// claims — the dispatcher claims first, and a mailbox-less hook is handed +/// rows the test path spawned after commit. /// /// `publish_concurrency` bounds how many publishes are in flight at once. /// `1` preserves strict claim order; higher values overlap publish round diff --git a/src/outbox_worker/store/in_memory.rs b/src/outbox_worker/store/in_memory.rs index e0b0053b..13a2c718 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 0b30fd17..826e5992 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/repository/traits.rs b/src/repository/traits.rs index ced2ab24..7a651326 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 91aaacab..f26f8254 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/snapshot/store.rs b/src/snapshot/store.rs index 10044e37..36f30949 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/sqlx_repo/repo/commit.rs b/src/sqlx_repo/repo/commit.rs index d0e13f1e..d7841aec 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 3e437e99..7dfd9c70 100644 --- a/src/sqlx_repo/repo/streams.rs +++ b/src/sqlx_repo/repo/streams.rs @@ -137,19 +137,30 @@ 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 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 { + after_version + } else { + 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/src/time.rs b/src/time.rs new file mode 100644 index 00000000..28129174 --- /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/bomberman/handlers/mod.rs b/tests/bomberman/handlers/mod.rs index dfd1c0f0..f58d17c9 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 332f28c9..bdbacea7 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 326ce6d7..f5fd00a4 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/causal_public_invoke/main.rs b/tests/causal_public_invoke/main.rs new file mode 100644 index 00000000..40ab8ff2 --- /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"); +} diff --git a/tests/causal_wait_path/main.rs b/tests/causal_wait_path/main.rs new file mode 100644 index 00000000..c6e415ed --- /dev/null +++ b/tests/causal_wait_path/main.rs @@ -0,0 +1,370 @@ +//! 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::command_dispatch::{CommandHost, HttpCommandHost, SharedCommandHost}; +use distributed::graphql::VerifiedPrincipal; +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" } + })) + .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_rejects_spoofed_body_identity() { + 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(), 400, "{}", 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).expect("valid wait-path URL"); + 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"); +} + +#[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).expect("valid wait-path URL")); + 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(); + 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" } + }) + .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"); +} diff --git a/tests/celld/Dockerfile b/tests/celld/Dockerfile new file mode 100644 index 00000000..9e6762d3 --- /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/Makefile b/tests/celld/Makefile new file mode 100644 index 00000000..2b24854a --- /dev/null +++ b/tests/celld/Makefile @@ -0,0 +1,57 @@ +# Local celld worker: rebuild WASM, deploy to Azurite, restart the node. +# +# make reload worker-build --dev + celld deploy + compose restart +# make watch cargo-watch the above (waits for the first source change) +# +# CELLD_WATCH in docker-compose is the node's SQLite/replication dir, not +# 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 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 +AZURE_STORAGE_ACCOUNT_KEY ?= Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + +ensure-watch: + @command -v cargo-watch >/dev/null || { echo "installing cargo-watch…"; cargo install cargo-watch; } + +# Debug wasm is faster to iterate than the --release used by up-celld-nats. +reload: + @command -v worker-build >/dev/null || { echo "worker-build required: cargo install worker-build"; exit 1; } + @command -v celld >/dev/null || { echo "celld CLI required: curl -fsSL https://celld.dev/install.sh | sh"; exit 1; } + cd worker && worker-build --dev + 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)"; \ + ( cd $(REPO_ROOT) && celld deploy tests/celld/worker --bucket az://celld ) + 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 \ + --postpone \ + -d 2 \ + -w src \ + -w Cargo.toml \ + -w wrangler.jsonc \ + -w ../../../src/microsvc/cell_host \ + -w ../../../src/command_dispatch \ + -w ../../e2e-ui/crates/todo-domain \ + -w ../../e2e-ui/crates/chat-domain \ + -- $(MAKE) -C $(CURDIR) reload + +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 new file mode 100644 index 00000000..6e10cf4c --- /dev/null +++ b/tests/celld/README.md @@ -0,0 +1,107 @@ +# celld live Todo and Chat cells + +First live celld host for portable command hosts: one `TodoCell` Durable +Object per todo id, one `ChatCell` per message id, private SQLite, Docker +Compose for the daemon **and** Azurite (no AWS or Cloudflare account). + +The Worker is workers-rs Durable Object classes around +`distributed::cell_host::AggregateCell` and +`AggregateCell`. Shard rule is still +`idFromName(todo_id)` / `idFromName(message_id)` (`PCH-DEC-004`). GraphQL +and projectors are not cell methods — Chat `@live` stays on the GraphQL +host. The event log is stored in Durable Object SQLite table `cell_events`. +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 +production fleet bucket. + +## Prerequisites + +- 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 + +```sh +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 +(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 +DISTRIBUTED_INTERNAL_SECRET=test-only-internal-secret-change-me-2026 \ + 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_WATCH` is the node's local SQLite/replication working directory — it does **not** watch Worker source. For source reload while iterating: + +```sh +make -C tests/celld watch # cargo-watch: worker-build --dev + deploy + restart +# or, with the GraphQL playground: +cd tests/e2e-celld && make run # also watches GraphQL (WATCH_WORKER=0 to skip wasm) +``` + +`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. + +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 +`POST /chat/:id/chat.post` the same wait-path (events + sealed + outbox; +Chat does not snapshot). 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 row. + +Outbox drain: wait-path JSON includes still-`pending` rows for projection +metadata, but the mutation only schedules the cell address. The bounded host +worker calls `outbox.claim` with a worker id and lease, publishes with a +timeout, then calls `outbox.complete` or `outbox.release` with the same +owner. Stale or forged completion is rejected. If `OUTBOX_DRAIN_URL` is set, +a Durable Object alarm every `OUTBOX_DRAIN_INTERVAL_MS` sends an +address-only retry hint; the cell remains the durable source of truth. + +Every non-health Worker route requires `DISTRIBUTED_INTERNAL_SECRET`. The +fixture's checked-in value is only for loopback CI/local use. Production must +provision a unique secret binding, TLS, and network policy; this example does +not claim to provide a production celld fleet configuration. + +Tear down: `docker compose -f tests/celld/docker-compose.yml down -v`. + +Optional e2e-ui split (same Svelte app, not the default playground): +`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 + +| 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 new file mode 100644 index 00000000..235e5635 --- /dev/null +++ b/tests/celld/docker-compose.yml @@ -0,0 +1,85 @@ +# Local celld + Azurite. No AWS or Cloudflare account. +# +# 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). +# +# 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). +# +# Azurite is a development store — not a production celld fleet. +# +# 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: + - "127.0.0.1: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: + build: + context: . + dockerfile: Dockerfile + restart: always + init: true + ports: + - "127.0.0.1:${CELLD_HTTP_PORT:-18080}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + azurite-init: + condition: service_completed_successfully + environment: + AZURE_STORAGE_USE_EMULATOR: "true" + AZURE_STORAGE_ACCOUNT_NAME: devstoreaccount1 + AZURE_STORAGE_ACCOUNT_KEY: Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + CELLD_BUCKET: az://celld + # SQLite/replication working directory on the node — not Worker source HMR. + CELLD_WATCH: /var/lib/celld/state + 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 new file mode 100644 index 00000000..7da7d931 --- /dev/null +++ b/tests/celld/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# 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:-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 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 00000000..9b419d67 --- /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 new file mode 100644 index 00000000..261254fb --- /dev/null +++ b/tests/celld/main.rs @@ -0,0 +1,492 @@ +//! Live celld host: one Todo Durable Object per todo id, one Chat cell per +//! message 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 distributed::cell_host::{ + CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, CELL_PRINCIPAL_PARTITION_HEADER, + CELL_SERVICE_ID_HEADER, +}; +use serde_json::Value; + +const TEST_SERVICE_ID: &str = "celld-live-test"; +const TEST_PRINCIPAL_PARTITION: &str = "test-principal-alice"; + +#[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_and_chat_cells() { + 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"], "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"); + assert_eq!(bindings[1]["name"], "CHAT"); + assert_eq!(bindings[1]["class_name"], "ChatCell"); + let v1 = spec["migrations"][0]["new_sqlite_classes"] + .as_array() + .unwrap(); + assert_eq!(v1[0], "TodoCell"); + let v2 = spec["migrations"][1]["new_sqlite_classes"] + .as_array() + .unwrap(); + assert_eq!(v2[0], "ChatCell"); + assert_eq!( + spec["vars"]["OUTBOX_DRAIN_URL"], + "http://host.docker.internal:8791/internal/outbox/drain" + ); + + 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("pub struct ChatCell")); + assert!(source.contains("AggregateCell::")); + assert!(source.contains("AggregateCell::")); + assert!(source.contains("id_from_name")); + assert!(source.contains("mount(create())")); + assert!(source.contains("mount(complete())")); + assert!(source.contains("mount(post())")); + 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("chat.post")); + assert!(source.contains("outbox.complete")); + assert!(source.contains("outbox.claim")); + assert!(source.contains("outbox.release")); + assert!(source.contains("CREATE TABLE IF NOT EXISTS cell_outbox")); + assert!(source.contains("CREATE TABLE IF NOT EXISTS cell_commands")); + assert!(source.contains("dispatch_idempotent")); + assert!(source.contains("restore_durable_commands")); + assert!(source.contains("sealed_row")); + assert!(source.contains("new_with_snapshots")); + assert!(source.contains("restore_durable_events")); + assert!(source.contains("restore_durable_snapshots")); + assert!(source.contains("restore_chat_copy")); +} + +#[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"); + 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("CELLD_HTTP_PORT:-18080")); + assert!(compose.contains("127.0.0.1:${CELLD_HTTP_PORT:-18080}:8080")); + assert!(compose.contains(":8080")); + assert!(compose.contains("host.docker.internal:host-gateway")); +} + +#[tokio::test] +async fn live_cell_private_routes_reject_missing_forged_and_malformed_authority() { + let Some(base) = env_support::broker_env("CELLD_URL", "celld live security boundary") else { + return; + }; + let base = base.trim_end_matches('/').to_string(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client"); + wait_healthy(&client, &base).await; + let (id, _) = unique_cell_pair("todo"); + let command = serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000199", + "input": { "title": "must not be created" } + }); + + let missing = client + .post(format!("{base}/todo/{id}/todo.create")) + .header(CELL_SERVICE_ID_HEADER, TEST_SERVICE_ID) + .header(CELL_PRINCIPAL_PARTITION_HEADER, TEST_PRINCIPAL_PARTITION) + .header("x-user-id", "alice") + .header("x-roles", "admin") + .json(&command) + .send() + .await + .expect("missing secret"); + assert_eq!(missing.status(), 401); + + let forged = client + .post(format!("{base}/todo/{id}/todo.create")) + .header( + CELL_INTERNAL_SECRET_HEADER, + "forged-secret-for-red-team-request", + ) + .header(CELL_SERVICE_ID_HEADER, TEST_SERVICE_ID) + .header(CELL_PRINCIPAL_PARTITION_HEADER, TEST_PRINCIPAL_PARTITION) + .header("x-user-id", "alice") + .header("x-roles", "admin") + .json(&command) + .send() + .await + .expect("forged secret"); + assert_eq!(forged.status(), 401); + + let read = client + .get(format!("{base}/todo/{id}")) + .send() + .await + .expect("unauthenticated read"); + assert_eq!(read.status(), 401); + + let malformed = trusted_cell_request(client.post(format!("{base}/todo/{id}/outbox.claim"))) + .json(&serde_json::json!({ + "workerId": "attacker", + "limit": 1, + "leaseMs": 30_000, + "forged": true + })) + .send() + .await + .expect("malformed claim"); + assert_eq!(malformed.status(), 400); +} + +#[tokio::test] +async fn live_todo_cell_create_complete_reopen_archive_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, b) = unique_cell_pair("todo"); + + let created = trusted_cell_request( + client + .post(format!("{base}/todo/{a}/todo.create")) + .header("x-user-id", "alice") + .header("x-roles", "user"), + ) + .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["payload"]["id"], a); + assert_eq!(created["payload"]["status"], "open"); + assert_eq!( + created["receipt"]["commandId"], + "0190a000-0000-7000-8000-000000000201" + ); + let causation_id = created["receipt"]["causationId"] + .as_str() + .expect("causationId") + .to_string(); + + let replay = trusted_cell_request( + client + .post(format!("{base}/todo/{a}/todo.create")) + .header("x-user-id", "alice") + .header("x-roles", "user"), + ) + .json(&serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000201", + "input": { "title": "ship celld" } + })) + .send() + .await + .expect("replay create"); + assert_eq!(replay.status(), 201, "{}", replay.text().await.unwrap()); + let replay: Value = replay.json().await.unwrap(); + assert_eq!(replay["receipt"]["replayed"], true); + assert_eq!(replay["receipt"]["causationId"], causation_id); + assert_eq!(replay["payload"], created["payload"]); + + let conflict = trusted_cell_request( + client + .post(format!("{base}/todo/{a}/todo.create")) + .header("x-user-id", "alice") + .header("x-roles", "user"), + ) + .json(&serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000201", + "input": { "title": "different input" } + })) + .send() + .await + .expect("conflicting create"); + assert_eq!(conflict.status(), 409, "{}", conflict.text().await.unwrap()); + let conflict: Value = conflict.json().await.unwrap(); + assert_eq!(conflict["code"], "COMMAND_ID_REUSE"); + + let completed = trusted_cell_request( + client + .post(format!("{base}/todo/{a}/todo.complete")) + .header("x-user-id", "alice") + .header("x-roles", "user"), + ) + .json(&serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000202", + "input": {} + })) + .send() + .await + .expect("complete"); + assert_eq!( + completed.status(), + 200, + "{}", + completed.text().await.unwrap() + ); + let completed: Value = completed.json().await.unwrap(); + assert_eq!(completed["payload"]["status"], "completed"); + assert_eq!( + completed["receipt"]["commandId"], + "0190a000-0000-7000-8000-000000000202" + ); + + let reopened = trusted_cell_request( + client + .post(format!("{base}/todo/{a}/todo.reopen")) + .header("x-user-id", "alice") + .header("x-roles", "user"), + ) + .json(&serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000203", + "input": {} + })) + .send() + .await + .expect("reopen"); + assert_eq!(reopened.status(), 200, "{}", reopened.text().await.unwrap()); + let reopened: Value = reopened.json().await.unwrap(); + assert_eq!(reopened["payload"]["status"], "open"); + + let archived = trusted_cell_request( + client + .post(format!("{base}/todo/{a}/todo.archive")) + .header("x-user-id", "alice") + .header("x-roles", "user"), + ) + .json(&serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000204", + "input": {} + })) + .send() + .await + .expect("archive"); + assert_eq!(archived.status(), 200, "{}", archived.text().await.unwrap()); + let archived: Value = archived.json().await.unwrap(); + assert_eq!(archived["payload"]["status"], "archived"); + + let got: Value = trusted_cell_request(client.get(format!("{base}/todo/{a}"))) + .send() + .await + .expect("get") + .json() + .await + .unwrap(); + assert_eq!(got["title"], "ship celld"); + assert_eq!(got["status"], "archived"); + + let other = trusted_cell_request(client.get(format!("{base}/todo/{b}"))) + .send() + .await + .expect("missing cell"); + assert_eq!(other.status(), 404, "second name must be a different cell"); +} + +#[tokio::test] +async fn live_chat_cell_post_and_isolate() { + let Some(base) = env_support::broker_env("CELLD_URL", "celld live Chat 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, b) = unique_cell_pair("chat"); + let created_at = unix_millis(); + + let posted = trusted_cell_request( + client + .post(format!("{base}/chat/{a}/chat.post")) + .header("x-user-id", "alice") + .header("x-roles", "user"), + ) + .json(&serde_json::json!({ + "commandId": "0190a000-0000-7000-8000-000000000301", + "input": { + "message_id": a, + "room_id": "lobby", + "body": "hello from a cell", + "created_at": created_at, + } + })) + .send() + .await + .expect("post"); + assert_eq!(posted.status(), 201, "{}", posted.text().await.unwrap()); + let posted: Value = posted.json().await.unwrap(); + assert_eq!(posted["payload"]["message_id"], a); + assert_eq!(posted["payload"]["body"], "hello from a cell"); + assert_eq!(posted["payload"]["author_id"], "alice"); + assert_eq!( + posted["receipt"]["commandId"], + "0190a000-0000-7000-8000-000000000301" + ); + + let got: Value = trusted_cell_request(client.get(format!("{base}/chat/{a}"))) + .send() + .await + .expect("get") + .json() + .await + .unwrap(); + assert_eq!(got["body"], "hello from a cell"); + assert_eq!(got["author_id"], "alice"); + assert_eq!(got["room_id"], "lobby"); + + let pending = posted["outbox"].as_array().cloned().unwrap_or_default(); + if !pending.is_empty() { + let ids: Vec = pending + .iter() + .filter_map(|row| row.get("id").cloned()) + .collect(); + let worker_id = "celld-live-test-worker"; + let claim = trusted_cell_request(client.post(format!("{base}/chat/{a}/outbox.claim"))) + .json(&serde_json::json!({ + "workerId": worker_id, + "limit": 64, + "leaseMs": 30_000 + })) + .send() + .await + .expect("outbox.claim"); + assert_eq!(claim.status(), 200, "{}", claim.text().await.unwrap()); + let claim: Value = claim.json().await.unwrap(); + assert_eq!(claim["outbox"].as_array().map(Vec::len), Some(ids.len())); + assert!(claim["outbox"] + .as_array() + .unwrap() + .iter() + .all(|row| row["status"] == "in_flight")); + + let stale = trusted_cell_request(client.post(format!("{base}/chat/{a}/outbox.complete"))) + .json(&serde_json::json!({ "workerId": "wrong-worker", "ids": ids })) + .send() + .await + .expect("stale completion"); + assert_eq!(stale.status(), 409); + + let complete = + trusted_cell_request(client.post(format!("{base}/chat/{a}/outbox.complete"))) + .json(&serde_json::json!({ "workerId": worker_id, "ids": ids })) + .send() + .await + .expect("outbox.complete"); + assert_eq!(complete.status(), 200, "{}", complete.text().await.unwrap()); + + let drained: Value = + trusted_cell_request(client.post(format!("{base}/chat/{a}/outbox.claim"))) + .json(&serde_json::json!({ + "workerId": worker_id, + "limit": 64, + "leaseMs": 30_000 + })) + .send() + .await + .expect("second outbox.claim") + .json() + .await + .unwrap(); + assert_eq!(drained["outbox"].as_array().map(Vec::len).unwrap_or(0), 0); + } + + let other = trusted_cell_request(client.get(format!("{base}/chat/{b}"))) + .send() + .await + .expect("missing cell"); + assert_eq!(other.status(), 404, "second name must be a different cell"); +} + +fn unique_cell_pair(kind: &str) -> (String, String) { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + (format!("{kind}-{nanos}-a"), format!("{kind}-{nanos}-b")) +} + +fn unix_millis() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_millis() + .to_string() +} + +fn trusted_cell_request(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + let secret = std::env::var(CELL_INTERNAL_SECRET_ENV) + .unwrap_or_else(|_| "test-only-internal-secret-change-me-2026".into()); + request + .header(CELL_INTERNAL_SECRET_HEADER, secret) + .header(CELL_SERVICE_ID_HEADER, TEST_SERVICE_ID) + .header(CELL_PRINCIPAL_PARTITION_HEADER, TEST_PRINCIPAL_PARTITION) +} + +async fn wait_healthy(client: &reqwest::Client, base: &str) { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + loop { + 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 { + panic!("celld at {base} did not become healthy in 30s"); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } +} diff --git a/tests/celld/worker/Cargo.toml b/tests/celld/worker/Cargo.toml new file mode 100644 index 00000000..00530d29 --- /dev/null +++ b/tests/celld/worker/Cargo.toml @@ -0,0 +1,25 @@ +[workspace] +members = ["."] +resolver = "2" + +[package] +name = "todo-cell-worker" +version = "0.1.0" +edition = "2021" +publish = false +description = "workers-rs Todo and Chat cells: AggregateCell + 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" } +chat-domain = { path = "../../e2e-ui/crates/chat-domain" } +worker = "0.8" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/tests/celld/worker/src/lib.rs b/tests/celld/worker/src/lib.rs new file mode 100644 index 00000000..098bde2f --- /dev/null +++ b/tests/celld/worker/src/lib.rs @@ -0,0 +1,1266 @@ +//! Todo and Chat Durable Object classes backed by `AggregateCell`. +//! +//! 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`). Chat `@live` +//! stays on the GraphQL host. + +use std::collections::HashSet; +use std::time::{Duration, SystemTime}; + +use chat_domain::{post, ChatMessage, ChatMessageState}; +use distributed::cell_host::{ + AggregateCell, CellCommandIdentity, CellDispatchError, CellDispatchResult, CellOutboxWireItem, + CellWaitPathRequest, DurableCellCommand, DurableCellEvents, DurableCellSnapshot, + InternalHttpSecret, CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, + CELL_PRINCIPAL_PARTITION_HEADER, CELL_SERVICE_ID_HEADER, MAX_CELL_OUTBOX_ITEMS, + MAX_CELL_OUTBOX_PAYLOAD_BYTES, MAX_CELL_OUTBOX_WIRE_BYTES, +}; +use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; +use distributed::{EventRecord, OutboxMessage}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use todo_domain::{ + archive, complete, create, force_archive, purge, rename, reopen, Todo, TodoState, +}; +use worker::*; + +const MAX_CELL_REQUEST_BYTES: usize = 2 * 1024 * 1024; + +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) +)"; + +const SNAPSHOTS_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_snapshots ( + stream TEXT PRIMARY KEY, + body TEXT NOT NULL +)"; + +const SEALED_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_sealed ( + id TEXT PRIMARY KEY, + body TEXT NOT NULL +)"; + +const OUTBOX_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_outbox ( + id TEXT PRIMARY KEY, + body TEXT NOT NULL +)"; + +const COMMANDS_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_commands ( + id TEXT PRIMARY KEY, + body TEXT NOT NULL +)"; + +#[durable_object] +pub struct TodoCell { + cell: AggregateCell, + sql: SqlStorage, + storage: Storage, + env: Env, + shard: String, +} + +impl DurableObject for TodoCell { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + let storage = state.storage(); + let sql = storage.sql(); + 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"); + sql.exec(OUTBOX_DDL, None).expect("create cell_outbox"); + sql.exec(COMMANDS_DDL, None).expect("create cell_commands"); + let shard = state.id().name().unwrap_or_else(|| "todo".to_string()); + let cell = AggregateCell::::new_with_snapshots(shard.clone(), 1) + .expect("todo cell identity") + .mount(create()) + .mount(rename()) + .mount(complete()) + .mount(reopen()) + .mount(archive()) + .mount(force_archive()) + .mount(purge()); + 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); + } + if let Ok(commands) = load_commands(&sql) { + let _ = cell.restore_durable_commands(commands); + } + Self { + cell, + sql, + storage, + env, + shard, + } + } + + async fn fetch(&self, mut req: Request) -> Result { + if let Err(error) = authenticate_internal_request(&req, &self.env) { + return internal_auth_error(error); + } + 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() + .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::Post, Some("todo.create")) => { + create_todo( + &self.sql, + &self.storage, + &self.env, + &self.cell, + &id, + &mut req, + ) + .await + } + (Method::Post, Some(command)) + if matches!( + command, + "todo.rename" + | "todo.complete" + | "todo.reopen" + | "todo.archive" + | "todo.force_archive" + | "todo.purge" + ) => + { + transition_todo( + &self.sql, + &self.storage, + &self.env, + &self.cell, + &id, + command, + &mut req, + ) + .await + } + (Method::Post, Some("outbox.claim")) => { + claim_outbox(&self.sql, &self.storage, &self.env, &self.cell, &mut req).await + } + (Method::Post, Some("outbox.complete")) => { + settle_outbox( + &self.sql, + &self.storage, + &self.env, + &self.cell, + &mut req, + true, + ) + .await + } + (Method::Post, Some("outbox.release")) => { + settle_outbox( + &self.sql, + &self.storage, + &self.env, + &self.cell, + &mut req, + false, + ) + .await + } + _ => json_status(json!({ "error": "not found" }), 404), + } + } + + async fn alarm(&self) -> Result { + if let Err(error) = restore_working_copy(&self.sql, &self.cell) { + return json_status(json!({ "error": error }), 500); + } + run_outbox_alarm(&self.storage, &self.env, &self.cell, "todo", &self.shard).await + } +} + +#[durable_object] +pub struct ChatCell { + cell: AggregateCell, + sql: SqlStorage, + storage: Storage, + env: Env, + shard: String, +} + +impl DurableObject for ChatCell { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + let storage = state.storage(); + let sql = storage.sql(); + sql.exec(EVENTS_DDL, None).expect("create cell_events"); + sql.exec(SEALED_DDL, None).expect("create cell_sealed"); + sql.exec(OUTBOX_DDL, None).expect("create cell_outbox"); + sql.exec(COMMANDS_DDL, None).expect("create cell_commands"); + let shard = state.id().name().unwrap_or_else(|| "chat".to_string()); + let cell = AggregateCell::::new(shard.clone()) + .expect("chat cell identity") + .mount(post()); + if let Ok(events) = load_events(&sql) { + let _ = cell.restore_durable_events(events); + } + if let Ok(commands) = load_commands(&sql) { + let _ = cell.restore_durable_commands(commands); + } + Self { + cell, + sql, + storage, + env, + shard, + } + } + + async fn fetch(&self, mut req: Request) -> Result { + if let Err(error) = authenticate_internal_request(&req, &self.env) { + return internal_auth_error(error); + } + if let Err(error) = restore_chat_copy(&self.sql, &self.cell) { + return json_status(json!({ "error": error }), 500); + } + 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("chat") => id.clone(), + _ => return json_status(json!({ "error": "missing chat id" }), 400), + }; + + match (req.method(), parts.get(2).map(String::as_str)) { + (Method::Get, None) => get_chat(&self.cell, &id).await, + (Method::Post, Some("chat.post")) => { + post_chat( + &self.sql, + &self.storage, + &self.env, + &self.cell, + &id, + &mut req, + ) + .await + } + (Method::Post, Some("outbox.claim")) => { + claim_outbox(&self.sql, &self.storage, &self.env, &self.cell, &mut req).await + } + (Method::Post, Some("outbox.complete")) => { + settle_outbox( + &self.sql, + &self.storage, + &self.env, + &self.cell, + &mut req, + true, + ) + .await + } + (Method::Post, Some("outbox.release")) => { + settle_outbox( + &self.sql, + &self.storage, + &self.env, + &self.cell, + &mut req, + false, + ) + .await + } + _ => json_status(json!({ "error": "not found" }), 404), + } + } + + async fn alarm(&self) -> Result { + if let Err(error) = restore_chat_copy(&self.sql, &self.cell) { + return json_status(json!({ "error": error }), 500); + } + run_outbox_alarm(&self.storage, &self.env, &self.cell, "chat", &self.shard).await + } +} + +#[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+chat cells\n"); + } + if let Err(error) = authenticate_internal_request(&req, &env) { + return internal_auth_error(error); + } + let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + let (binding, id) = match (parts.first().copied(), parts.get(1).copied()) { + (Some("todo"), Some(id)) => ("TODO", id), + (Some("chat"), Some(id)) => ("CHAT", id), + _ => { + return Response::error( + "cells: authenticated internal command/read/outbox routes\n", + 404, + ); + } + }; + let namespace = env.durable_object(binding)?; + let stub = namespace.id_from_name(id)?.get_stub()?; + stub.fetch_with_request(req).await +} + +fn authenticate_internal_request( + req: &Request, + env: &Env, +) -> std::result::Result<(), CellDispatchError> { + let configured = env + .secret(CELL_INTERNAL_SECRET_ENV) + .map(|value| value.to_string()) + .or_else(|_| { + env.var(CELL_INTERNAL_SECRET_ENV) + .map(|value| value.to_string()) + }) + .map_err(|_| { + CellDispatchError::Internal(format!("{CELL_INTERNAL_SECRET_ENV} is required")) + })?; + let secret = InternalHttpSecret::new(configured).map_err(CellDispatchError::Internal)?; + let candidate = req + .headers() + .get(CELL_INTERNAL_SECRET_HEADER) + .map_err(|_| CellDispatchError::Unauthorized)? + .ok_or(CellDispatchError::Unauthorized)?; + if !secret.matches(&candidate) { + return Err(CellDispatchError::Unauthorized); + } + Ok(()) +} + +fn internal_auth_error(error: CellDispatchError) -> Result { + match error { + CellDispatchError::Unauthorized => json_status( + json!({ "code": "UNAUTHORIZED", "error": "unauthorized" }), + 401, + ), + _ => json_status( + json!({ "code": "INTERNAL", "error": "internal cell authentication is unavailable" }), + 500, + ), + } +} + +fn session_from_headers(user: Option, roles: Option) -> Session { + let mut session = Session::new(); + if let Some(user) = user.filter(|value| !value.is_empty()) { + session.set(USER_ID_KEY, user); + } + if let Some(roles) = roles.filter(|value| !value.is_empty()) { + session.set(ROLE_KEY, roles); + } + session +} + +fn request_session(req: &Request) -> Session { + let user = req + .headers() + .get(USER_ID_KEY) + .ok() + .flatten() + .filter(|value| !value.is_empty()); + let roles = req + .headers() + .get(ROLE_KEY) + .ok() + .flatten() + .filter(|value| !value.is_empty()); + session_from_headers(user, roles) +} + +async fn get_chat(cell: &AggregateCell, id: &str) -> Result { + if let Ok(Some(row)) = cell.sealed_row() { + return json_status(row, 200); + } + match cell.load().await { + Ok(Some(message)) => json_status(http_chat(&ChatMessageState::from(&message)), 200), + Ok(None) => json_status(json!({ "error": "not found", "id": id }), 404), + Err(error) => json_status(json!({ "error": error.to_string() }), 500), + } +} + +async fn post_chat( + sql: &SqlStorage, + storage: &Storage, + env: &Env, + cell: &AggregateCell, + id: &str, + req: &mut Request, +) -> Result { + let session = request_session(req); + let body = match bounded_json::(req).await { + Ok(body) => body, + Err(error) => return map_cell_error(CellDispatchError::BadRequest(error.into()), cell), + }; + let (command_id, mut input) = match wait_path_parts(&body) { + Ok(parts) => parts, + Err(error) => return map_cell_error(error, cell), + }; + let identity = match request_cell_identity(req, &command_id) { + Ok(identity) => identity, + Err(error) => return map_cell_error(error, cell), + }; + if input.get("message_id").and_then(Value::as_str).is_none() { + input + .as_object_mut() + .map(|object| object.insert("message_id".into(), json!(id))); + } + match cell + .dispatch_idempotent("chat.post", &identity, input, session) + .await + { + Ok(dispatch) => { + seal_chat_from_load(cell).await; + persist_chat_copy(sql, cell)?; + arm_drain_alarm(storage, env, has_pending(cell)).await; + wait_path_ok( + dispatch.payload().clone(), + &dispatch, + 201, + outbox_wire(cell), + ) + } + Err(error) => { + persist_chat_copy(sql, cell)?; + arm_drain_alarm(storage, env, has_pending(cell)).await; + map_cell_error(error, cell) + } + } +} + +fn http_chat(state: &ChatMessageState) -> Value { + json!({ + "message_id": state.message_id, + "room_id": state.room_id, + "author_id": state.author_id, + "body": state.body, + "created_at": state.created_at, + }) +} + +fn restore_chat_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())?; + let commands = load_commands(sql).map_err(|error| error.to_string())?; + cell.restore_durable_commands(commands) + .map_err(|error| error.to_string())?; + let outbox = load_outbox(sql).map_err(|error| error.to_string())?; + cell.restore_durable_outbox(outbox) + .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_chat_from_load(cell: &AggregateCell) { + if let Ok(Some(message)) = cell.load().await { + let _ = cell.replace_sealed_row(http_chat(&ChatMessageState::from(&message))); + } +} + +fn persist_chat_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(), + ]), + )?; + } + } + persist_commands(sql, cell)?; + persist_outbox(sql, cell)?; + 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(()) +} + +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), + Err(error) => json_status(json!({ "error": error.to_string() }), 500), + } +} + +fn wait_path_parts(body: &Value) -> std::result::Result<(String, Value), CellDispatchError> { + let request = + CellWaitPathRequest::parse(body.clone()).map_err(CellDispatchError::BadRequest)?; + Ok((request.command_id, request.input)) +} + +fn request_cell_identity( + req: &Request, + command_id: &str, +) -> std::result::Result { + let service_id = required_internal_header(req, CELL_SERVICE_ID_HEADER)?; + let principal_partition = required_internal_header(req, CELL_PRINCIPAL_PARTITION_HEADER)?; + CellCommandIdentity::new(service_id, principal_partition, command_id) +} + +fn required_internal_header( + req: &Request, + name: &str, +) -> std::result::Result { + req.headers() + .get(name) + .map_err(|error| { + CellDispatchError::Internal(format!("could not read internal cell header: {error}")) + })? + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or(CellDispatchError::Unauthorized) +} + +fn wait_path_ok( + payload: Value, + dispatch: &CellDispatchResult, + status: u16, + outbox: Value, +) -> Result { + json_status( + json!({ + "payload": payload, + "receipt": { + "commandId": dispatch.command_id(), + "causationId": dispatch.causation_id(), + "state": dispatch.state(), + "replayed": dispatch.replayed(), + }, + "outbox": outbox, + }), + status, + ) +} + +fn outbox_wire(cell: &AggregateCell) -> Value +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + let rows = cell.durable_outbox().unwrap_or_default(); + let mut budget = CellOutboxWireBudget::default(); + let mut items = Vec::new(); + for message in rows.iter().filter(|message| message.is_pending()) { + let Some(item) = budget.try_add(message) else { + break; + }; + items.push(item); + } + Value::Array(items) +} + +fn has_pending(cell: &AggregateCell) -> bool +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + cell.durable_outbox() + .ok() + .map(|rows| { + rows.iter() + .any(|row| !row.is_published() && !row.is_failed()) + }) + .unwrap_or(false) +} + +async fn create_todo( + sql: &SqlStorage, + storage: &Storage, + env: &Env, + cell: &AggregateCell, + id: &str, + req: &mut Request, +) -> Result { + let body = match bounded_json::(req).await { + Ok(body) => body, + Err(error) => return map_cell_error(CellDispatchError::BadRequest(error.into()), cell), + }; + let (command_id, input) = match wait_path_parts(&body) { + Ok(parts) => parts, + Err(error) => return map_cell_error(error, cell), + }; + let identity = match request_cell_identity(req, &command_id) { + Ok(identity) => identity, + Err(error) => return map_cell_error(error, cell), + }; + let title = input + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + match cell + .dispatch_idempotent( + "todo.create", + &identity, + json!({ "todo_id": id, "title": title }), + request_session(req), + ) + .await + { + Ok(dispatch) => { + seal_from_load(cell).await; + persist_working_copy(sql, cell)?; + arm_drain_alarm(storage, env, has_pending(cell)).await; + wait_path_ok( + http_from_command(id, dispatch.payload(), &title), + &dispatch, + 201, + outbox_wire(cell), + ) + } + Err(error) => { + persist_working_copy(sql, cell)?; + arm_drain_alarm(storage, env, has_pending(cell)).await; + map_cell_error(error, cell) + } + } +} + +async fn transition_todo( + sql: &SqlStorage, + storage: &Storage, + env: &Env, + cell: &AggregateCell, + id: &str, + command: &str, + req: &mut Request, +) -> Result { + let body = match bounded_json::(req).await { + Ok(body) => body, + Err(error) => return map_cell_error(CellDispatchError::BadRequest(error.into()), cell), + }; + let (command_id, mut input) = match wait_path_parts(&body) { + Ok(parts) => parts, + Err(error) => return map_cell_error(error, cell), + }; + let identity = match request_cell_identity(req, &command_id) { + Ok(identity) => identity, + Err(error) => return map_cell_error(error, cell), + }; + let Some(input_object) = input.as_object_mut() else { + return map_cell_error( + CellDispatchError::BadRequest("input must be an object".into()), + cell, + ); + }; + input_object + .entry("todo_id".to_string()) + .or_insert_with(|| json!(id)); + match cell + .dispatch_idempotent(command, &identity, input, request_session(req)) + .await + { + Ok(dispatch) => { + seal_from_load(cell).await; + persist_working_copy(sql, cell)?; + arm_drain_alarm(storage, env, has_pending(cell)).await; + let title = cell + .load() + .await + .ok() + .flatten() + .map(|todo| TodoState::from(&todo).title) + .unwrap_or_default(); + wait_path_ok( + http_from_command(id, dispatch.payload(), &title), + &dispatch, + 200, + outbox_wire(cell), + ) + } + Err(error) => { + persist_working_copy(sql, cell)?; + arm_drain_alarm(storage, env, has_pending(cell)).await; + map_cell_error(error, cell) + } + } +} + +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 { + let mut body = payload.as_object().cloned().unwrap_or_default(); + body.entry("id".to_string()).or_insert_with(|| json!(id)); + body.entry("todo_id".to_string()) + .or_insert_with(|| json!(id)); + body.entry("owner_id".to_string()) + .or_insert_with(|| json!("")); + body.entry("title".to_string()) + .or_insert_with(|| json!(fallback_title)); + body.entry("status".to_string()) + .or_insert_with(|| json!("open")); + Value::Object(body) +} + +fn map_cell_error(error: CellDispatchError, cell: &AggregateCell) -> Result +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + let status = error.status_code(); + json_status( + json!({ + "error": error.client_message(), + "code": error.code(), + "outbox": outbox_wire(cell), + }), + status, + ) +} + +fn json_status(body: Value, status: u16) -> Result { + Ok(Response::from_json(&body)?.with_status(status)) +} + +async fn bounded_json( + req: &mut Request, +) -> std::result::Result { + if req + .headers() + .get("content-length") + .ok() + .flatten() + .and_then(|value| value.parse::().ok()) + .is_some_and(|length| length > MAX_CELL_REQUEST_BYTES) + { + return Err("cell request exceeds 2 MiB"); + } + let bytes = req + .bytes() + .await + .map_err(|_| "could not read cell request body")?; + if bytes.len() > MAX_CELL_REQUEST_BYTES { + return Err("cell request exceeds 2 MiB"); + } + serde_json::from_slice(&bytes).map_err(|_| "invalid cell request JSON") +} + +#[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())?; + let snapshots = load_snapshots(sql).map_err(|error| error.to_string())?; + cell.restore_durable_snapshots(snapshots) + .map_err(|error| error.to_string())?; + let commands = load_commands(sql).map_err(|error| error.to_string())?; + cell.restore_durable_commands(commands) + .map_err(|error| error.to_string())?; + let outbox = load_outbox(sql).map_err(|error| error.to_string())?; + cell.restore_durable_outbox(outbox) + .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<()> { + 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(), + ]), + )?; + } + } + 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()]), + )?; + } + persist_commands(sql, cell)?; + persist_outbox(sql, cell)?; + 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 persist_outbox(sql: &SqlStorage, cell: &AggregateCell) -> Result<()> +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + let rows = cell + .durable_outbox() + .map_err(|error| Error::RustError(error.to_string()))?; + sql.exec("DELETE FROM cell_outbox", None)?; + for message in rows { + let body = serde_json::to_string(&outbox_item(&message)) + .map_err(|error| Error::RustError(error.to_string()))?; + sql.exec( + "INSERT INTO cell_outbox (id, body) VALUES (?, ?)", + Some(vec![message.id.into(), body.into()]), + )?; + } + Ok(()) +} + +fn persist_commands(sql: &SqlStorage, cell: &AggregateCell) -> Result<()> +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + let rows = cell + .durable_commands() + .map_err(|error| Error::RustError(error.to_string()))?; + sql.exec("DELETE FROM cell_commands", None)?; + for command in rows { + sql.exec( + "INSERT INTO cell_commands (id, body) VALUES (?, ?)", + Some(vec![command.id.into(), command.body.into()]), + )?; + } + Ok(()) +} + +fn load_commands(sql: &SqlStorage) -> Result> { + sql.exec("SELECT id, body FROM cell_commands ORDER BY id", None)? + .to_array() +} + +fn outbox_item(message: &OutboxMessage) -> Value { + serde_json::to_value(CellOutboxWireItem::from_message(message)) + .expect("cell outbox wire item is serializable") +} + +#[derive(Default)] +struct CellOutboxWireBudget { + items: usize, + payload_bytes: usize, + wire_bytes: usize, +} + +impl CellOutboxWireBudget { + fn try_add(&mut self, message: &OutboxMessage) -> Option { + let item = outbox_item(message); + let encoded_bytes = serde_json::to_vec(&item).ok()?.len().saturating_add(1); + let items = self.items.saturating_add(1); + let payload_bytes = self.payload_bytes.saturating_add(message.payload.len()); + let wire_bytes = self.wire_bytes.saturating_add(encoded_bytes); + if items > MAX_CELL_OUTBOX_ITEMS + || payload_bytes > MAX_CELL_OUTBOX_PAYLOAD_BYTES + || wire_bytes > MAX_CELL_OUTBOX_WIRE_BYTES + { + return None; + } + self.items = items; + self.payload_bytes = payload_bytes; + self.wire_bytes = wire_bytes; + Some(item) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ClaimOutboxRequest { + worker_id: String, + limit: usize, + lease_ms: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettleOutboxRequest { + worker_id: String, + ids: Vec, + #[serde(default)] + error: Option, +} + +async fn claim_outbox( + sql: &SqlStorage, + storage: &Storage, + env: &Env, + cell: &AggregateCell, + req: &mut Request, +) -> Result +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + let body = match bounded_json::(req).await { + Ok(body) => body, + Err(_) => { + return json_status( + json!({ "code": "BAD_REQUEST", "error": "invalid outbox claim request" }), + 400, + ) + } + }; + if !valid_worker_id(&body.worker_id) + || body.limit == 0 + || body.limit > MAX_CELL_OUTBOX_ITEMS + || !(1_000..=60_000).contains(&body.lease_ms) + { + return json_status( + json!({ "code": "BAD_REQUEST", "error": "invalid outbox claim bounds" }), + 400, + ); + } + let now = SystemTime::UNIX_EPOCH + Duration::from_millis(Date::now().as_millis()); + let lease = Duration::from_millis(body.lease_ms); + let mut rows = cell + .durable_outbox() + .map_err(|error| Error::RustError(error.to_string()))?; + let mut claimed = Vec::new(); + let mut budget = CellOutboxWireBudget::default(); + let mut bound_violation = false; + for row in rows.iter_mut().filter(|row| row.is_claimable_at(now)) { + if claimed.len() == body.limit { + break; + } + let mut candidate = row.clone(); + candidate + .claim_at(body.worker_id.clone(), lease, now) + .map_err(|error| Error::RustError(error.to_string()))?; + let Some(item) = budget.try_add(&candidate) else { + bound_violation = true; + break; + }; + *row = candidate; + claimed.push(item); + } + if bound_violation && claimed.is_empty() { + return json_status( + json!({ "code": "INTERNAL", "error": "stored outbox row violates transport bounds" }), + 500, + ); + } + if !claimed.is_empty() { + cell.restore_durable_outbox(rows) + .map_err(|error| Error::RustError(error.to_string()))?; + persist_outbox(sql, cell)?; + } + arm_drain_alarm(storage, env, has_pending(cell)).await; + json_status(json!({ "outbox": claimed }), 200) +} + +async fn settle_outbox( + sql: &SqlStorage, + storage: &Storage, + env: &Env, + cell: &AggregateCell, + req: &mut Request, + complete: bool, +) -> Result +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + let body = match bounded_json::(req).await { + Ok(body) => body, + Err(_) => { + return json_status( + json!({ "code": "BAD_REQUEST", "error": "invalid outbox settlement request" }), + 400, + ) + } + }; + let unique = body.ids.iter().collect::>(); + if !valid_worker_id(&body.worker_id) + || body.ids.is_empty() + || body.ids.len() > MAX_CELL_OUTBOX_ITEMS + || unique.len() != body.ids.len() + || body + .ids + .iter() + .any(|id| id.is_empty() || id.len() > 512 || id.chars().any(char::is_control)) + || body.error.as_ref().is_some_and(|error| error.len() > 1_024) + { + return json_status( + json!({ "code": "BAD_REQUEST", "error": "invalid outbox settlement bounds" }), + 400, + ); + } + let mut rows = cell + .durable_outbox() + .map_err(|error| Error::RustError(error.to_string()))?; + if body.ids.iter().any(|id| { + !rows + .iter() + .any(|row| &row.id == id && row.is_in_flight() && row.is_claimed_by(&body.worker_id)) + }) { + return json_status( + json!({ "code": "CONFLICT", "error": "outbox claim is stale or not owned by this worker" }), + 409, + ); + } + for row in rows + .iter_mut() + .filter(|row| body.ids.iter().any(|id| id == &row.id)) + { + let result = if complete { + row.complete() + } else { + row.release(body.error.clone().unwrap_or_default()) + }; + if let Err(error) = result { + return json_status( + json!({ "code": "CONFLICT", "error": error.to_string() }), + 409, + ); + } + } + cell.restore_durable_outbox(rows) + .map_err(|error| Error::RustError(error.to_string()))?; + persist_outbox(sql, cell)?; + arm_drain_alarm(storage, env, has_pending(cell)).await; + json_status(json!({ "ok": true }), 200) +} + +fn valid_worker_id(worker_id: &str) -> bool { + !worker_id.is_empty() && worker_id.len() <= 512 && !worker_id.chars().any(char::is_control) +} + +async fn arm_drain_alarm(storage: &Storage, env: &Env, pending: bool) { + if !pending { + let _ = storage.delete_alarm().await; + return; + } + if drain_url(env).is_none() { + return; + } + let ms = env + .var("OUTBOX_DRAIN_INTERVAL_MS") + .ok() + .and_then(|value| value.to_string().parse::().ok()) + .unwrap_or(5_000) + .max(1_000); + let _ = storage.set_alarm(Duration::from_millis(ms)).await; +} + +async fn run_outbox_alarm( + storage: &Storage, + env: &Env, + cell: &AggregateCell, + kind: &str, + id: &str, +) -> Result +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + if has_pending(cell) { + offer_pending(env, kind, id).await; + arm_drain_alarm(storage, env, true).await; + } else { + arm_drain_alarm(storage, env, false).await; + } + Response::ok("ok") +} + +fn drain_url(env: &Env) -> Option { + env.var("OUTBOX_DRAIN_URL") + .ok() + .map(|value| value.to_string()) + .filter(|url| !url.is_empty()) +} + +async fn offer_pending(env: &Env, kind: &str, id: &str) { + let Some(url) = drain_url(env) else { + return; + }; + let Ok(secret) = env + .secret(CELL_INTERNAL_SECRET_ENV) + .map(|value| value.to_string()) + .or_else(|_| { + env.var(CELL_INTERNAL_SECRET_ENV) + .map(|value| value.to_string()) + }) + else { + return; + }; + let payload = json!({ "kind": kind, "id": id }); + let headers = Headers::new(); + let _ = headers.set("content-type", "application/json"); + let _ = headers.set(CELL_INTERNAL_SECRET_HEADER, &secret); + let mut init = RequestInit::new(); + init.with_method(Method::Post) + .with_headers(headers) + .with_body(Some(worker::wasm_bindgen::JsValue::from_str( + &payload.to_string(), + ))); + if let Ok(req) = Request::new_with_init(&url, &init) { + let _ = Fetch::Request(req).send().await; + } +} + +fn load_outbox(sql: &SqlStorage) -> Result> { + let rows: Vec = match sql.exec("SELECT id, body FROM cell_outbox", None) { + Ok(cursor) => cursor.to_array()?, + Err(_) => return Ok(Vec::new()), + }; + rows.into_iter() + .map(|row| { + let value: Value = serde_json::from_str(&row.body) + .map_err(|error| Error::RustError(error.to_string()))?; + parse_outbox_item(&value) + }) + .collect() +} + +fn parse_outbox_item(item: &Value) -> Result { + serde_json::from_value::(item.clone()) + .map_err(|error| Error::RustError(format!("outbox wire: {error}")))? + .try_into_stored_message() + .map_err(Error::RustError) +} + +#[derive(Deserialize)] +struct OutboxRow { + #[allow(dead_code)] + id: String, + body: String, +} + +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( + "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) +} + +#[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/celld/worker/wrangler.jsonc b/tests/celld/worker/wrangler.jsonc new file mode 100644 index 00000000..3da05e21 --- /dev/null +++ b/tests/celld/worker/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "distributed-todo-cell", + "main": "build/worker/shim.mjs", + "compatibility_date": "2026-01-01", + "durable_objects": { + "bindings": [ + { "name": "TODO", "class_name": "TodoCell" }, + { "name": "CHAT", "class_name": "ChatCell" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["TodoCell"] }, + { "tag": "v2", "new_sqlite_classes": ["ChatCell"] } + ], + "vars": { + "DISTRIBUTED_INTERNAL_SECRET": "test-only-internal-secret-change-me-2026", + "OUTBOX_DRAIN_URL": "http://host.docker.internal:8791/internal/outbox/drain", + "OUTBOX_DRAIN_INTERVAL_MS": "5000" + } +} diff --git a/tests/durable_enqueue_sqlite/main.rs b/tests/durable_enqueue_sqlite/main.rs index 2be1b41b..3b3a4930 100644 --- a/tests/durable_enqueue_sqlite/main.rs +++ b/tests/durable_enqueue_sqlite/main.rs @@ -49,6 +49,25 @@ async fn service() -> Repo { .aggregate::() } +async fn wait_until_published(store: &impl OutboxStore, count: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if store + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) + .await + .unwrap() + .len() + >= count + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("immediate publish should settle outbox rows"); +} + #[tokio::test] async fn commit_publishes_immediately_over_sqlite() { let repo = service().await; @@ -62,13 +81,14 @@ async fn commit_publishes_immediately_over_sqlite() { ) .with_bus(InMemoryBus::new()); - // The handler claims the outbox row in the SQL transaction, then publishes - // it immediately through the attached bus. + // Command completion returns at durable commit; the bounded worker + // claims the pending row and publishes it. service .dispatch("counter.touch", json!({}), Session::new()) .await .unwrap(); + wait_until_published(&store, 1).await; let published = store .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await @@ -100,6 +120,7 @@ async fn run_consumes_command_and_publishes_over_sqlite() { bus.send("counter.touch", b"{}".to_vec()).await.unwrap(); service.run(RunOptions::idempotent()).await.unwrap(); + wait_until_published(&store, 1).await; let published = store .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await diff --git a/tests/e2e-celld/.gitignore b/tests/e2e-celld/.gitignore new file mode 100644 index 00000000..484b142d --- /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 00000000..7740aa12 --- /dev/null +++ b/tests/e2e-celld/Cargo.toml @@ -0,0 +1,30 @@ +# Sibling of tests/e2e-ui. Same domain crates; new service crates. +# GraphQL wait-dispatches Todo create/complete and chat.post to celld. +# Cell outbox drains onto NATS; @live and Eventual projectors stay here. +[workspace] +resolver = "2" +members = [ + "crates/todo-service", + "crates/chat-service", + "crates/blob-service", + "crates/identity-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", "nats"] } +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 00000000..378ce650 --- /dev/null +++ b/tests/e2e-celld/Makefile @@ -0,0 +1,179 @@ +# 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 +# +# make run watches Rust (cargo-watch) and the cell worker; Vite HMR covers +# 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 test help wasm ensure-watch + +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) +NATS_PORT ?= 14222 +NATS_URL ?= nats://127.0.0.1:$(NATS_PORT) +NPM ?= npm +WATCH ?= 1 +WATCH_WORKER ?= 1 +DISTRIBUTED_INTERNAL_SECRET ?= test-only-internal-secret-change-me-2026 + +wasm: + $(MAKE) -C ../e2e-ui wasm + +ensure-watch: + @command -v cargo-watch >/dev/null || { echo "installing cargo-watch…"; cargo install cargo-watch; } + +run: wasm $(if $(filter 1,$(WATCH) $(WATCH_WORKER)),ensure-watch) + @set -e; \ + if [ -f $(ENV_FILE) ]; then set -a; . ./$(ENV_FILE); set +a; fi; \ + if [ -n "$${E2E_CELLD_DATABASE_URL:-}" ]; then export DATABASE_URL="$$E2E_CELLD_DATABASE_URL"; fi; \ + case "$${DATABASE_URL:-}" in \ + postgres://*|postgresql://*) ;; \ + *) \ + echo "e2e-celld requires Postgres DATABASE_URL — start: make -C ../e2e-ui up"; \ + echo "or: E2E_CELLD_DATABASE_URL=postgres://… make run"; \ + exit 1; \ + ;; \ + esac; \ + 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"; \ + _nats="$(NATS_URL)"; \ + if ! nc -z 127.0.0.1 $(NATS_PORT) 2>/dev/null; then \ + echo "NATS not reachable at $$_nats — start: make -C ../e2e-ui up-celld-nats"; \ + exit 1; \ + fi; \ + export NATS_URL="$$_nats"; \ + export DISTRIBUTED_INTERNAL_SECRET="$(DISTRIBUTED_INTERNAL_SECRET)"; \ + export PUBLIC_E2E_PROFILE="celld-nats"; \ + _bind="$${BIND:-127.0.0.1:8791}"; \ + _api_port="$${_bind##*:}"; \ + _base="$${E2E_API_ORIGIN:-http://127.0.0.1:$${_api_port}}"; \ + _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"; \ + export BIND="127.0.0.1:$${_api_port}"; \ + 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-worker.pid .make-runner.log; \ + echo "starting e2e-celld API on $$_base (DATABASE_URL=$$DATABASE_URL CELLD_URL=$$CELLD_URL NATS_URL=$$NATS_URL) …"; \ + if [ "$(WATCH)" = "1" ]; then \ + echo "API cargo-watch on (WATCH=0 to disable) …"; \ + cargo watch -d 1 \ + -i target -i '*.db' -i '.make-*' \ + -w crates -w Cargo.toml \ + -w ../../src -w ../../Cargo.toml \ + -w ../e2e-ui/crates/todo-domain \ + -w ../e2e-ui/crates/chat-domain \ + -w ../e2e-ui/crates/blob-domain \ + -w ../e2e-ui/crates/readmodels \ + -w ../e2e-ui/crates/projections \ + -x "run -p e2e-celld-runner --bin e2e-celld" \ + > .make-runner.log 2>&1 & \ + else \ + cargo run -p e2e-celld-runner --bin e2e-celld > .make-runner.log 2>&1 & \ + fi; \ + echo $$! > .make-runner.pid; \ + if [ "$(WATCH_WORKER)" = "1" ]; then \ + echo "worker cargo-watch on (WATCH_WORKER=0 to disable) …"; \ + $(MAKE) -C ../celld watch > .make-worker.log 2>&1 & \ + echo $$! > .make-worker.pid; \ + fi; \ + 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); \ + stop_pidfile() { \ + [ -f "$$1" ] || return 0; \ + _pid=$$(cat "$$1"); \ + for _child in $$(pgrep -P "$$_pid" 2>/dev/null); do \ + pkill -P "$$_child" 2>/dev/null || true; \ + kill "$$_child" 2>/dev/null || true; \ + done; \ + kill "$$_pid" 2>/dev/null || true; \ + rm -f "$$1"; \ + }; \ + cleanup() { \ + stop_pidfile .make-ui.pid; \ + stop_pidfile .make-runner.pid; \ + stop_pidfile .make-worker.pid; \ + 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; \ + }; \ + trap cleanup EXIT INT TERM; \ + echo ""; \ + echo " UI $$_ui (celld badge in the navbar)"; \ + echo " API $$_base"; \ + echo " CELLD $$CELLD_URL"; \ + echo " NATS $$NATS_URL"; \ + echo " GraphiQL $$_base/graphql"; \ + echo " this is tests/e2e-celld — not tests/e2e-ui make run"; \ + echo " watch: GraphQL cargo-watch, worker rebuild+deploy, Vite HMR"; \ + echo " Ctrl-C stops API + UI + worker watch"; \ + echo ""; \ + wait $$(cat .make-ui.pid) 2>/dev/null || wait + +test: + cargo test --workspace -- --nocapture + +stop: + @stop_pidfile() { \ + [ -f "$$1" ] || return 0; \ + _pid=$$(cat "$$1"); \ + for _child in $$(pgrep -P "$$_pid" 2>/dev/null); do \ + pkill -P "$$_child" 2>/dev/null || true; \ + kill "$$_child" 2>/dev/null || true; \ + done; \ + kill "$$_pid" 2>/dev/null || true; \ + rm -f "$$1"; \ + }; \ + stop_pidfile .make-ui.pid; \ + stop_pidfile .make-runner.pid; \ + stop_pidfile .make-worker.pid; \ + 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; \ + echo stopped + +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" + @echo " make stop stop API + UI + worker watch" + @echo " infra: make -C ../e2e-ui up && make -C ../e2e-ui up-celld-nats" + @echo " Postgres read models from e2e-ui.env DATABASE_URL (not sqlite)" diff --git a/tests/e2e-celld/README.md b/tests/e2e-celld/README.md new file mode 100644 index 00000000..fb5f97a5 --- /dev/null +++ b/tests/e2e-celld/README.md @@ -0,0 +1,80 @@ +# 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 + `CelldRoute` (kind/shard/payload) for `TodoCell` | +| `e2e-celld-chat` | Chat mounts + `CelldRoute` for `ChatCell`; `@live` stays here | +| `e2e-celld-blob` | Blob Atomic commands (in-process) | +| `e2e-celld-identity` | Zitadel ingress/scrape + AuthUsers projector | +| `e2e-celld-graphql` | GraphQL process (`graphql_router_with_host`) | +| `tests/celld/worker` | `TodoCell` + `ChatCell` | + +`distributed::cell_host::CelldCommandHost` wait-dispatches `todo.create` / +`todo.complete` / `chat.post` to `{CELLD_URL}/{kind}/{shard}/{command}`. +Aggregate crates only supply a `CelldRoute` (kind, shard id, payload map). +The cell commits events and outbox in one private SQLite. A single bounded host +scheduler claims leased rows, publishes them through `MessagePublisher` (NATS +here; Kafka/Rabbit swap the bus), and settles only rows owned by that worker. +Mutation completion only queues the durable cell address; it never waits for +the broker. Cell alarms POST the same address-only hint to +`/internal/outbox/drain`. Eventual projectors here fill SQL so +`@live` still fires. Blob and identity stay in-process. GraphQL is the user edge (`OidcBearer` +on the engine); Zitadel Actions and outbox drain are internal HTTP on the +same process. GraphQL and projectors are not cell class methods. + +## Private HTTP boundary + +All cell reads, commands, outbox claim/settle operations, cell alarms, and +Zitadel internal routes require `DISTRIBUTED_INTERNAL_SECRET` in the +`x-distributed-internal-secret` header. Startup fails when the secret is +missing or invalid. Local compose ports and the GraphQL listener bind to +loopback by default. The checked-in value in the worker config is test-only; +real deployments must install a unique secret binding and still use TLS and +network policy. Health endpoints remain public and disclose no secret. + +The threat model assumes the GraphQL edge has already verified user identity. +The internal secret prevents a network caller from forging trusted user/role, +service, partition, alarm, or outbox-settlement headers. Strict envelopes, +body limits, URL encoding, disabled redirects, timeouts, leases, and ownership +tokens bound the damage from malformed, replayed, slow, or concurrent requests. + +Workspace tests (no live celld): + +```sh +make test # cargo test --workspace (CI) +``` + +```sh +cd tests/e2e-ui +make up # Zitadel + Postgres (read models + login) +make up-celld-nats # Azurite + celld + NATS + +cd ../e2e-celld +make run # GraphQL :8791 + UI :5180 (watches sources) +``` + +Eventual projectors and `@live` use **Postgres** from `e2e-ui.env` +(`DATABASE_URL`). There is no SQLite read-model path. Cells still keep +private SQLite per Durable Object. Override with `E2E_CELLD_DATABASE_URL`. + +`make run` reloads on its own: + +| Surface | How | +|---|---| +| Svelte UI | Vite HMR (`npm run dev`) | +| GraphQL host | `cargo-watch` on `src/`, e2e-celld crates, and domain crates | +| Cell worker | `cargo-watch` → `worker-build --dev` + `celld deploy` + compose restart | + +`WATCH=0` / `WATCH_WORKER=0` turn those cargo-watch loops off. Compose +`CELLD_WATCH` is the node's SQLite working directory, not a source watcher +— celld loads a deployment at startup, so worker changes need a restart +(the watch target does that). First `cargo-watch` install: `cargo install cargo-watch`. + +Open `http://localhost:5180`. The navbar shows a **celld** badge. Sign in +(`alice` / `Password1!` when Zitadel is up). Todos create/complete and +lobby posts go to cells; open Chat in two tabs to see `@live` still fire. + +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 00000000..a6bfe3e7 --- /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 00000000..dbaf42fe --- /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 00000000..8da26b14 --- /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 00000000..ed2c5d85 --- /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 00000000..d886bc20 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "e2e-celld-chat" +version.workspace = true +edition.workspace = true +publish = false +description = "Chat lobby; celld wait-path for chat.post; outbox drains to NATS" + +[dependencies] +distributed = { workspace = true } +serde_json = { workspace = true } +chat-domain = { path = "../../../e2e-ui/crates/chat-domain" } +e2e-projections = { path = "../../../e2e-ui/crates/projections" } 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 00000000..dbaf42fe --- /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/handlers/events/mod.rs b/tests/e2e-celld/crates/chat-service/src/handlers/events/mod.rs new file mode 100644 index 00000000..561692bf --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/events/mod.rs @@ -0,0 +1 @@ +pub mod project_chat_messages; 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 00000000..89d15e44 --- /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/mod.rs b/tests/e2e-celld/crates/chat-service/src/handlers/mod.rs new file mode 100644 index 00000000..a9970c28 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/handlers/mod.rs @@ -0,0 +1 @@ +pub mod events; diff --git a/tests/e2e-celld/crates/chat-service/src/host.rs b/tests/e2e-celld/crates/chat-service/src/host.rs new file mode 100644 index 00000000..4f46e4f8 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/host.rs @@ -0,0 +1,34 @@ +//! Chat cell wait-path: shard + GraphQL payload. Outbox drain lives in +//! [`distributed::cell_host`]. + +use distributed::cell_host::CelldRoute; +use distributed::microsvc::Session; +use serde_json::{json, Value}; + +/// `POST {CELLD_URL}/chat/{message_id}/chat.post`. +pub fn celld_route() -> CelldRoute { + CelldRoute::new(&["chat.post"], "chat", chat_shard, graphql_chat_payload) +} + +fn chat_shard(input: &Value) -> Option { + input + .get("message_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn graphql_chat_payload(_command: &str, input: &Value, remote: &Value, session: &Session) -> Value { + json!({ + "message_id": remote.get("message_id").or_else(|| input.get("message_id")).cloned().unwrap_or(json!("")), + "room_id": remote.get("room_id").or_else(|| input.get("room_id")).cloned().unwrap_or(json!("lobby")), + "author_id": remote + .get("author_id") + .cloned() + .or_else(|| session.user_id().map(|id| json!(id))) + .unwrap_or(json!("")), + "body": remote.get("body").or_else(|| input.get("body")).cloned().unwrap_or(json!("")), + "created_at": remote.get("created_at").or_else(|| input.get("created_at")).cloned().unwrap_or(json!("")), + }) +} 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 00000000..7e7e43b1 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/lib.rs @@ -0,0 +1,13 @@ +//! Chat service crate: lobby messages. +//! +//! SOA mounts stay in-process. The optional celld host wait-dispatches +//! `chat.post` through [`distributed::cell_host::CelldCommandHost`]. GraphQL +//! `@live` stays here. + +mod bounds; +mod handlers; +mod host; +mod routes; + +pub use host::celld_route; +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 00000000..9389dfc3 --- /dev/null +++ b/tests/e2e-celld/crates/chat-service/src/routes.rs @@ -0,0 +1,42 @@ +//! Chat room messages + eventual 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; + +pub const MODULE_ID: &str = "chat"; + +type ChatRoutes = + Routes, ChatMessage>, S>>; + +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()) + .modeled_projector(chat_projector) + .handle(handlers::events::project_chat_messages::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 00000000..36ddad37 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/Cargo.toml @@ -0,0 +1,26 @@ +[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 } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +sqlx = { workspace = true } +axum = { workspace = true } +reqwest = { workspace = true } +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" } +e2e-celld-identity = { path = "../identity-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 00000000..96dc01d1 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/application.rs @@ -0,0 +1,33 @@ +//! 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_identity as identity; +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 + projector"), + (blob::MODULE_ID, "blob Atomic commands"), + (identity::MODULE_ID, "Zitadel ingress + AuthUsers projector"), +]; 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 00000000..dbaf42fe --- /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 00000000..b80a18b5 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/host.rs @@ -0,0 +1,167 @@ +//! Celld example GraphQL host. Not the e2e-ui one-process playground. +//! +//! Todo create/complete and chat.post wait-dispatch to celld. GraphQL `@live` +//! and Eventual projectors stay in this process. + +use std::sync::Arc; +use std::time::Duration; + +use distributed::bus::MessagePublisher; +use distributed::bus::NatsBus; +use distributed::cell_host::{CelldCommandHost, InternalHttpSecret}; +use distributed::command_dispatch::SharedCommandHost; +use distributed::graphql::IdentityConfig; +use distributed::microsvc::{ + spawn_outbox_publish_loop, spawn_service_consumer_loop, Service, CONSUMER_IDLE_POLL, +}; +use distributed::BusPublisher; +use distributed::{PostgresLockManager, PostgresRepository}; + +use crate::http::serve; +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 nats_url: String, + pub internal_secret: InternalHttpSecret, +} + +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={} NATS_URL={}", + E2E_UI_APPLICATION, options.bind, celld_url, options.nats_url + ); + if !(database_url.starts_with("postgres://") || database_url.starts_with("postgresql://")) { + return Err("e2e-celld requires Postgres DATABASE_URL (make -C tests/e2e-ui up)".into()); + } + run_postgres(database_url, options, celld_url).await +} + +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 nats = connect_nats(&options.nats_url).await?; + + let change_rx = repo.read_model_changes(); + let service = build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(nats.clone()); + let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let service = Arc::new(service.try_with_graphql(gql)?); + let publisher = BusPublisher::new(Arc::new(nats.clone())); + let celld_host = celld_command_host( + celld_url.clone(), + Arc::clone(&service), + publisher.clone(), + options.internal_secret.clone(), + )?; + let outbox_drain = celld_host.outbox_alarm_handler(); + let host: SharedCommandHost = Arc::new(celld_host); + + spawn_outbox_publish_loop( + repo.outbox_store(), + Arc::new(nats.clone()), + "e2e-celld", + Duration::from_secs(30), + 5, + ); + { + let repo = repo.clone(); + let locks = locks.clone(); + let nats = nats.clone(); + spawn_service_consumer_loop(move || { + build_service(repo.clone(), locks.clone(), repo.clone()) + .with_bus(nats.clone().with_idle_poll(CONSUMER_IDLE_POLL)) + }); + } + spawn_zitadel_scrape(repo.clone()); + + eprintln!( + "e2e-celld (postgres) listening on http://{} — cell wait-path; bus drain; @live stays here", + options.bind + ); + serve( + service, + host, + &options.bind, + Some(outbox_drain), + options.internal_secret, + ) + .await?; + Ok(()) +} + +fn celld_command_host

( + celld_url: String, + service: Arc, + publisher: P, + internal_secret: InternalHttpSecret, +) -> Result, distributed::microsvc::CausalDispatchError> +where + P: MessagePublisher + Clone + Send + Sync + 'static, +{ + Ok( + CelldCommandHost::new(celld_url, service, publisher, internal_secret)? + .route(e2e_celld_todo::celld_route()) + .route(e2e_celld_chat::celld_route()), + ) +} + +async fn connect_nats(url: &str) -> Result> { + let bus = NatsBus::connect(url) + .namespace("e2e-celld") + .group(BUS_GROUP) + .await?; + bus.ensure_stream().await?; + eprintln!("e2e-celld bus ready (nats {url}); swap connect_nats for Kafka/Rabbit"); + Ok(bus) +} + +fn spawn_zitadel_scrape(repo: R) +where + R: distributed::TransactionalCommit + + distributed::ReadModelWritePlanStore + + 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/http.rs b/tests/e2e-celld/crates/graphql-service/src/http.rs new file mode 100644 index 00000000..ca238e37 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/http.rs @@ -0,0 +1,177 @@ +//! Process HTTP: GraphQL is the user edge (engine `OidcBearer`). +//! +//! Zitadel Action ingress/scrape and cell outbox drain are internal — shared +//! secret or process-local, not a user Bearer. User writes stay on `/graphql`. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use distributed::cell_host::{ + InternalHttpSecret, CELL_INTERNAL_SECRET_HEADER, CELL_OUTBOX_DRAIN_PATH, +}; +use distributed::command_dispatch::SharedCommandHost; +use distributed::graphql::graphql_router_with_host; +use distributed::microsvc::{HandlerError, Service, Session}; +use futures_util::future::BoxFuture; +use serde_json::{json, Value}; + +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, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +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() + } + } +} + +async fn require_internal( + State(secret): State, + request: Request, + next: Next, +) -> Response { + if authorized_internal(request.headers(), &secret) { + return next.run(request).await; + } + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "code": "UNAUTHORIZED", "error": "unauthorized" })), + ) + .into_response() +} + +fn authorized_internal(headers: &HeaderMap, secret: &InternalHttpSecret) -> bool { + headers + .get(CELL_INTERNAL_SECRET_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|candidate| secret.matches(candidate)) +} + +/// Cell alarm POSTs pending outbox here; GraphQL publishes via MessagePublisher. +pub type InternalOutboxDrain = + Arc BoxFuture<'static, Result<(), String>> + Send + Sync>; + +/// GraphQL wait-dispatches through an explicit [`SharedCommandHost`]. +/// +/// Identity is the engine's (`OidcBearer` on `POST /graphql` / WS `connection_init`). +/// HTTP command routes stay off — `POST /todo.create` is 404. +pub async fn serve( + service: Arc, + host: SharedCommandHost, + addr: &str, + outbox_drain: Option, + internal_secret: InternalHttpSecret, +) -> Result<(), std::io::Error> { + let engine = service + .graphql_engine() + .ok_or_else(|| std::io::Error::other("serve 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 mut app = Router::new() + .route( + "/health", + get(move || { + let body = health_body.clone(); + async move { Json(body) } + }), + ) + .merge(graphql_router_with_host(engine, host)); + let mut internal = Router::new() + .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 } + }), + ); + if let Some(drain) = outbox_drain { + internal = internal.route( + CELL_OUTBOX_DRAIN_PATH, + post(move |Json(body): Json| { + let drain = Arc::clone(&drain); + async move { + match drain(body).await { + Ok(()) => ( + StatusCode::ACCEPTED, + Json(json!({ "ok": true })), + ), + Err(error) + if error.contains("capacity") || error.contains("not running") => + { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "code": "UNAVAILABLE", "error": "outbox scheduler unavailable" })), + ) + } + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(json!({ "code": "BAD_REQUEST", "error": "invalid outbox hint" })), + ), + } + } + }), + ); + } + app = app.merge(internal.route_layer(middleware::from_fn_with_state( + internal_secret, + require_internal, + ))); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await +} 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 00000000..74aaf1e8 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/lib.rs @@ -0,0 +1,25 @@ +//! GraphQL process for the celld example (sibling of e2e-ui, not `make run`). +//! +//! Todo create/complete wait-dispatch to celld. Chat, Blob, and identity stay +//! in-process via their service crates. Domain crates are the e2e-ui ones. + +mod application; +mod bounds; +mod host; +mod http; +pub mod modules; + +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_identity::{ + 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 00000000..c4ce4da4 --- /dev/null +++ b/tests/e2e-celld/crates/graphql-service/src/modules/compose.rs @@ -0,0 +1,81 @@ +//! 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 e2e_celld_identity::Identity; +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_identity as identity; +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::MODULE_ID, +]; + +/// Compose todo + chat + blob + identity 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, + AggregateRepository, Identity>: + 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.clone(), + locks.clone(), + read_models.clone(), + projections.blob, + ); + let identity = identity::routes(repo, locks, read_models); + + // GraphQL-only public write surface. POST /todo.* stays 404 (suite T0). + // Zitadel Action ingress is the identity crate, re-mounted in `http::serve`. + Service::new() + .named("e2e-ui") + .routes(todos) + .routes(chat) + .routes(blob) + .routes(identity) +} 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 00000000..1aec044d --- /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 00000000..068b0ba6 --- /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 00000000..69b4723c --- /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/identity-service/Cargo.toml b/tests/e2e-celld/crates/identity-service/Cargo.toml new file mode 100644 index 00000000..ba818c42 --- /dev/null +++ b/tests/e2e-celld/crates/identity-service/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "e2e-celld-identity" +version.workspace = true +edition.workspace = true +publish = false +description = "Zitadel identity ingestor + AuthUsers projector (in-process; not a cell)" + +[dependencies] +distributed = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +reqwest = { workspace = true } +e2e-projections = { path = "../../../e2e-ui/crates/projections" } +e2e-readmodels = { path = "../../../e2e-ui/crates/readmodels" } diff --git a/tests/e2e-celld/crates/identity-service/src/aggregate.rs b/tests/e2e-celld/crates/identity-service/src/aggregate.rs new file mode 100644 index 00000000..d5a3c15d --- /dev/null +++ b/tests/e2e-celld/crates/identity-service/src/aggregate.rs @@ -0,0 +1,29 @@ +//! Outbox leaf for identity ingress. No domain commands; AuthUsers is a read model. + +use distributed::{Aggregate, Entity, EventRecord}; + +/// Persistence leaf so Zitadel ingress can publish provider messages. +#[derive(Default)] +pub struct Identity { + entity: Entity, +} + +impl Aggregate for Identity { + type ReplayError = std::convert::Infallible; + + fn aggregate_type() -> &'static str { + "identity" + } + + fn entity(&self) -> &Entity { + &self.entity + } + + fn entity_mut(&mut self) -> &mut Entity { + &mut self.entity + } + + fn replay_event(&mut self, _event: &EventRecord) -> Result<(), Self::ReplayError> { + Ok(()) + } +} diff --git a/tests/e2e-celld/crates/identity-service/src/bounds.rs b/tests/e2e-celld/crates/identity-service/src/bounds.rs new file mode 100644 index 00000000..dbaf42fe --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/deps.rs b/tests/e2e-celld/crates/identity-service/src/deps.rs new file mode 100644 index 00000000..ecd685c0 --- /dev/null +++ b/tests/e2e-celld/crates/identity-service/src/deps.rs @@ -0,0 +1,9 @@ +use distributed::microsvc::RepoReadModelDependencies; +use distributed::{AggregateRepository, QueuedRepository}; + +use crate::aggregate::Identity; + +pub type QueuedStore = QueuedRepository; + +pub type IdentityRepo = AggregateRepository, Identity>; +pub type AuthDeps = RepoReadModelDependencies, S>; diff --git a/tests/e2e-celld/crates/identity-service/src/handlers/events/mod.rs b/tests/e2e-celld/crates/identity-service/src/handlers/events/mod.rs new file mode 100644 index 00000000..6e55e44f --- /dev/null +++ b/tests/e2e-celld/crates/identity-service/src/handlers/events/mod.rs @@ -0,0 +1 @@ +pub mod project_auth_user; diff --git a/tests/e2e-celld/crates/identity-service/src/handlers/events/project_auth_user.rs b/tests/e2e-celld/crates/identity-service/src/handlers/events/project_auth_user.rs new file mode 100644 index 00000000..fa10caea --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/handlers/ingestors/mod.rs b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/mod.rs new file mode 100644 index 00000000..b85b44a6 --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/handlers/ingestors/zitadel/auth.rs b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/zitadel/auth.rs new file mode 100644 index 00000000..e748a155 --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/handlers/ingestors/zitadel/handle.rs b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/zitadel/handle.rs new file mode 100644 index 00000000..b37b2b1e --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/handlers/ingestors/zitadel/map.rs b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/zitadel/map.rs new file mode 100644 index 00000000..9693b44a --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/handlers/ingestors/zitadel/mod.rs b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/zitadel/mod.rs new file mode 100644 index 00000000..422982ac --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/handlers/ingestors/zitadel/publish.rs b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/zitadel/publish.rs new file mode 100644 index 00000000..cde74935 --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/handlers/ingestors/zitadel/scrape.rs b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/zitadel/scrape.rs new file mode 100644 index 00000000..c988fcab --- /dev/null +++ b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/zitadel/scrape.rs @@ -0,0 +1,538 @@ +//! 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::read_model::ReadModelWritePlanBuilder; +use distributed::{ReadModelWritePlanStore, TransactionalCommit}; +use e2e_projections::{ + map_zitadel_user_status, map_zitadel_user_upsert, 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( + outbox: &R, + directory: &S, + cfg: &ZitadelScrapeConfig, +) -> ScrapeReport +where + R: TransactionalCommit, + S: ReadModelWritePlanStore, +{ + 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; + }; + if let Err(e) = materialize_auth_user(directory, &mapped).await { + report.errors.push(format!( + "user {}: auth_users upsert failed: {e}", + mapped.payload.provider_subject + )); + continue; + } + match publish_mapped_delivery(outbox, &mapped).await { + Ok(()) => report.published += 1, + Err(e) => { + if is_expected_scrape_duplicate(&e) { + report.skipped += 1; + } else { + report.errors.push(format!( + "user {}: publish failed: {e}", + mapped.payload.provider_subject + )); + } + } + } + } + report +} + +async fn materialize_auth_user( + store: &S, + mapped: &MappedDelivery, +) -> Result<(), String> { + let name = mapped.message_name.as_str(); + let row = if name.contains("deactivated") || name.contains("reactivated") { + map_zitadel_user_status(name, &mapped.payload) + } else { + map_zitadel_user_upsert(name, &mapped.payload) + }; + let mut plan = ReadModelWritePlanBuilder::new(); + plan.upsert(&row).map_err(|e| e.to_string())?; + plan.commit(store).await.map_err(|e| e.to_string())?; + Ok(()) +} + +/// 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 + ReadModelWritePlanStore + 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, &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, &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/identity-service/src/handlers/ingestors/zitadel_scrape.rs b/tests/e2e-celld/crates/identity-service/src/handlers/ingestors/zitadel_scrape.rs new file mode 100644 index 00000000..8b8e5ba8 --- /dev/null +++ b/tests/e2e-celld/crates/identity-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: crate::bounds::ReadStore, +{ + // 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, ctx.read_model_store(), &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/identity-service/src/handlers/mod.rs b/tests/e2e-celld/crates/identity-service/src/handlers/mod.rs new file mode 100644 index 00000000..e7d0ab09 --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/handlers/util.rs b/tests/e2e-celld/crates/identity-service/src/handlers/util.rs new file mode 100644 index 00000000..9aa0154b --- /dev/null +++ b/tests/e2e-celld/crates/identity-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/identity-service/src/lib.rs b/tests/e2e-celld/crates/identity-service/src/lib.rs new file mode 100644 index 00000000..055eb326 --- /dev/null +++ b/tests/e2e-celld/crates/identity-service/src/lib.rs @@ -0,0 +1,16 @@ +//! Identity service crate: Zitadel ingress/scrape + AuthUsers projection. +//! +//! Not a cell. Chat and Blob stay in their own crates; this only owns IdP +//! import so AuthUsers joins are not mounted on the chat aggregate. + +mod aggregate; +mod bounds; +mod deps; +pub mod handlers; +mod routes; + +pub use aggregate::Identity; +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/identity-service/src/routes.rs b/tests/e2e-celld/crates/identity-service/src/routes.rs new file mode 100644 index 00000000..6ff899ed --- /dev/null +++ b/tests/e2e-celld/crates/identity-service/src/routes.rs @@ -0,0 +1,48 @@ +//! Zitadel ingress/scrape commands + AuthUsers projector. + +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; + +use crate::aggregate::Identity; +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers; + +pub const MODULE_ID: &str = "identity"; + +type IdentityRoutes = + Routes, Identity>, S>>; + +pub fn routes(repo: R, locks: L, read_models: S) -> IdentityRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, Identity>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + Routes::for_aggregate::(repo, locks, read_models) + .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, + ) + .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/runner/Cargo.toml b/tests/e2e-celld/crates/runner/Cargo.toml new file mode 100644 index 00000000..c1566f38 --- /dev/null +++ b/tests/e2e-celld/crates/runner/Cargo.toml @@ -0,0 +1,15 @@ +[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] +distributed = { workspace = true } +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 00000000..2ee27e7b --- /dev/null +++ b/tests/e2e-celld/crates/runner/src/main.rs @@ -0,0 +1,43 @@ +//! Celld example runner (not `tests/e2e-ui` / `make run`). +//! +//! Env: +//! - `CELLD_URL` — required +//! - `NATS_URL` — required (JetStream; Eventual projectors subscribe here) +//! - `DATABASE_URL` — required `postgres://…` (from `make -C tests/e2e-ui up`) +//! - `BIND` (default `127.0.0.1:8791`) +//! - `OIDC_*` → OidcBearer; else DevHeaders + +use std::env; + +use distributed::cell_host::{InternalHttpSecret, CELL_INTERNAL_SECRET_ENV}; +use e2e_celld_graphql::{identity_from_env, run, HostOptions}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let database_url = env::var("DATABASE_URL") + .map_err(|_| "DATABASE_URL is required (postgres://…). Start: make -C tests/e2e-ui up")?; + if !(database_url.starts_with("postgres://") || database_url.starts_with("postgresql://")) { + return Err("e2e-celld requires Postgres DATABASE_URL (not sqlite)".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")?; + let nats_url = env::var("NATS_URL") + .map_err(|_| "NATS_URL is required. Start infra: make -C tests/e2e-ui up-celld-nats")?; + let internal_secret = InternalHttpSecret::new( + env::var(CELL_INTERNAL_SECRET_ENV) + .map_err(|_| "DISTRIBUTED_INTERNAL_SECRET is required")?, + )?; + eprintln!("e2e-celld CELLD_URL={celld_url} NATS_URL={nats_url}"); + run( + &database_url, + HostOptions { + bind, + identity: identity_from_env(), + celld_url, + nats_url, + internal_secret, + }, + ) + .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 00000000..a3811910 --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "e2e-celld-todo" +version.workspace = true +edition.workspace = true +publish = false +description = "Todo service: celld wait-path; cell outbox drains to NATS" + +[dependencies] +distributed = { 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 00000000..dbaf42fe --- /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 00000000..a5bc7069 --- /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 00000000..4e13f8fe --- /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 00000000..b55607ed --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/src/host.rs @@ -0,0 +1,92 @@ +//! Todo cell wait-path: shard + GraphQL payload. Outbox drain lives in +//! [`distributed::cell_host`]. + +use distributed::cell_host::CelldRoute; +use distributed::microsvc::Session; +use serde_json::{json, Value}; + +const TODO_CELL_COMMANDS: &[&str] = &[ + "todo.create", + "todo.rename", + "todo.complete", + "todo.reopen", + "todo.archive", + "todo.force_archive", + "todo.purge", +]; + +/// Route every Todo aggregate transition to the same cell shard. +pub fn celld_route() -> CelldRoute { + CelldRoute::new(TODO_CELL_COMMANDS, "todo", todo_shard, graphql_todo_payload) +} + +fn todo_shard(input: &Value) -> Option { + input + .get("todo_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +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(|| match command { + "todo.complete" => json!("completed"), + "todo.archive" | "todo.force_archive" => json!("archived"), + _ => json!("open"), + }); + match command { + "todo.create" => json!({ + "todo_id": id, + "owner_id": remote + .get("owner_id") + .cloned() + .or_else(|| session.user_id().map(|id| json!(id))) + .unwrap_or(json!("")), + "title": remote.get("title").or_else(|| input.get("title")).cloned().unwrap_or(json!("")), + "status": status, + }), + "todo.rename" => json!({ + "todo_id": id, + "title": remote.get("title").or_else(|| input.get("title")).cloned().unwrap_or(json!("")), + "status": status, + }), + "todo.complete" | "todo.reopen" | "todo.archive" => { + json!({ "todo_id": id, "status": status }) + } + "todo.force_archive" => json!({ + "todo_id": id, + "owner_id": remote.get("owner_id").cloned().unwrap_or(json!("")), + "status": status, + "archived_by": remote + .get("archived_by") + .cloned() + .or_else(|| session.user_id().map(|id| json!(id))) + .unwrap_or(json!("")), + }), + "todo.purge" => json!({ + "todo_id": id, + "purged": remote.get("purged").cloned().unwrap_or(json!(true)), + }), + _ => remote.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn celld_route_keeps_every_todo_transition_on_one_shard() { + assert_eq!(celld_route().commands, TODO_CELL_COMMANDS); + } +} 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 00000000..294e3b8c --- /dev/null +++ b/tests/e2e-celld/crates/todo-service/src/lib.rs @@ -0,0 +1,12 @@ +//! Todo service crate for the celld example. +//! +//! Domain commands stay in `todo-domain`. Every Todo aggregate transition is +//! wait-dispatched to celld through [`distributed::cell_host::CelldCommandHost`]. + +mod bounds; +mod handlers; +mod host; +mod routes; + +pub use host::celld_route; +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 00000000..4e20d90c --- /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) +} diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 0427d580..67de057e 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -1,13 +1,16 @@ # e2e-ui template — run from this directory. # # make up # Docker: Postgres + Zitadel + bootstrap env -# make run # API + UI (uses e2e-ui.env if present) +# 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 / 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 + gen-client check-client contracts-check check clean help ensure-watch \ + 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. @@ -22,9 +25,24 @@ ENV_FILE ?= e2e-ui.env NPM ?= npm JS_DIR ?= ../../js CARGO_TEST_FLAGS ?= -- --nocapture +WATCH ?= 1 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) +DISTRIBUTED_INTERNAL_SECRET ?= test-only-internal-secret-change-me-2026 +# 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 @@ -35,8 +53,11 @@ up: down: docker compose -f docker/docker-compose.yml down +ensure-watch: + @command -v cargo-watch >/dev/null || { echo "installing cargo-watch…"; cargo install cargo-watch; } + ## API + UI (loads e2e-ui.env when present) -run: ui-install +run: ui-install $(if $(filter 1,$(WATCH)),ensure-watch) @set -e; \ if [ -f $(ENV_FILE) ]; then set -a; . ./$(ENV_FILE); set +a; fi; \ _bind="$${BIND:-127.0.0.1:8791}"; \ @@ -51,7 +72,17 @@ run: ui-install 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 API ($${DATABASE_URL:-sqlite}) on $$_base …"; \ - cargo run -p e2e-runner --bin e2e-ui > .make-runner.log 2>&1 & \ + if [ "$(WATCH)" = "1" ]; then \ + echo "API cargo-watch on (WATCH=0 to disable) …"; \ + cargo watch -d 1 \ + -i target -i '*.db' -i '.make-*' \ + -w crates -w Cargo.toml \ + -w ../../src -w ../../Cargo.toml \ + -x "run -p e2e-runner --bin e2e-ui" \ + > .make-runner.log 2>&1 & \ + else \ + cargo run -p e2e-runner --bin e2e-ui > .make-runner.log 2>&1 & \ + fi; \ echo $$! > .make-runner.pid; \ ok=0; \ for i in $$(seq 1 80); do \ @@ -68,12 +99,21 @@ run: ui-install cd ui && $(NPM) run dev -- --host $$_ui_host --port $$_ui_port & \ echo $$! > ../.make-ui.pid; \ cd ..; \ + stop_pidfile() { \ + [ -f "$$1" ] || return 0; \ + _pid=$$(cat "$$1"); \ + for _child in $$(pgrep -P "$$_pid" 2>/dev/null); do \ + pkill -P "$$_child" 2>/dev/null || true; \ + kill "$$_child" 2>/dev/null || true; \ + done; \ + kill "$$_pid" 2>/dev/null || true; \ + rm -f "$$1"; \ + }; \ 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; \ + stop_pidfile .make-ui.pid; \ + stop_pidfile .make-runner.pid; \ 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 ""; \ @@ -81,6 +121,7 @@ run: ui-install echo " API $$_base"; \ echo " GraphiQL $$_base/graphql"; \ echo " WS $$_base/graphql/ws"; \ + echo " watch: GraphQL cargo-watch, Vite HMR (WATCH=0 to disable)"; \ echo " Ctrl-C stops both"; \ echo ""; \ wait $$(cat .make-ui.pid) 2>/dev/null || wait @@ -90,12 +131,94 @@ run-api: cargo run -p e2e-runner 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 + @stop_pidfile() { \ + [ -f "$$1" ] || return 0; \ + _pid=$$(cat "$$1"); \ + for _child in $$(pgrep -P "$$_pid" 2>/dev/null); do \ + pkill -P "$$_child" 2>/dev/null || true; \ + kill "$$_child" 2>/dev/null || true; \ + done; \ + kill "$$_pid" 2>/dev/null || true; \ + rm -f "$$1"; \ + }; \ + stop_pidfile .make-ui.pid; \ + stop_pidfile .make-runner.pid; \ + 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; \ + 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)"; \ + 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; \ + 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+Chat 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 + -@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: + 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)" \ + DISTRIBUTED_INTERNAL_SECRET="$(DISTRIBUTED_INTERNAL_SECRET)" \ + cargo test --test celld -- --nocapture + +test-celld-nats: + @echo "optional profile smoke — default make test / make run unchanged" + cd $(REPO_ROOT) && \ + CELLD_URL="$(CELLD_URL)" NATS_URL="$(NATS_URL)" \ + DISTRIBUTED_INTERNAL_SECRET="$(DISTRIBUTED_INTERNAL_SECRET)" \ + 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" @@ -190,11 +313,17 @@ clean: stop help: @echo "e2e-ui" @echo " make up Postgres + Zitadel + OIDC bootstrap → e2e-ui.env" - @echo " make run API + UI (source e2e-ui.env when present)" + @echo " make run API + UI (cargo-watch GraphQL, Vite HMR; WATCH=0 one-shot)" @echo " make test offline suite + UI structural" @echo " make ci-offline CI drift + offline suites with safe pipeline overlap" @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 -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" @echo " GRAPHIQL=0 disable GraphiQL when running the API" diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index be0293a3..0f0359cb 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -4,6 +4,15 @@ A copyable Distributed service and SvelteKit UI demonstrating one modeled projection from aggregate transition to server read model, generated GraphQL client, optimistic replica update, and causal confirmation. +Rust · TypeScript · CQRS / ES · SvelteKit · celld · Kafka · NATS · RabbitMQ · +PSQL · SQLite · OIDC · Keycloak · Authentik + +Default is **one process** (`make run`). The same UI can wait-dispatch Todo +create/complete and `chat.post` to celld from [`../e2e-celld`](../e2e-celld). +Todo commands are `portable_command!` declarations in `todo-domain`; hosts +only `.mount` them. Chat is a small cell so `@live` still coming from GraphQL +is the demo. + ## Option A — local cluster + workspace GitOps One-time: start the kind control plane on Dory's Docker engine. Then run the @@ -39,7 +48,21 @@ make run 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!`. +with password `Password1!`. `make run` uses `cargo-watch` on the GraphQL +host (Vite already HMR's the UI). `WATCH=0 make run` is a one-shot `cargo run`. + +This is the **default one-process playground**. Optional celld: + +```bash +cd tests/e2e-ui && make up && make up-celld-nats +cd ../e2e-celld && make run +``` + +`make up-celld-nats` / `make test-celld-nats` (`celld-nats-profile/`) start +Azurite + celld + NATS. They are not `make run`. GraphQL wait-dispatches +Todo create/complete and `chat.post` to cells (one SQLite shard per todo or +message). GraphQL `@live`, Eventual projectors, Blob, and identity stay in +the GraphQL process. ## The developer experience @@ -101,14 +124,7 @@ framework auto-derive client cache previews from input + defaults + claims (not a separate hand-built mapping): ```rust -.command_transition::< - domain_commands::Complete, - TodoCompleteInput, - Eventual, ->("todo.complete") -.field_name("todos_complete") -.roles(["user", "admin"]) -.handle(todo_complete::handle) +.mount(todo_domain::commands::complete()) ``` The compiler specializes `TODOS` into safe client operations: apply the same 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 00000000..10e842d9 --- /dev/null +++ b/tests/e2e-ui/celld-nats-profile/README.md @@ -0,0 +1,87 @@ +# 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 +``` + +Optional profile: + +```sh +cd tests/e2e-ui +make up-celld-nats # Azurite + celld + NATS (not make run) +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 + +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. +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 + +| Path | Where | +|---|---| +| 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 | +| `@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 +cd tests/e2e-ui +make up-celld-nats +make test-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): + +```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 00000000..5f623813 --- /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: + - "127.0.0.1:${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/blob-domain/Cargo.toml b/tests/e2e-ui/crates/blob-domain/Cargo.toml index d7ef74f5..7550a2c2 100644 --- a/tests/e2e-ui/crates/blob-domain/Cargo.toml +++ b/tests/e2e-ui/crates/blob-domain/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib", "rlib"] [features] default = ["domain"] # Aggregate + levels + distributed host (server / tests). -domain = ["dep:distributed", "dep:thiserror", "dep:rand"] +domain = ["dep:distributed", "dep:thiserror", "dep:rand", "dep:e2e-readmodels"] # Client pure export (wasm-pack / wasm32). wasm = ["dep:wasm-bindgen"] @@ -22,6 +22,7 @@ serde_json = { workspace = true } distributed = { workspace = true, optional = true } thiserror = { workspace = true, optional = true } rand = { workspace = true, optional = true } +e2e-readmodels = { path = "../readmodels", optional = true } wasm-bindgen = { version = "0.2", optional = true } [dev-dependencies] diff --git a/tests/e2e-ui/crates/blob-domain/src/commands/mod.rs b/tests/e2e-ui/crates/blob-domain/src/commands/mod.rs new file mode 100644 index 00000000..e8c3ed85 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/commands/mod.rs @@ -0,0 +1,60 @@ +//! Portable Blob command declarations. +//! +//! Commands shard by `game_id`, so a cell is `BlobGame:{game_id}`. Client +//! preview wasm remains in the crate's wasm module. Each command and its +//! GraphQL types live in one module. + +mod move_dir; +mod start; +mod start_level; +mod support; + +pub use move_dir::{handle_move, move_dir, BlobMoveInput, Move}; +pub use start::{handle_start, start, BlobStartInput, Start}; +pub use start_level::{handle_start_level, start_level, BlobStartLevelInput, StartLevel}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::BlobGame; + use distributed::microsvc::Routes; + use distributed::{AggregateBuilder, InMemoryRepository}; + + #[test] + fn atomic_commands_mount_with_their_projection_contracts() { + let specs = Routes::new() + .with_repo(InMemoryRepository::new().aggregate::()) + .mount(start()) + .mount(move_dir()) + .mount(start_level()) + .command_specs() + .expect("blob command declarations compile"); + + for command in ["blob.start", "blob.move", "blob.start_level"] { + let spec = specs + .iter() + .find(|spec| spec.id == command) + .unwrap_or_else(|| panic!("missing {command}")); + let model = spec.projected_model.as_deref().unwrap_or(""); + assert!( + model == "BlobGames" || model == "blob_games", + "{command} should be Atomic, got {model:?}" + ); + } + + let move_spec = specs + .iter() + .find(|spec| spec.id == "blob.move") + .expect("blob.move"); + let projection = move_spec.projection_contract.to_string(); + assert!(projection.contains("blob.simulate_move"), "{projection}"); + assert!(projection.contains("blobSimulateMove"), "{projection}"); + } + + #[test] + fn client_preview_wasm_stays_in_blob_domain_wasm_module() { + let src = include_str!("../wasm.rs"); + assert!(src.contains("blobSimulateMove")); + assert!(src.contains("blob_simulate_move")); + } +} diff --git a/tests/e2e-ui/crates/blob-domain/src/commands/move_dir.rs b/tests/e2e-ui/crates/blob-domain/src/commands/move_dir.rs new file mode 100644 index 00000000..947ad45d --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/commands/move_dir.rs @@ -0,0 +1,102 @@ +use distributed::graphql::{Atomic, CommandProjectionPureReduce, PreparedCommand}; +use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::portable_command; +use e2e_readmodels::BlobGames; +use serde::Deserialize; + +use super::support::{authenticated_user, principal, rejected, sealed_row}; +use crate::{domain_commands, BlobGame, Direction}; + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct BlobMoveInput { + pub game_id: String, + pub direction: String, +} + +pub async fn handle_move( + ctx: &CausalCommandContext<'_, BlobGame>, + input: BlobMoveInput, +) -> Result>, HandlerError> { + let owner = principal(ctx)?; + let direction = Direction::parse(&input.direction).ok_or_else(|| { + HandlerError::Rejected(format!( + "invalid direction `{}` (use up|down|left|right)", + input.direction + )) + })?; + let repo = ctx.repo(); + let mut game = repo + .get(&input.game_id) + .await? + .ok_or_else(|| HandlerError::NotFound(input.game_id.clone()))?; + game.move_dir(&owner, direction).map_err(rejected)?; + let row = sealed_row(&*game)?; + repo.readmodel(row).publish_events().commit(game)?.atomic() +} + +fn blob_preview() -> CommandProjectionPureReduce { + CommandProjectionPureReduce::wasm( + "blob.simulate_move", + "blob/pkg/blob_wasm", + "blobSimulateMove", + "BlobGames", + ) + .key_input("game_id", ["game_id"]) + .arg_input("direction", ["direction"]) + .assign([ + "map_json", + "score", + "player_dead", + "current_level_completed", + "status", + ]) +} + +portable_command! { + name: "blob.move", + transition: domain_commands::MoveDir, + aggregate: BlobGame, + input: BlobMoveInput, + outcome: Atomic, + shard: |input| input.game_id.clone(), + roles: ["user", "admin"], + field: "blob_games_move", + constructor: move_dir, + preview_reduce_known_record: blob_preview(), + guard: authenticated_user, + handle: handle_move, +} + +#[cfg(test)] +mod tests { + use super::*; + use distributed::cell_host::instance_name; + use distributed::Aggregate; + + #[test] + fn shard_is_game_id() { + let input = BlobMoveInput { + game_id: "g1".into(), + direction: "up".into(), + }; + assert_eq!(Move::shard(&input), "g1"); + } + + #[test] + fn cell_is_parent_game_shard() { + let input = BlobMoveInput { + game_id: "g1".into(), + direction: "up".into(), + }; + let shard = Move::shard(&input); + 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)" + ); + } +} diff --git a/tests/e2e-ui/crates/blob-domain/src/commands/start.rs b/tests/e2e-ui/crates/blob-domain/src/commands/start.rs new file mode 100644 index 00000000..5409b420 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/commands/start.rs @@ -0,0 +1,58 @@ +use distributed::graphql::{Atomic, PreparedCommand}; +use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::portable_command; +use e2e_readmodels::BlobGames; +use serde::Deserialize; + +use super::support::{authenticated_user, principal, rejected, sealed_row}; +use crate::{domain_commands, BlobGame}; + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct BlobStartInput { + pub game_id: String, +} + +pub async fn handle_start( + ctx: &CausalCommandContext<'_, BlobGame>, + input: BlobStartInput, +) -> Result>, HandlerError> { + let owner = principal(ctx)?; + let repo = ctx.repo(); + if repo.get(&input.game_id).await?.is_some() { + return Err(HandlerError::Rejected(format!( + "game {} already exists", + input.game_id + ))); + } + let mut game = repo.create(); + game.start_with_demo(&input.game_id, &owner) + .map_err(rejected)?; + let row = sealed_row(&*game)?; + repo.readmodel(row).publish_events().commit(game)?.atomic() +} + +portable_command! { + name: "blob.start", + transition: domain_commands::StartWithMap, + aggregate: BlobGame, + input: BlobStartInput, + outcome: Atomic, + shard: |input| input.game_id.clone(), + roles: ["user", "admin"], + field: "blob_games_start", + guard: authenticated_user, + handle: handle_start, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shard_is_game_id() { + let input = BlobStartInput { + game_id: "g1".into(), + }; + assert_eq!(Start::shard(&input), "g1"); + } +} diff --git a/tests/e2e-ui/crates/blob-domain/src/commands/start_level.rs b/tests/e2e-ui/crates/blob-domain/src/commands/start_level.rs new file mode 100644 index 00000000..77f6e9ce --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/commands/start_level.rs @@ -0,0 +1,54 @@ +use distributed::graphql::{Atomic, PreparedCommand}; +use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::portable_command; +use e2e_readmodels::BlobGames; +use serde::Deserialize; + +use super::support::{authenticated_user, principal, rejected, sealed_row}; +use crate::{domain_commands, BlobGame}; + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct BlobStartLevelInput { + pub game_id: String, +} + +pub async fn handle_start_level( + ctx: &CausalCommandContext<'_, BlobGame>, + input: BlobStartLevelInput, +) -> Result>, HandlerError> { + let owner = principal(ctx)?; + let repo = ctx.repo(); + let mut game = repo + .get(&input.game_id) + .await? + .ok_or_else(|| HandlerError::NotFound(input.game_id.clone()))?; + game.start_next_generated_level(&owner).map_err(rejected)?; + let row = sealed_row(&*game)?; + repo.readmodel(row).publish_events().commit(game)?.atomic() +} + +portable_command! { + name: "blob.start_level", + transition: domain_commands::StartLevel, + aggregate: BlobGame, + input: BlobStartLevelInput, + outcome: Atomic, + shard: |input| input.game_id.clone(), + roles: ["user", "admin"], + field: "blob_games_start_level", + guard: authenticated_user, + handle: handle_start_level, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shard_is_game_id() { + let input = BlobStartLevelInput { + game_id: "g1".into(), + }; + assert_eq!(StartLevel::shard(&input), "g1"); + } +} diff --git a/tests/e2e-ui/crates/blob-domain/src/commands/support.rs b/tests/e2e-ui/crates/blob-domain/src/commands/support.rs new file mode 100644 index 00000000..0359480f --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/commands/support.rs @@ -0,0 +1,34 @@ +use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::{mutation_file, Aggregate, Mutation}; +use e2e_readmodels::BlobGames; + +use crate::{BlobGame, BlobGameState}; + +pub(super) fn rejected(err: impl std::fmt::Display) -> HandlerError { + HandlerError::Rejected(err.to_string()) +} + +pub(super) fn principal(ctx: &CausalCommandContext<'_, A>) -> Result +where + A: Aggregate + Send + Sync + 'static, +{ + ctx.user_id().map(str::to_string) +} + +pub(super) fn authenticated_user(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + ctx.session().user_id().is_some_and(|id| !id.is_empty()) +} + +#[allow(non_snake_case)] +fn SaveBlobGame() -> Mutation<()> { + mutation_file!("src/mutations/save_blob_game.mutation.graphql") +} + +pub(super) fn sealed_row(game: &BlobGame) -> Result { + SaveBlobGame() + .from_state(&BlobGameState::from(game)) + .map_err(|error| HandlerError::Other(Box::new(error))) +} diff --git a/tests/e2e-ui/crates/blob-domain/src/lib.rs b/tests/e2e-ui/crates/blob-domain/src/lib.rs index f71a4b4a..d2697fd0 100644 --- a/tests/e2e-ui/crates/blob-domain/src/lib.rs +++ b/tests/e2e-ui/crates/blob-domain/src/lib.rs @@ -8,6 +8,8 @@ pub mod core; +#[cfg(feature = "domain")] +pub mod commands; #[cfg(feature = "domain")] pub mod levels; #[cfg(feature = "domain")] @@ -18,6 +20,11 @@ pub mod wasm; pub use core::{simulate_move, tile, Direction, MovePreview, SimulateError}; +#[cfg(feature = "domain")] +pub use commands::{ + move_dir, start, start_level, BlobMoveInput, BlobStartInput, BlobStartLevelInput, Move, Start, + StartLevel, +}; #[cfg(feature = "domain")] pub use levels::{demo_map, generate_level, generate_level_with, is_hamiltonian_passable}; #[cfg(feature = "domain")] diff --git a/tests/e2e-ui/crates/blob-domain/src/mutations/save_blob_game.mutation.graphql b/tests/e2e-ui/crates/blob-domain/src/mutations/save_blob_game.mutation.graphql new file mode 100644 index 00000000..e8de7e87 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/mutations/save_blob_game.mutation.graphql @@ -0,0 +1,5 @@ +# Syntax-only read-model mutation → MutationProgram IR. +# Not a public GraphQL schema field. +mutation SaveBlobGame { + upsert_blob_games(object: $input.game) +} diff --git a/tests/e2e-ui/crates/chat-domain/Cargo.toml b/tests/e2e-ui/crates/chat-domain/Cargo.toml index 8fa4e97f..a717c2b5 100644 --- a/tests/e2e-ui/crates/chat-domain/Cargo.toml +++ b/tests/e2e-ui/crates/chat-domain/Cargo.toml @@ -6,7 +6,9 @@ publish = false description = "ChatMessage aggregate for live chat (subscription demo)" [dependencies] -distributed = { workspace = true } +# Path + default-features so the wasm cell worker can depend on this crate +# without pulling e2e-ui's sqlite/postgres/http/graphql feature set. +distributed = { path = "../../../..", default-features = false } serde = { workspace = true } thiserror = { workspace = true } diff --git a/tests/e2e-ui/crates/chat-domain/src/commands/mod.rs b/tests/e2e-ui/crates/chat-domain/src/commands/mod.rs new file mode 100644 index 00000000..5b7d1cd7 --- /dev/null +++ b/tests/e2e-ui/crates/chat-domain/src/commands/mod.rs @@ -0,0 +1,10 @@ +//! Portable Chat command declarations. +//! +//! Zitadel ingest stays in the service module rather than becoming a cell +//! class method. Each domain command and its GraphQL types live in one module. + +mod post; + +pub use post::{ + canonical_near_unix_millis, handle_post, post, ChatPostInput, ChatPostPayload, Post, +}; diff --git a/tests/e2e-ui/crates/chat-domain/src/commands/post.rs b/tests/e2e-ui/crates/chat-domain/src/commands/post.rs new file mode 100644 index 00000000..1b0bb525 --- /dev/null +++ b/tests/e2e-ui/crates/chat-domain/src/commands/post.rs @@ -0,0 +1,170 @@ +use distributed::graphql::{Eventual, PreparedCommand}; +use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::portable_command; +use serde::{Deserialize, Serialize}; + +use crate::{domain_commands, ChatMessage, ChatMessagePostedDomainEvent, ChatMessageState}; + +fn rejected(err: impl std::fmt::Display) -> HandlerError { + HandlerError::Rejected(err.to_string()) +} + +fn principal(ctx: &CausalCommandContext<'_, ChatMessage>) -> Result { + ctx.user_id().map(str::to_string) +} + +fn authenticated_user(ctx: &CausalCommandContext<'_, ChatMessage>) -> bool { + ctx.session().user_id().is_some_and(|id| !id.is_empty()) +} + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct ChatPostInput { + pub message_id: String, + pub body: String, + pub room_id: String, + /// Client-generated unix milliseconds used by the optimistic row and + /// accepted only when it is close to server time. + pub created_at: String, +} + +#[derive(Debug, Serialize, distributed::GraphqlOutput)] +pub struct ChatPostPayload { + pub message_id: String, + pub room_id: String, + pub author_id: String, + pub body: String, + pub created_at: String, +} + +pub async fn handle_post( + ctx: &CausalCommandContext<'_, ChatMessage>, + input: ChatPostInput, +) -> Result>, HandlerError> { + let author = principal(ctx)?; + let created_at = canonical_near_unix_millis(&input.created_at)?; + let repo = ctx.repo(); + + if repo.get(&input.message_id).await?.is_some() { + return Err(HandlerError::Rejected(format!( + "message {} already exists", + input.message_id + ))); + } + + let mut message = repo.create(); + message + .post( + &input.message_id, + &input.room_id, + &author, + &input.body, + &created_at, + ) + .map_err(rejected)?; + + let state = ChatMessageState::from(&*message); + repo.publish_events() + .commit(message)? + .eventual(ChatPostPayload { + message_id: state.message_id, + room_id: state.room_id, + author_id: state.author_id, + body: state.body, + created_at: state.created_at, + }) +} + +/// Accept a client timestamp only when it is canonical unix milliseconds +/// within five minutes of server time. +/// +/// `wasm32-unknown-unknown` has no `SystemTime::now` (cell hosts). There the +/// value must still be canonical digits; the GraphQL wait-path already +/// accepted it on the native host. +pub fn canonical_near_unix_millis(value: &str) -> Result { + let millis = value + .parse::() + .map_err(|_| rejected("created_at must be canonical unix milliseconds"))?; + if millis.to_string() != value { + return Err(rejected( + "created_at must be canonical unix milliseconds within five minutes of server time", + )); + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + if millis.abs_diff(now) > 300_000 { + return Err(rejected( + "created_at must be canonical unix milliseconds within five minutes of server time", + )); + } + } + Ok(value.to_string()) +} + +portable_command! { + name: "chat.post", + transition: domain_commands::Post, + aggregate: ChatMessage, + input: ChatPostInput, + outcome: Eventual, + shard: |input| input.message_id.clone(), + roles: ["user", "admin"], + field: "chat_messages_post", + authenticated_user_field: ( + ChatMessagePostedDomainEvent, + ChatMessageState, + "author_id" + ), + guard: authenticated_user, + handle: handle_post, +} + +#[cfg(test)] +mod tests { + use super::*; + use distributed::microsvc::Routes; + use distributed::{AggregateBuilder, InMemoryRepository}; + + #[test] + fn shard_is_message_id() { + let input = ChatPostInput { + message_id: "m1".into(), + body: "hi".into(), + room_id: "lobby".into(), + created_at: "1".into(), + }; + assert_eq!(Post::shard(&input), "m1"); + } + + #[test] + fn post_uses_handle_escape_hatch() { + assert_eq!(Post::COMMAND, "chat.post"); + let _ = handle_post; + let _ = canonical_near_unix_millis; + } + + #[test] + fn created_at_rejects_non_canonical() { + assert!(canonical_near_unix_millis("not-a-time").is_err()); + assert!(canonical_near_unix_millis("01").is_err()); + } + + #[test] + fn declaration_mounts_without_host_specific_dependencies() { + let specs = Routes::new() + .with_repo(InMemoryRepository::new().aggregate::()) + .mount(post()) + .command_specs() + .expect("chat command declaration compiles"); + let spec = specs + .iter() + .find(|spec| spec.id == "chat.post") + .expect("chat.post"); + assert_eq!(spec.field_name, "chat_messages_post"); + assert_eq!(spec.roles, ["admin", "user"]); + } +} diff --git a/tests/e2e-ui/crates/chat-domain/src/lib.rs b/tests/e2e-ui/crates/chat-domain/src/lib.rs index 2c35356c..cb8fa5f0 100644 --- a/tests/e2e-ui/crates/chat-domain/src/lib.rs +++ b/tests/e2e-ui/crates/chat-domain/src/lib.rs @@ -1,7 +1,9 @@ //! Chat message aggregate — post to a room; author is the session user. +pub mod commands; pub mod models; +pub use commands::{handle_post, post, ChatPostInput, ChatPostPayload, Post}; pub use models::{ domain_commands, ChatError, ChatMessage, ChatMessagePostedDomainEvent, ChatMessageState, }; diff --git a/tests/e2e-ui/crates/service/Cargo.toml b/tests/e2e-ui/crates/service/Cargo.toml index 048ac73a..79b5123b 100644 --- a/tests/e2e-ui/crates/service/Cargo.toml +++ b/tests/e2e-ui/crates/service/Cargo.toml @@ -13,8 +13,6 @@ tokio = { workspace = true } sqlx = { workspace = true } axum = { workspace = true } reqwest = { workspace = true } -tower = "0.5" -futures-util = "0.3" todo-domain = { path = "../todo-domain" } chat-domain = { path = "../chat-domain" } blob-domain = { path = "../blob-domain" } diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs deleted file mode 100644 index 16a57450..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Command: `blob.move` — direction up|down|left|right. -//! -//! Input is only what the client knows (`game_id` + `direction`). Server domain -//! seals via Atomic. Client may run the declared pure (`blob.simulate_move` / -//! `$lib/blob/simulate-move`) for known-row optimism; that paint is provisional. - -use blob_domain::{BlobGame, BlobGameState, Direction}; -use distributed::graphql::{Atomic, PreparedCommand}; -use distributed::microsvc::{CausalCommandContext, HandlerError}; -use e2e_projections::SaveBlobGame; -use e2e_readmodels::BlobGames; -use serde::Deserialize; - -use crate::handlers::util::{principal, rejected}; - -pub const COMMAND: &str = "blob.move"; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct BlobMoveInput { - pub game_id: String, - pub direction: String, -} - -pub async fn handle( - ctx: &CausalCommandContext<'_, BlobGame>, - input: BlobMoveInput, -) -> Result>, HandlerError> { - let owner = principal(ctx)?; - let dir = Direction::parse(&input.direction).ok_or_else(|| { - HandlerError::Rejected(format!( - "invalid direction `{}` (use up|down|left|right)", - input.direction - )) - })?; - - let repo = ctx.repo(); - let mut game = repo - .get(&input.game_id) - .await? - .ok_or_else(|| HandlerError::NotFound(input.game_id.clone()))?; - game.move_dir(&owner, dir).map_err(rejected)?; - - let row = SaveBlobGame() - .from_state(&BlobGameState::from(&*game)) - .map_err(|error| HandlerError::Other(Box::new(error)))?; - repo.readmodel(row) - .publish_events() - .commit(game)? - .atomic() -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs deleted file mode 100644 index d056d1e8..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Command: `blob.start` — create game + demo level. Owner = session user. - -use blob_domain::{BlobGame, BlobGameState}; -use distributed::graphql::{PreparedCommand, Atomic}; -use distributed::microsvc::{CausalCommandContext, HandlerError}; -use e2e_projections::SaveBlobGame; -use e2e_readmodels::BlobGames; -use serde::Deserialize; - -use crate::handlers::util::{principal, rejected}; - -pub const COMMAND: &str = "blob.start"; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct BlobStartInput { - pub game_id: String, -} - -pub type BlobGamePayload = BlobGames; - -pub async fn handle( - ctx: &CausalCommandContext<'_, BlobGame>, - input: BlobStartInput, -) -> Result>, HandlerError> { - let owner = principal(ctx)?; - let repo = ctx.repo(); - - if repo.get(&input.game_id).await?.is_some() { - return Err(HandlerError::Rejected(format!( - "game {} already exists", - input.game_id - ))); - } - - let mut game = repo.create(); - game.start_with_demo(&input.game_id, &owner) - .map_err(rejected)?; - - let row = SaveBlobGame() - .from_state(&BlobGameState::from(&*game)) - .map_err(|error| HandlerError::Other(Box::new(error)))?; - repo.readmodel(row) - .publish_events() - .commit(game)? - .atomic() -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs deleted file mode 100644 index 590e027d..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Command: `blob.start_level` — next level after complete (new generated map). - -use blob_domain::{BlobGame, BlobGameState}; -use distributed::graphql::{PreparedCommand, Atomic}; -use distributed::microsvc::{CausalCommandContext, HandlerError}; -use e2e_projections::SaveBlobGame; -use e2e_readmodels::BlobGames; -use serde::Deserialize; - -use crate::handlers::util::{principal, rejected}; - -pub const COMMAND: &str = "blob.start_level"; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct BlobStartLevelInput { - pub game_id: String, -} - -pub async fn handle( - ctx: &CausalCommandContext<'_, BlobGame>, - input: BlobStartLevelInput, -) -> Result>, HandlerError> { - let owner = principal(ctx)?; - let repo = ctx.repo(); - let mut game = repo - .get(&input.game_id) - .await? - .ok_or_else(|| HandlerError::NotFound(input.game_id.clone()))?; - // Fresh passable layout each level (like original generateLevel) - game.start_next_generated_level(&owner).map_err(rejected)?; - - let row = SaveBlobGame() - .from_state(&BlobGameState::from(&*game)) - .map_err(|error| HandlerError::Other(Box::new(error)))?; - repo.readmodel(row) - .publish_events() - .commit(game)? - .atomic() -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs b/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs deleted file mode 100644 index a5118dbd..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Command: `chat.post` — author is always the authenticated session user. - -use chat_domain::{ChatMessage, ChatMessageState}; -use distributed::graphql::{Eventual, PreparedCommand}; -use distributed::microsvc::{CausalCommandContext, HandlerError}; -use serde::{Deserialize, Serialize}; - -use crate::handlers::util::{principal, rejected}; - -pub const COMMAND: &str = "chat.post"; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct ChatPostInput { - pub message_id: String, - pub body: String, - pub room_id: String, - /// Client-generated unix milliseconds used by the optimistic row and - /// accepted only when it is close to server time. - pub created_at: String, -} - -#[derive(Debug, Serialize, distributed::GraphqlOutput)] -pub struct ChatPostPayload { - pub message_id: String, - pub room_id: String, - pub author_id: String, - pub body: String, - pub created_at: String, -} - -pub async fn handle( - ctx: &CausalCommandContext<'_, ChatMessage>, - input: ChatPostInput, -) -> Result>, HandlerError> { - let author = principal(ctx)?; - let created_at = canonical_near_unix_millis(&input.created_at)?; - let repo = ctx.repo(); - - if repo.get(&input.message_id).await?.is_some() { - return Err(HandlerError::Rejected(format!( - "message {} already exists", - input.message_id - ))); - } - - let mut msg = repo.create(); - msg.post( - &input.message_id, - &input.room_id, - &author, - &input.body, - &created_at, - ) - .map_err(rejected)?; - - let state = ChatMessageState::from(&*msg); - repo.publish_events().commit(msg)?.eventual(ChatPostPayload { - message_id: state.message_id, - room_id: state.room_id, - author_id: state.author_id, - body: state.body, - created_at: state.created_at, - }) -} - -fn canonical_near_unix_millis(value: &str) -> Result { - use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let millis = value - .parse::() - .map_err(|_| rejected("created_at must be canonical unix milliseconds"))?; - if millis.to_string() != value || millis.abs_diff(now) > 300_000 { - return Err(rejected( - "created_at must be canonical unix milliseconds within five minutes of server time", - )); - } - Ok(value.to_string()) -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/mod.rs b/tests/e2e-ui/crates/service/src/handlers/commands/mod.rs index 2860b72d..c79fda52 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/mod.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/mod.rs @@ -1,12 +1,2 @@ -pub mod blob_move; -pub mod blob_start; -pub mod blob_start_level; -pub mod chat_post; -pub mod payloads; -pub mod todo_archive; -pub mod todo_complete; -pub mod todo_create; -pub mod todo_force_archive; -pub mod todo_purge; -pub mod todo_rename; -pub mod todo_reopen; +// Command handlers for Todo/Chat/Blob live in domain crates. +// This module remains for service-only integration commands if any are added. diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/payloads.rs b/tests/e2e-ui/crates/service/src/handlers/commands/payloads.rs deleted file mode 100644 index 76698a17..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/payloads.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Shared GraphQL command payload types for todo lifecycle mutations. - -use serde::Serialize; - -/// Common complete / archive / reopen GraphQL payload. -#[derive(Debug, Serialize, distributed::GraphqlOutput)] -pub struct TodoStatusPayload { - pub todo_id: String, - pub status: String, -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs deleted file mode 100644 index 4ae904c2..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Command: `todo.archive` — owner-only (aggregate enforces). - -use distributed::graphql::{Eventual, PreparedCommand}; -use distributed::microsvc::{CausalCommandContext, HandlerError}; -use serde::Deserialize; -use todo_domain::{Todo, TodoState}; - -use crate::handlers::commands::payloads::TodoStatusPayload; -use crate::handlers::util::{principal, rejected}; - -pub const COMMAND: &str = "todo.archive"; - -/// GraphQL output — same shape as complete/reopen (shared type). -pub type TodoArchivePayload = TodoStatusPayload; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct TodoArchiveInput { - pub todo_id: String, -} - -pub async fn handle( - ctx: &CausalCommandContext<'_, Todo>, - input: TodoArchiveInput, -) -> Result>, HandlerError> { - let owner = principal(ctx)?; - let repo = ctx.repo(); - let mut todo = repo - .get(&input.todo_id) - .await? - .ok_or_else(|| HandlerError::NotFound(input.todo_id.clone()))?; - todo.archive(&owner).map_err(rejected)?; - - let state = TodoState::from(&*todo); - repo.publish_events() - .commit(todo)? - .eventual(TodoArchivePayload { - todo_id: state.todo_id, - status: state.status, - }) -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs deleted file mode 100644 index 91d3fdfb..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Command: `todo.complete` — owner-only (aggregate enforces). -//! -//! The executable path is `load_by` + `invoke` + `eventual` on the module -//! route. This file owns the command identity and GraphQL input only. - -use serde::Deserialize; - -pub const COMMAND: &str = "todo.complete"; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct TodoCompleteInput { - pub todo_id: String, -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs deleted file mode 100644 index 0d820c2e..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Command: `todo.purge` — owner-only physical read-model deletion. - -use distributed::graphql::{Eventual, PreparedCommand}; -use distributed::microsvc::{CausalCommandContext, HandlerError}; -use serde::{Deserialize, Serialize}; -use todo_domain::Todo; - -use crate::handlers::util::{principal, rejected}; - -pub const COMMAND: &str = "todo.purge"; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct TodoPurgeInput { - pub todo_id: String, -} - -#[derive(Debug, Serialize, distributed::GraphqlOutput)] -pub struct TodoPurgePayload { - pub todo_id: String, - pub purged: bool, -} - -pub async fn handle( - ctx: &CausalCommandContext<'_, Todo>, - input: TodoPurgeInput, -) -> Result>, HandlerError> { - let owner = principal(ctx)?; - let repo = ctx.repo(); - let mut todo = repo - .get(&input.todo_id) - .await? - .ok_or_else(|| HandlerError::NotFound(input.todo_id.clone()))?; - todo.purge(&owner).map_err(rejected)?; - - repo.publish_events() - .commit(todo)? - .eventual(TodoPurgePayload { - todo_id: input.todo_id, - purged: true, - }) -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs deleted file mode 100644 index 0a512b7b..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Command: `todo.rename` — owner-only (aggregate enforces). - -use distributed::graphql::{Eventual, PreparedCommand}; -use distributed::microsvc::{CausalCommandContext, HandlerError}; -use serde::{Deserialize, Serialize}; -use todo_domain::{Todo, TodoState}; - -use crate::handlers::util::{principal, rejected}; - -pub const COMMAND: &str = "todo.rename"; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct TodoRenameInput { - pub todo_id: String, - pub title: String, -} - -#[derive(Debug, Serialize, distributed::GraphqlOutput)] -pub struct TodoRenamePayload { - pub todo_id: String, - pub title: String, - pub status: String, -} - -pub async fn handle( - ctx: &CausalCommandContext<'_, Todo>, - input: TodoRenameInput, -) -> Result>, HandlerError> { - let owner = principal(ctx)?; - let repo = ctx.repo(); - let mut todo = repo - .get(&input.todo_id) - .await? - .ok_or_else(|| HandlerError::NotFound(input.todo_id.clone()))?; - todo.rename(&owner, &input.title).map_err(rejected)?; - - let state = TodoState::from(&*todo); - repo.publish_events() - .commit(todo)? - .eventual(TodoRenamePayload { - todo_id: state.todo_id, - title: state.title, - status: state.status, - }) -} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs deleted file mode 100644 index 9ef34d79..00000000 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Command: `todo.reopen` — owner-only (aggregate enforces). - -use distributed::graphql::{Eventual, PreparedCommand}; -use distributed::microsvc::{CausalCommandContext, HandlerError}; -use serde::Deserialize; -use todo_domain::{Todo, TodoState}; - -use crate::handlers::commands::payloads::TodoStatusPayload; -use crate::handlers::util::{principal, rejected}; - -pub const COMMAND: &str = "todo.reopen"; - -pub type TodoReopenPayload = TodoStatusPayload; - -#[derive(Debug, Deserialize, distributed::GraphqlInput)] -pub struct TodoReopenInput { - pub todo_id: String, -} - -pub async fn handle( - ctx: &CausalCommandContext<'_, Todo>, - input: TodoReopenInput, -) -> Result>, HandlerError> { - let owner = principal(ctx)?; - let repo = ctx.repo(); - let mut todo = repo - .get(&input.todo_id) - .await? - .ok_or_else(|| HandlerError::NotFound(input.todo_id.clone()))?; - todo.reopen(&owner).map_err(rejected)?; - - let state = TodoState::from(&*todo); - repo.publish_events() - .commit(todo)? - .eventual(TodoReopenPayload { - todo_id: state.todo_id, - status: state.status, - }) -} diff --git a/tests/e2e-ui/crates/service/src/handlers/ingestors/zitadel/scrape.rs b/tests/e2e-ui/crates/service/src/handlers/ingestors/zitadel/scrape.rs index 2c3c3214..f1937315 100644 --- a/tests/e2e-ui/crates/service/src/handlers/ingestors/zitadel/scrape.rs +++ b/tests/e2e-ui/crates/service/src/handlers/ingestors/zitadel/scrape.rs @@ -6,8 +6,11 @@ use std::env; use std::time::Duration; -use distributed::TransactionalCommit; -use e2e_projections::{ZitadelEmail, ZitadelUserPayload}; +use distributed::read_model::ReadModelWritePlanBuilder; +use distributed::{ReadModelWritePlanStore, TransactionalCommit}; +use e2e_projections::{ + map_zitadel_user_status, map_zitadel_user_upsert, ZitadelEmail, ZitadelUserPayload, +}; use serde::Deserialize; use serde_json::{json, Value}; @@ -92,10 +95,15 @@ pub struct ScrapeReport { } /// List users from Zitadel Management API and publish provider messages for each. -pub async fn scrape_users_to_outbox( - repo: &R, +pub async fn scrape_users_to_outbox( + outbox: &R, + directory: &S, cfg: &ZitadelScrapeConfig, -) -> ScrapeReport { +) -> ScrapeReport +where + R: TransactionalCommit, + S: ReadModelWritePlanStore, +{ let mut report = ScrapeReport::default(); let users = match list_all_users(cfg).await { Ok(u) => u, @@ -111,7 +119,18 @@ pub async fn scrape_users_to_outbox( report.skipped += 1; continue; }; - match publish_mapped_delivery(repo, &mapped).await { + // Directory joins (chat author, blob owner) must not wait on bus + // delivery. Scrape already has the profile; outbox is for other + // subscribers. Duplicate outbox ids used to skip this upsert, which + // left `auth_users` empty after a published-but-never-consumed scrape. + if let Err(e) = materialize_auth_user(directory, &mapped).await { + report.errors.push(format!( + "user {}: auth_users upsert failed: {e}", + mapped.payload.provider_subject + )); + continue; + } + match publish_mapped_delivery(outbox, &mapped).await { Ok(()) => report.published += 1, Err(e) => { // Content-addressed scrape ids: unchanged profile re-scrape hits the @@ -131,6 +150,22 @@ pub async fn scrape_users_to_outbox( report } +async fn materialize_auth_user( + store: &S, + mapped: &MappedDelivery, +) -> Result<(), String> { + let name = mapped.message_name.as_str(); + let row = if name.contains("deactivated") || name.contains("reactivated") { + map_zitadel_user_status(name, &mapped.payload) + } else { + map_zitadel_user_upsert(name, &mapped.payload) + }; + let mut plan = ReadModelWritePlanBuilder::new(); + plan.upsert(&row).map_err(|e| e.to_string())?; + plan.commit(store).await.map_err(|e| e.to_string())?; + Ok(()) +} + /// True when publish failed because this scrape delivery id was already committed. /// /// Matches repository `DuplicateOutboxMessageInBatch` display text and common @@ -385,14 +420,14 @@ fn now_ms() -> String { /// 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, + R: TransactionalCommit + ReadModelWritePlanStore + 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; + let r = scrape_users_to_outbox(&repo, &repo, &cfg).await; eprintln!( "zitadel scrape (start): listed={} published={} skipped={} errors={}", r.listed, @@ -409,7 +444,7 @@ where } loop { tokio::time::sleep(cfg.interval).await; - let r = scrape_users_to_outbox(&repo, &cfg).await; + let r = scrape_users_to_outbox(&repo, &repo, &cfg).await; if r.published > 0 || !r.errors.is_empty() { eprintln!( "zitadel scrape: listed={} published={} skipped={} errors={}", diff --git a/tests/e2e-ui/crates/service/src/handlers/ingestors/zitadel_scrape.rs b/tests/e2e-ui/crates/service/src/handlers/ingestors/zitadel_scrape.rs index 2b24f17d..8b8e5ba8 100644 --- a/tests/e2e-ui/crates/service/src/handlers/ingestors/zitadel_scrape.rs +++ b/tests/e2e-ui/crates/service/src/handlers/ingestors/zitadel_scrape.rs @@ -26,7 +26,7 @@ pub async fn handle(ctx: &Context<'_, AuthDeps>) -> Result(repo: R) where - R: distributed::TransactionalCommit + Clone + Send + Sync + 'static, + R: distributed::TransactionalCommit + + distributed::ReadModelWritePlanStore + + Clone + + Send + + Sync + + 'static, { match ZitadelScrapeConfig::from_env() { Some(cfg) if cfg.background_enabled() || cfg.on_start => { diff --git a/tests/e2e-ui/crates/service/src/http.rs b/tests/e2e-ui/crates/service/src/http.rs new file mode 100644 index 00000000..e47c00ec --- /dev/null +++ b/tests/e2e-ui/crates/service/src/http.rs @@ -0,0 +1,83 @@ +//! Process HTTP: GraphQL is the user edge (engine `OidcBearer`). +//! +//! 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. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::Json; +use distributed::microsvc::{HandlerError, Service, Session}; +use serde_json::{json, Value}; + +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, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +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 GraphQL (engine identity) plus Zitadel Action HTTP. +pub async fn serve(service: Arc, 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 } + }), + ); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await +} diff --git a/tests/e2e-ui/crates/service/src/lib.rs b/tests/e2e-ui/crates/service/src/lib.rs index 17e3b0b0..bf63a1a8 100644 --- a/tests/e2e-ui/crates/service/src/lib.rs +++ b/tests/e2e-ui/crates/service/src/lib.rs @@ -15,8 +15,8 @@ mod bounds; mod deps; pub mod handlers; mod host; +mod http; pub mod modules; -mod oidc_layer; pub use application::{ DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE, @@ -27,9 +27,9 @@ pub use handlers::ingestors::zitadel::{ scrape_users_to_outbox, spawn_scrape_loop, ScrapeReport, ZitadelScrapeConfig, }; pub use host::{run, HostOptions}; +pub use http::serve; 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, }; -pub use oidc_layer::serve_with_oidc; diff --git a/tests/e2e-ui/crates/service/src/modules/blob.rs b/tests/e2e-ui/crates/service/src/modules/blob.rs index 02105165..ed2c5d85 100644 --- a/tests/e2e-ui/crates/service/src/modules/blob.rs +++ b/tests/e2e-ui/crates/service/src/modules/blob.rs @@ -1,19 +1,13 @@ //! Blob game module: Atomic command mounts (direct projection seal). -use blob_domain::domain_commands; use blob_domain::BlobGame; -use distributed::graphql::{ - Atomic, CommandProjectionPureReduce, SurfaceDirectProjection, -}; +use distributed::graphql::SurfaceDirectProjection; use distributed::microsvc::{ ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, }; use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; -use e2e_readmodels::BlobGames; use crate::bounds::{EventStore, Locks, ReadStore}; -use crate::handlers::commands::{blob_move, blob_start, blob_start_level}; -use crate::handlers::util::causal_has_user; /// Logical module id for composition inventories. pub const MODULE_ID: &str = "blob"; @@ -21,12 +15,7 @@ pub const MODULE_ID: &str = "blob"; type BlobRoutes = Routes, BlobGame>, S>>; -/// Mount blob Atomic commands. -/// -/// Emit sets come from domain transitions that directly capture events: -/// - start → [`domain_commands::StartWithMap`] (`blob.started`; demo start uses this path) -/// - move → [`domain_commands::MoveDir`] (`blob.moved`) -/// - start_level → [`domain_commands::StartLevel`] (`blob.level_started`) +/// Mount blob Atomic commands from blob-domain. pub fn routes( repo: R, locks: L, @@ -49,46 +38,7 @@ where { let _ = _blob_direct; Routes::for_aggregate::(repo, locks, read_models) - .command_transition::< - domain_commands::StartWithMap, - blob_start::BlobStartInput, - Atomic, - >(blob_start::COMMAND) - .field_name("blob_games_start") - .roles(["user", "admin"].into_iter()) - .guarded(causal_has_user, blob_start::handle) - .command_transition::< - domain_commands::MoveDir, - blob_move::BlobMoveInput, - Atomic, - >(blob_move::COMMAND) - .field_name("blob_games_move") - .roles(["user", "admin"].into_iter()) - // Domain pure: blob_domain::core — WASM package under $lib; gen-client hosts it. - .preview_reduce_known_record( - CommandProjectionPureReduce::wasm( - "blob.simulate_move", - "blob/pkg/blob_wasm", - "blobSimulateMove", - "BlobGames", - ) - .key_input("game_id", ["game_id"]) - .arg_input("direction", ["direction"]) - .assign([ - "map_json", - "score", - "player_dead", - "current_level_completed", - "status", - ]), - ) - .guarded(causal_has_user, blob_move::handle) - .command_transition::< - domain_commands::StartLevel, - blob_start_level::BlobStartLevelInput, - Atomic, - >(blob_start_level::COMMAND) - .field_name("blob_games_start_level") - .roles(["user", "admin"].into_iter()) - .guarded(causal_has_user, blob_start_level::handle) + .mount(blob_domain::commands::start()) + .mount(blob_domain::commands::move_dir()) + .mount(blob_domain::commands::start_level()) } diff --git a/tests/e2e-ui/crates/service/src/modules/chat.rs b/tests/e2e-ui/crates/service/src/modules/chat.rs index 84f3b615..a1bfbae9 100644 --- a/tests/e2e-ui/crates/service/src/modules/chat.rs +++ b/tests/e2e-ui/crates/service/src/modules/chat.rs @@ -1,8 +1,7 @@ //! Chat + identity-ingestor module: room messages, Zitadel ingress, auth_user projector. -use chat_domain::domain_commands; -use chat_domain::{ChatMessage, ChatMessagePostedDomainEvent, ChatMessageState}; -use distributed::graphql::{Eventual, SurfaceProjector}; +use chat_domain::ChatMessage; +use distributed::graphql::SurfaceProjector; use distributed::microsvc::{ ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, }; @@ -10,8 +9,6 @@ use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; use crate::bounds::{EventStore, Locks, ReadStore}; use crate::handlers; -use crate::handlers::commands::chat_post; -use crate::handlers::util::causal_has_user; /// Logical module id for composition inventories. pub const MODULE_ID: &str = "chat"; @@ -41,17 +38,7 @@ where HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, { Routes::for_aggregate::(repo, locks, read_models) - .command_transition::< - domain_commands::Post, - chat_post::ChatPostInput, - Eventual, - >(chat_post::COMMAND) - .field_name("chat_messages_post") - .roles(["user", "admin"].into_iter()) - // Lobby rows are public-readable, so no owner row policy can supply - // this otherwise server-only transition value to automatic optimism. - .authenticated_user_field::("author_id") - .guarded(causal_has_user, chat_post::handle) + .mount(chat_domain::commands::post()) // Zitadel Action ingress + on-demand scrape remain non-GraphQL // integration commands (explicit extension mounts). .command(handlers::ingestors::zitadel::COMMAND) diff --git a/tests/e2e-ui/crates/service/src/modules/compose.rs b/tests/e2e-ui/crates/service/src/modules/compose.rs index 731043c6..148c3e67 100644 --- a/tests/e2e-ui/crates/service/src/modules/compose.rs +++ b/tests/e2e-ui/crates/service/src/modules/compose.rs @@ -54,7 +54,7 @@ where // 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`. + // the chat module and re-mounted in `http::serve`. Service::new() .named("e2e-ui") .routes(todos) diff --git a/tests/e2e-ui/crates/service/src/modules/todo.rs b/tests/e2e-ui/crates/service/src/modules/todo.rs index 82fdb12f..c73ff618 100644 --- a/tests/e2e-ui/crates/service/src/modules/todo.rs +++ b/tests/e2e-ui/crates/service/src/modules/todo.rs @@ -1,22 +1,14 @@ //! Todo bounded-context module: command mounts + eventual projector. -use distributed::graphql::{Eventual, SurfaceProjector}; +use distributed::graphql::SurfaceProjector; use distributed::microsvc::{ ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, }; -use distributed::{ - command_input_defaults, AggregateBuilder, AggregateRepository, QueuedRepository, -}; -use todo_domain::domain_commands; -use todo_domain::{Todo, TodoState}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; +use todo_domain::Todo; use crate::bounds::{EventStore, Locks, ReadStore}; use crate::handlers; -use crate::handlers::commands::{ - payloads, todo_archive, todo_complete, todo_create, todo_force_archive, todo_purge, todo_rename, - todo_reopen, -}; -use crate::handlers::util::{causal_has_user, causal_is_admin}; /// Logical module id for composition inventories. pub const MODULE_ID: &str = "todo"; @@ -46,74 +38,13 @@ where HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, { Routes::for_aggregate::(repo, locks, read_models) - .command_transition::< - domain_commands::Create, - todo_create::TodoCreateInput, - Eventual, - >(todo_create::COMMAND) - .field_name("todos_create") - .roles(["user", "admin"].into_iter()) - .input_defaults(command_input_defaults! { - input: todo_create::TodoCreateInput; - default input.todo_id = uuid_v7(); - }) - .guarded(causal_has_user, todo_create::handle) - .command_transition::< - domain_commands::Rename, - todo_rename::TodoRenameInput, - Eventual, - >(todo_rename::COMMAND) - .field_name("todos_rename") - .roles(["user", "admin"].into_iter()) - .guarded(causal_has_user, todo_rename::handle) - .command_transition::< - domain_commands::Complete, - todo_complete::TodoCompleteInput, - Eventual, - >(todo_complete::COMMAND) - .field_name("todos_complete") - .roles(["user", "admin"].into_iter()) - .load_by(|input: &todo_complete::TodoCompleteInput| input.todo_id.clone()) - .invoke(|todo, _input, owner| todo.complete(owner)) - .eventual(|todo| { - let state = TodoState::from(&**todo); - payloads::TodoStatusPayload { - todo_id: state.todo_id, - status: state.status, - } - }) - .command_transition::< - domain_commands::Reopen, - todo_reopen::TodoReopenInput, - Eventual, - >(todo_reopen::COMMAND) - .field_name("todos_reopen") - .roles(["user", "admin"].into_iter()) - .guarded(causal_has_user, todo_reopen::handle) - .command_transition::< - domain_commands::Archive, - todo_archive::TodoArchiveInput, - Eventual, - >(todo_archive::COMMAND) - .field_name("todos_archive") - .roles(["user", "admin"].into_iter()) - .guarded(causal_has_user, todo_archive::handle) - .command_transition::< - domain_commands::ForceArchive, - todo_force_archive::TodoForceArchiveInput, - Eventual, - >(todo_force_archive::COMMAND) - .field_name("todos_force_archive") - .roles(["admin"]) - .guarded(causal_is_admin, todo_force_archive::handle) - .command_transition::< - domain_commands::Purge, - todo_purge::TodoPurgeInput, - Eventual, - >(todo_purge::COMMAND) - .field_name("todos_purge") - .roles(["user", "admin"].into_iter()) - .guarded(causal_has_user, todo_purge::handle) + .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::events::project_todos::handle) } diff --git a/tests/e2e-ui/crates/service/src/oidc_layer.rs b/tests/e2e-ui/crates/service/src/oidc_layer.rs deleted file mode 100644 index 263fbd41..00000000 --- a/tests/e2e-ui/crates/service/src/oidc_layer.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! 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::post; -use axum::Json; -use distributed::graphql::{ - 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. -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 -} - -#[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-ui/crates/todo-domain/Cargo.toml b/tests/e2e-ui/crates/todo-domain/Cargo.toml index fb7c5ce6..389b0127 100644 --- a/tests/e2e-ui/crates/todo-domain/Cargo.toml +++ b/tests/e2e-ui/crates/todo-domain/Cargo.toml @@ -6,9 +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 } +serde_json = { version = "1" } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/archive.rs b/tests/e2e-ui/crates/todo-domain/src/commands/archive.rs new file mode 100644 index 00000000..08e80658 --- /dev/null +++ b/tests/e2e-ui/crates/todo-domain/src/commands/archive.rs @@ -0,0 +1,40 @@ +use distributed::graphql::Eventual; +use distributed::portable_command; +use serde::Deserialize; + +use super::TodoStatusPayload; +use crate::{domain_commands, Todo}; + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct TodoArchiveInput { + pub todo_id: String, +} + +pub type TodoArchivePayload = TodoStatusPayload; + +portable_command! { + name: "todo.archive", + transition: domain_commands::Archive, + aggregate: Todo, + input: TodoArchiveInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + load: required, + roles: ["user", "admin"], + field: "todos_archive", + invoke: |todo, _input, principal| todo.archive(principal), + payload: |todo| TodoStatusPayload::from_todo(&**todo), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shard_is_todo_id() { + let input = TodoArchiveInput { + todo_id: "todo-1".into(), + }; + assert_eq!(Archive::shard(&input), "todo-1"); + } +} diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/complete.rs b/tests/e2e-ui/crates/todo-domain/src/commands/complete.rs new file mode 100644 index 00000000..70e420dd --- /dev/null +++ b/tests/e2e-ui/crates/todo-domain/src/commands/complete.rs @@ -0,0 +1,54 @@ +use distributed::graphql::Eventual; +use distributed::portable_command; +use serde::{Deserialize, Serialize}; + +use crate::{domain_commands, Todo, TodoState}; + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct TodoCompleteInput { + pub todo_id: String, +} + +/// Shared complete / archive / reopen payload. +#[derive(Debug, Serialize, distributed::GraphqlOutput)] +pub struct TodoStatusPayload { + pub todo_id: String, + pub status: String, +} + +impl TodoStatusPayload { + pub(super) fn from_todo(todo: &Todo) -> Self { + let state = TodoState::from(todo); + Self { + todo_id: state.todo_id, + status: state.status, + } + } +} + +portable_command! { + name: "todo.complete", + transition: domain_commands::Complete, + aggregate: Todo, + input: TodoCompleteInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + load: required, + roles: ["user", "admin"], + field: "todos_complete", + invoke: |todo, _input, principal| todo.complete(principal), + payload: |todo| TodoStatusPayload::from_todo(&**todo), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shard_is_todo_id() { + let input = TodoCompleteInput { + todo_id: "todo-1".into(), + }; + assert_eq!(Complete::shard(&input), "todo-1"); + } +} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs b/tests/e2e-ui/crates/todo-domain/src/commands/create.rs similarity index 54% rename from tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs rename to tests/e2e-ui/crates/todo-domain/src/commands/create.rs index 60dbf185..31ee6da4 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/create.rs @@ -1,25 +1,18 @@ -//! Command: `todo.create` — owner is always the authenticated session user. -//! -//! GraphQL: `todos_create` (roles: user, admin). Session admission is the -//! mount guard (`causal_has_user`); this body binds that principal as owner. - +use distributed::command_input_defaults; use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::portable_command; use serde::{Deserialize, Serialize}; -use todo_domain::{Todo, TodoState}; - -use crate::handlers::util::{principal, rejected}; -pub const COMMAND: &str = "todo.create"; +use super::support::{authenticated_user, principal, rejected}; +use crate::{domain_commands, Todo, TodoState}; -/// Mutation / command input — `owner_id` is never accepted from the client. #[derive(Debug, Deserialize, distributed::GraphqlInput)] pub struct TodoCreateInput { pub todo_id: String, pub title: String, } -/// GraphQL mutation payload for `todos_create`. #[derive(Debug, Serialize, distributed::GraphqlOutput)] pub struct TodoCreatePayload { pub todo_id: String, @@ -28,25 +21,21 @@ pub struct TodoCreatePayload { pub status: String, } -pub async fn handle( +pub async fn handle_create( ctx: &CausalCommandContext<'_, Todo>, input: TodoCreateInput, ) -> Result>, HandlerError> { - // Owner is always the authenticated principal — not client-supplied. let owner = principal(ctx)?; let repo = ctx.repo(); - if repo.get(&input.todo_id).await?.is_some() { return Err(HandlerError::Rejected(format!( "todo {} already exists", input.todo_id ))); } - let mut todo = repo.create(); todo.create(&input.todo_id, &owner, &input.title) .map_err(rejected)?; - let state = TodoState::from(&*todo); repo.publish_events() .commit(todo)? @@ -57,3 +46,40 @@ pub async fn handle( status: state.status, }) } + +portable_command! { + name: "todo.create", + transition: domain_commands::Create, + aggregate: Todo, + input: TodoCreateInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + roles: ["user", "admin"], + field: "todos_create", + guard: authenticated_user, + handle: handle_create, + defaults: command_input_defaults! { + input: TodoCreateInput; + default input.todo_id = uuid_v7(); + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_uses_handle_escape_hatch() { + assert_eq!(Create::COMMAND, "todo.create"); + let _ = handle_create; + } + + #[test] + fn shard_is_todo_id() { + let input = TodoCreateInput { + todo_id: "todo-1".into(), + title: "one".into(), + }; + assert_eq!(Create::shard(&input), "todo-1"); + } +} diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs b/tests/e2e-ui/crates/todo-domain/src/commands/force_archive.rs similarity index 55% rename from tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs rename to tests/e2e-ui/crates/todo-domain/src/commands/force_archive.rs index 98ee8d35..b8229618 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/force_archive.rs @@ -1,17 +1,10 @@ -//! Command: `todo.force_archive` — **admin-only** GraphQL mutation. -//! -//! Emits `todo.force_archived` (distinct from owner `todo.archived`) so audit -//! trails can tell admin intervention from self-service archive. Projector -//! still upserts the same read-model shape. - use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::portable_command; use serde::{Deserialize, Serialize}; -use todo_domain::{Todo, TodoState}; - -use crate::handlers::util::{principal, rejected}; -pub const COMMAND: &str = "todo.force_archive"; +use super::support::{admin_user, principal, rejected}; +use crate::{domain_commands, Todo, TodoState}; #[derive(Debug, Deserialize, distributed::GraphqlInput)] pub struct TodoForceArchiveInput { @@ -23,11 +16,10 @@ pub struct TodoForceArchivePayload { pub todo_id: String, pub owner_id: String, pub status: String, - /// Session user id of the admin who forced the archive. pub archived_by: String, } -pub async fn handle( +pub async fn handle_force_archive( ctx: &CausalCommandContext<'_, Todo>, input: TodoForceArchiveInput, ) -> Result>, HandlerError> { @@ -37,7 +29,6 @@ pub async fn handle( .get(&input.todo_id) .await? .ok_or_else(|| HandlerError::NotFound(input.todo_id.clone()))?; - todo.force_archive().map_err(rejected)?; let state = TodoState::from(&*todo); repo.publish_events() @@ -49,3 +40,35 @@ pub async fn handle( archived_by: admin, }) } + +portable_command! { + name: "todo.force_archive", + transition: domain_commands::ForceArchive, + aggregate: Todo, + input: TodoForceArchiveInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + roles: ["admin"], + field: "todos_force_archive", + guard: admin_user, + handle: handle_force_archive, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn force_archive_uses_handle_escape_hatch() { + assert_eq!(ForceArchive::COMMAND, "todo.force_archive"); + let _ = handle_force_archive; + } + + #[test] + fn shard_is_todo_id() { + let input = TodoForceArchiveInput { + todo_id: "todo-1".into(), + }; + assert_eq!(ForceArchive::shard(&input), "todo-1"); + } +} diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/mod.rs b/tests/e2e-ui/crates/todo-domain/src/commands/mod.rs new file mode 100644 index 00000000..e489d4e9 --- /dev/null +++ b/tests/e2e-ui/crates/todo-domain/src/commands/mod.rs @@ -0,0 +1,122 @@ +//! Portable Todo command declarations. +//! +//! Hosts mount these values without depending on sqlx, celld, or a concrete +//! repository. Each command and its GraphQL types live in one module. + +mod archive; +mod complete; +mod create; +mod force_archive; +mod purge; +mod rename; +mod reopen; +mod support; + +pub use archive::{archive, Archive, TodoArchiveInput, TodoArchivePayload}; +pub use complete::{complete, Complete, TodoCompleteInput, TodoStatusPayload}; +pub use create::{create, handle_create, Create, TodoCreateInput, TodoCreatePayload}; +pub use force_archive::{ + force_archive, handle_force_archive, ForceArchive, TodoForceArchiveInput, + TodoForceArchivePayload, +}; +pub use purge::{purge, Purge, TodoPurgeInput, TodoPurgePayload}; +pub use rename::{rename, Rename, TodoRenameInput, TodoRenamePayload}; +pub use reopen::{reopen, Reopen, TodoReopenInput, TodoReopenPayload}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::Todo; + use distributed::microsvc::Routes; + use distributed::{AggregateBuilder, InMemoryRepository}; + + fn mounted_specs() -> Vec { + let repository = InMemoryRepository::new(); + Routes::new() + .with_repo(repository.aggregate::()) + .mount(create()) + .mount(rename()) + .mount(complete()) + .mount(reopen()) + .mount(archive()) + .mount(force_archive()) + .mount(purge()) + .command_specs() + .expect("todo command declarations compile") + .into_iter() + .map(|spec| spec.id) + .collect() + } + + #[test] + fn domain_declarations_mount_without_sqlx_or_celld() { + let ids = mounted_specs(); + for command in [ + "todo.create", + "todo.rename", + "todo.complete", + "todo.reopen", + "todo.archive", + "todo.force_archive", + "todo.purge", + ] { + assert!(ids.iter().any(|id| id == command), "missing {command}"); + } + } + + #[test] + fn complete_is_thin_shard_invoke_eventual() { + let complete_spec = Routes::new() + .with_repo(InMemoryRepository::new().aggregate::()) + .mount(complete()) + .command_specs() + .expect("complete spec") + .into_iter() + .find(|spec| spec.id == "todo.complete") + .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"); + } +} diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/purge.rs b/tests/e2e-ui/crates/todo-domain/src/commands/purge.rs new file mode 100644 index 00000000..03b8efb9 --- /dev/null +++ b/tests/e2e-ui/crates/todo-domain/src/commands/purge.rs @@ -0,0 +1,46 @@ +use distributed::graphql::Eventual; +use distributed::portable_command; +use serde::{Deserialize, Serialize}; + +use crate::{domain_commands, Todo}; + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct TodoPurgeInput { + pub todo_id: String, +} + +#[derive(Debug, Serialize, distributed::GraphqlOutput)] +pub struct TodoPurgePayload { + pub todo_id: String, + pub purged: bool, +} + +portable_command! { + name: "todo.purge", + transition: domain_commands::Purge, + aggregate: Todo, + input: TodoPurgeInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + load: required, + roles: ["user", "admin"], + field: "todos_purge", + invoke: |todo, _input, principal| todo.purge(principal), + payload: |todo| TodoPurgePayload { + todo_id: todo.todo_id.clone(), + purged: true, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shard_is_todo_id() { + let input = TodoPurgeInput { + todo_id: "todo-1".into(), + }; + assert_eq!(Purge::shard(&input), "todo-1"); + } +} diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/rename.rs b/tests/e2e-ui/crates/todo-domain/src/commands/rename.rs new file mode 100644 index 00000000..aee60824 --- /dev/null +++ b/tests/e2e-ui/crates/todo-domain/src/commands/rename.rs @@ -0,0 +1,53 @@ +use distributed::graphql::Eventual; +use distributed::portable_command; +use serde::{Deserialize, Serialize}; + +use crate::{domain_commands, Todo, TodoState}; + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct TodoRenameInput { + pub todo_id: String, + pub title: String, +} + +#[derive(Debug, Serialize, distributed::GraphqlOutput)] +pub struct TodoRenamePayload { + pub todo_id: String, + pub title: String, + pub status: String, +} + +portable_command! { + name: "todo.rename", + transition: domain_commands::Rename, + aggregate: Todo, + input: TodoRenameInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + load: required, + roles: ["user", "admin"], + field: "todos_rename", + invoke: |todo, input, principal| todo.rename(principal, &input.title), + payload: |todo| { + let state = TodoState::from(&**todo); + TodoRenamePayload { + todo_id: state.todo_id, + title: state.title, + status: state.status, + } + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shard_is_todo_id() { + let input = TodoRenameInput { + todo_id: "todo-1".into(), + title: "renamed".into(), + }; + assert_eq!(Rename::shard(&input), "todo-1"); + } +} diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/reopen.rs b/tests/e2e-ui/crates/todo-domain/src/commands/reopen.rs new file mode 100644 index 00000000..3ce683c8 --- /dev/null +++ b/tests/e2e-ui/crates/todo-domain/src/commands/reopen.rs @@ -0,0 +1,40 @@ +use distributed::graphql::Eventual; +use distributed::portable_command; +use serde::Deserialize; + +use super::TodoStatusPayload; +use crate::{domain_commands, Todo}; + +#[derive(Debug, Deserialize, distributed::GraphqlInput)] +pub struct TodoReopenInput { + pub todo_id: String, +} + +pub type TodoReopenPayload = TodoStatusPayload; + +portable_command! { + name: "todo.reopen", + transition: domain_commands::Reopen, + aggregate: Todo, + input: TodoReopenInput, + outcome: Eventual, + shard: |input| input.todo_id.clone(), + load: required, + roles: ["user", "admin"], + field: "todos_reopen", + invoke: |todo, _input, principal| todo.reopen(principal), + payload: |todo| TodoStatusPayload::from_todo(&**todo), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shard_is_todo_id() { + let input = TodoReopenInput { + todo_id: "todo-1".into(), + }; + assert_eq!(Reopen::shard(&input), "todo-1"); + } +} diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/support.rs b/tests/e2e-ui/crates/todo-domain/src/commands/support.rs new file mode 100644 index 00000000..fb5ef014 --- /dev/null +++ b/tests/e2e-ui/crates/todo-domain/src/commands/support.rs @@ -0,0 +1,27 @@ +use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::Aggregate; + +pub(super) fn rejected(err: impl std::fmt::Display) -> HandlerError { + HandlerError::Rejected(err.to_string()) +} + +pub(super) fn principal(ctx: &CausalCommandContext<'_, A>) -> Result +where + A: Aggregate + Send + Sync + 'static, +{ + ctx.user_id().map(str::to_string) +} + +pub(super) fn authenticated_user(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + ctx.session().user_id().is_some_and(|id| !id.is_empty()) +} + +pub(super) fn admin_user(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + authenticated_user(ctx) && ctx.session().has_role("admin") +} diff --git a/tests/e2e-ui/crates/todo-domain/src/lib.rs b/tests/e2e-ui/crates/todo-domain/src/lib.rs index a2c61c84..a346ea40 100644 --- a/tests/e2e-ui/crates/todo-domain/src/lib.rs +++ b/tests/e2e-ui/crates/todo-domain/src/lib.rs @@ -6,8 +6,16 @@ //! - complete/reopen/rename only while not archived //! - archive is terminal for mutations (except re-open is not allowed after archive) +pub mod commands; pub mod models; +pub use commands::{ + archive, complete, create, force_archive, purge, rename, reopen, Archive, Complete, Create, + ForceArchive, Purge, Rename, Reopen, TodoArchiveInput, TodoArchivePayload, TodoCompleteInput, + TodoCreateInput, TodoCreatePayload, TodoForceArchiveInput, TodoForceArchivePayload, + TodoPurgeInput, TodoPurgePayload, TodoRenameInput, TodoRenamePayload, TodoReopenInput, + TodoReopenPayload, TodoStatusPayload, +}; pub use models::{ domain_commands, Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, TodoCreatedDomainEvent, TodoDomainIdentity, TodoError, TodoForceArchivedDomainEvent, 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 0a92fb72..89d67e92 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, diff --git a/tests/e2e-ui/e2e/admin.admin.spec.ts b/tests/e2e-ui/e2e/admin.admin.spec.ts index 37298a54..a2e503d9 100644 --- a/tests/e2e-ui/e2e/admin.admin.spec.ts +++ b/tests/e2e-ui/e2e/admin.admin.spec.ts @@ -22,6 +22,10 @@ test.describe('admin (admin user)', () => { await expect(page.getByRole('heading', { name: /all todos/i })).toBeVisible({ timeout: 20_000 }); + const empty = page.getByText(/no todos in the read model/i); + if (await empty.isVisible().catch(() => false)) { + test.skip(true, 'no todos in the read model yet'); + } await expect(page.getByText(/force archive/i).first()).toBeVisible(); // Wait for the nested e2e-ui-admin client to hydrate before invoking // elevated commands (SSR markup alone has no Svelte handlers). diff --git a/tests/e2e-ui/e2e/chat.user.spec.ts b/tests/e2e-ui/e2e/chat.user.spec.ts index d1cf8c01..3087ae9f 100644 --- a/tests/e2e-ui/e2e/chat.user.spec.ts +++ b/tests/e2e-ui/e2e/chat.user.spec.ts @@ -1,6 +1,19 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { expectOptimisticPaint } from './helpers/optimism'; +/** + * Product Send is disabled while `busy` OR the composer is empty. Wait for + * enabled *after fill* so Eventual `projected` can clear `busy` with a real + * draft. A click while busy is ignored; an empty composer keeps Send disabled + * even when idle, so this wait must not run after send. + */ +async function fillAndSend(page: Page, body: string) { + const send = page.getByRole('button', { name: /send/i }); + await page.locator('#chat-body').fill(body); + await expect(send).toBeEnabled({ timeout: 30_000 }); + await send.click(); +} + test.describe('chat (alice)', () => { test('post a lobby message and see it in the log', async ({ page }) => { const body = `e2e chat ${Date.now()}`; @@ -10,8 +23,7 @@ test.describe('chat (alice)', () => { timeout: 20_000 }); - await page.locator('#chat-body').fill(body); - await page.getByRole('button', { name: /send/i }).click(); + await fillAndSend(page, body); const msg = page.locator('.ch-msg', { hasText: body }); await expect(msg).toBeVisible({ timeout: 20_000 }); @@ -25,6 +37,7 @@ test.describe('chat (alice)', () => { }); test('scroll-up / load-earlier fetches the next history page', async ({ page }) => { + test.setTimeout(180_000); await page.goto('/chat'); await expect(page.getByRole('heading', { name: /lobby/i })).toBeVisible({ timeout: 20_000 @@ -35,8 +48,7 @@ test.describe('chat (alice)', () => { const total = 30; for (let i = 0; i < total; i += 1) { const body = `history seed ${stamp} #${String(i).padStart(2, '0')}`; - await page.locator('#chat-body').fill(body); - await page.getByRole('button', { name: /send/i }).click(); + await fillAndSend(page, body); await expect(page.locator('.ch-msg', { hasText: body })).toBeVisible({ timeout: 15_000 }); @@ -58,8 +70,7 @@ test.describe('chat (alice)', () => { holdMs: 1_500, assertWithinMs: 300, act: async () => { - await page.locator('#chat-body').fill(fullPageBody); - await page.getByRole('button', { name: /send/i }).click(); + await fillAndSend(page, fullPageBody); }, assertOptimistic: async () => { await expect(fullPageMessage).toBeVisible({ timeout: 200 }); @@ -114,12 +125,11 @@ test.describe('chat (alice)', () => { }); const baseline = `continuity baseline ${Date.now()}`; - await page.locator('#chat-body').fill(baseline); const baselineResponse = page.waitForResponse( (response) => (response.request().postData() ?? '').includes('chat_messages_post') ); - await page.getByRole('button', { name: /send/i }).click(); + await fillAndSend(page, baseline); await expect(page.locator('.ch-msg', { hasText: baseline })).toBeVisible({ timeout: 20_000 }); @@ -152,8 +162,7 @@ test.describe('chat (alice)', () => { holdMs: 1_500, assertWithinMs: 300, act: async () => { - await page.locator('#chat-body').fill(body); - await page.getByRole('button', { name: /send/i }).click(); + await fillAndSend(page, body); }, assertOptimistic: async () => { await expect(msg).toBeVisible({ timeout: 200 }); diff --git a/tests/e2e-ui/e2e/helpers/login.ts b/tests/e2e-ui/e2e/helpers/login.ts index d7e03b7c..2b97aadb 100644 --- a/tests/e2e-ui/e2e/helpers/login.ts +++ b/tests/e2e-ui/e2e/helpers/login.ts @@ -21,12 +21,34 @@ export async function loginAs( await page.locator('#loginName').fill(username); await page.locator('#password').fill(password); - await page.getByRole('button', { name: /continue/i }).click(); + const continueBtn = page.getByRole('button', { name: /continue/i }); + await continueBtn.waitFor({ state: 'visible', timeout: 15_000 }); - // After success we leave /login (callback then app route). - await page.waitForURL((url) => !url.pathname.startsWith('/login'), { - timeout: 60_000 - }); + // Click and wait together. A sequential click-then-waitForURL misses a + // fast navigation and, on a stuck Login V2 authRequest, never leaves + // /login. One retry covers an expired authorize round-trip. + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await Promise.all([ + page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 30_000 + }), + continueBtn.click() + ]); + return; + } catch { + if (attempt === 1) break; + await page.goto(destination, { waitUntil: 'domcontentloaded' }); + await page.waitForURL(/\/login/, { timeout: 45_000 }); + await page.waitForSelector('#loginName', { timeout: 45_000 }); + await page.locator('#loginName').fill(username); + await page.locator('#password').fill(password); + } + } + + throw new Error( + `login as ${username} stayed on ${page.url()} after Continue` + ); } export async function expectLoggedInNav(page: Page) { diff --git a/tests/e2e-ui/e2e/todos.user.spec.ts b/tests/e2e-ui/e2e/todos.user.spec.ts index d3a97a11..cb350de2 100644 --- a/tests/e2e-ui/e2e/todos.user.spec.ts +++ b/tests/e2e-ui/e2e/todos.user.spec.ts @@ -181,7 +181,7 @@ test.describe('todos (alice)', () => { await expect(add).toBeDisabled(); }); - test('commands preserve rendered cache while revalidating', async ({ page }) => { + test('commands preserve rendered cache while settling', async ({ page }) => { await page.goto('/todos'); await expect(page.getByRole('heading', { name: /todos/i })).toBeVisible(); @@ -285,15 +285,20 @@ test.describe('todos (alice)', () => { // turns that event value into the upsert that must paint before the held // Eventual response. await expect(openItem).toBeVisible({ timeout: 1_000 }); + await expect(openItem).toHaveAttribute('aria-busy', 'true'); + await expect(openItem).toHaveClass(/item-pending/); + await expect(openItem.locator('.pending-state')).toHaveText('Saving…'); expect( - await page.locator('.board button:disabled').count(), - 'routine command concurrency guards must not flash Todo row controls disabled' - ).toBe(0); + await openItem.locator('button:disabled').count(), + 'a newly created optimistic Todo must not expose actions before its receipt' + ).toBe(3); await createResponse; await expect(openItem).toBeVisible(); + await expect(openItem).toHaveAttribute('aria-busy', 'false'); + await expect(openItem.locator('.pending-state')).toHaveCount(0); expect( await page.locator('.board button:disabled').count(), - 'routine command concurrency guards must not flash Todo row controls disabled' + 'Todo controls must unlock after its durable create receipt' ).toBe(0); expectBinarySorted(await visibleTodoOrders(page)); const todoId = await openItem.getAttribute('data-todo-id'); @@ -419,4 +424,122 @@ test.describe('todos (alice)', () => { await expect(archivedItem).toBeVisible(); await page.unrouteAll({ behavior: 'wait' }); }); + + test('rapid independent complete and reopen commands do not refetch or regress', async ({ + page + }) => { + await page.goto('/todos'); + await expect(page.getByRole('heading', { name: /todos/i })).toBeVisible(); + + const prefix = `rapid transitions ${Date.now()}`; + const titles = Array.from({ length: 6 }, (_, index) => `${prefix} ${index + 1}`); + const todoIds: string[] = []; + for (const title of titles) { + await page.locator('#todo-title').fill(title); + const response = waitForTodoCommand(page, 'todos_create'); + await page.getByRole('button', { name: /^add$/i }).click(); + expect((await response).ok(), 'setup todos_create must succeed').toBeTruthy(); + const item = page.locator('.item', { hasText: title }); + await expect(item).toBeVisible(); + const todoId = await item.getAttribute('data-todo-id'); + expect(todoId).not.toBeNull(); + todoIds.push(todoId!); + } + await page.waitForLoadState('networkidle'); + + let transitionQueries = 0; + let transitionResponses = 0; + page.on('request', (request) => { + const body = request.postData() ?? ''; + if (body.includes('query Todos')) transitionQueries += 1; + }); + page.on('response', (response) => { + const body = response.request().postData() ?? ''; + if (body.includes('todos_complete') || body.includes('todos_reopen')) { + transitionResponses += 1; + } + }); + await page.route('**/graphql', async (route) => { + const body = route.request().postData() ?? ''; + if (!body.includes('todos_complete') && !body.includes('todos_reopen')) { + await route.continue(); + return; + } + const response = await route.fetch(); + await new Promise((resolve) => setTimeout(resolve, 350)); + await route.fulfill({ response }); + }); + + const openPanel = page + .locator('.panel') + .filter({ has: page.getByRole('heading', { name: /^open$/i }) }); + const donePanel = page + .locator('.panel') + .filter({ has: page.getByRole('heading', { name: /^done$/i }) }); + + await startTodoOrderTrace(page); + for (const title of titles) { + await openPanel + .locator('.item', { hasText: title }) + .getByRole('button', { name: /^done$/i }) + .click(); + } + for (const title of titles) { + const item = donePanel.locator('.item', { hasText: title }); + await expect(item).toBeVisible({ timeout: 1_000 }); + await item.getByRole('button', { name: /^reopen$/i }).click(); + } + + for (const title of titles) { + await expect(openPanel.locator('.item', { hasText: title })).toBeVisible({ + timeout: 1_000 + }); + } + await expect + .poll(() => transitionResponses, { timeout: 20_000 }) + .toBe(titles.length * 2); + await page.waitForTimeout(750); + + const frames = await stopTodoOrderTrace(page); + const allDoneFrame = frames.findIndex((frame) => + todoIds.every((todoId) => todoIsIn(frame, todoId, 'done')) + ); + const fullyReopenedFrame = frames.findIndex( + (frame, index) => + index > allDoneFrame && + todoIds.every((todoId) => todoIsIn(frame, todoId, 'open')) + ); + for (const title of titles) { + await expect(openPanel.locator('.item', { hasText: title })).toBeVisible(); + await expect(donePanel.locator('.item', { hasText: title })).toHaveCount(0); + } + expect( + transitionQueries, + 'exact successful Todo deltas must not launch a full-list revalidation' + ).toBe(0); + expect(allDoneFrame, `rapid transitions never reached Done: ${JSON.stringify(frames)}`).toBeGreaterThanOrEqual( + 0 + ); + expect( + fullyReopenedFrame, + `rapid transitions never fully reopened: ${JSON.stringify(frames)}` + ).toBeGreaterThan(allDoneFrame); + expect( + frames + .slice(fullyReopenedFrame) + .every((frame) => todoIds.every((todoId) => todoIsIn(frame, todoId, 'open'))), + `a stale result regressed a fully reopened Todo: ${JSON.stringify(frames)}` + ).toBe(true); + expect( + frames.every( + (frame) => + isBinarySorted(frame.open) && + isBinarySorted(frame.done) && + todoIds.every((todoId) => validTodoTransitionFrame(frame, todoId)) + ), + `rapid transitions rendered an invalid generated order: ${JSON.stringify(frames)}` + ).toBe(true); + + await page.unrouteAll({ behavior: 'wait' }); + }); }); diff --git a/tests/e2e-ui/e2e/unauth.anon.spec.ts b/tests/e2e-ui/e2e/unauth.anon.spec.ts index 92c93cdb..8031bb77 100644 --- a/tests/e2e-ui/e2e/unauth.anon.spec.ts +++ b/tests/e2e-ui/e2e/unauth.anon.spec.ts @@ -25,6 +25,34 @@ 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 }); + }, 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/lib/components/shared/header/Navbar.svelte b/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte index c4323ed2..c42d5c30 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 @@ diff --git a/tests/e2e-ui/ui/src/routes/+page.svelte b/tests/e2e-ui/ui/src/routes/+page.svelte index 56b62cb8..7237eb5d 100644 --- a/tests/e2e-ui/ui/src/routes/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/+page.svelte @@ -7,8 +7,10 @@ import Footer from '$lib/components/shared/Footer.svelte'; import HmrBeacon from '$lib/components/HmrBeacon.svelte'; import { highlightCode } from '$lib/components/walkthrough/highlight'; + import { env } from '$env/dynamic/public'; const session = $derived(page.data.session); + const celldProfile = $derived(env.PUBLIC_E2E_PROFILE === 'celld-nats'); const signedIn = $derived(!!session?.user); const authConfigError = $derived(page.url.searchParams.get('error') === 'Configuration'); @@ -30,8 +32,8 @@ ]; const demos = [ - { href: '/chat', title: 'Lobby chat', tag: 'Live + anonymous', blurb: 'A shared room with SSR, live updates, and guest reads.' }, - { href: '/todos', title: 'Todos', tag: 'Eventual', blurb: 'Ownership rules, optimistic commands, projector fill.' }, + { href: '/chat', title: 'Lobby chat', tag: 'Live + anonymous', blurb: 'A shared room with SSR, live updates, and guest reads. Same post on a Service or a cell — @live stays on GraphQL.' }, + { href: '/todos', title: 'Todos', tag: 'Eventual · celld', blurb: 'Ownership rules, optimistic commands, projector fill. Same declarations on a Service or a cell.' }, { href: '/blob', tag: 'Atomic + WASM', title: 'Blob game', blurb: 'Atomic board in the response. Same domain pure runs as WASM in the replica.' }, { href: '/admin', title: 'Admin', tag: 'Surface', blurb: 'Elevated surface — separate client, more power.' }, { href: '/session', title: 'Session', tag: 'OIDC', blurb: 'Who you are to the app: tokens, groups, roles.' } @@ -239,12 +241,26 @@ Service::new() Event-source aggregates and stop there. Use the service bus alone. Take GraphQL reads without the replica. Adopt what you need.

+

+ Distributed lets you define domain logic cleanly, then compose those pieces like + blocks into one service or many — whatever suits your size. Change transports and + sharding later as you grow. +

  • Rust
  • TypeScript
  • CQRS / ES
  • SvelteKit
  • +
  • celld
  • +
  • Kafka
  • +
  • NATS
  • +
  • RabbitMQ
  • +
  • PSQL
  • +
  • SQLite
  • +
  • OIDC
  • +
  • Keycloak
  • +
  • Authentik
@@ -258,7 +274,15 @@ Service::new()

- This site is the living playground — real apps under tests/e2e-ui. + {#if celldProfile} + This session is tests/e2e-celld. Todo create/complete and lobby posts + wait-dispatch to a cell. GraphQL @live and Eventual projectors stay in this + process — that is the chat demo. + {:else} + This site is the living playground. Default is one process under + tests/e2e-ui. The same UI can wait-dispatch Todo and Chat commands to celld + from tests/e2e-celld. + {/if}