diff --git a/.github/workflows/integration-celld.yaml b/.github/workflows/integration-celld.yaml index f55f37481..2d4f00953 100644 --- a/.github/workflows/integration-celld.yaml +++ b/.github/workflows/integration-celld.yaml @@ -7,7 +7,7 @@ name: celld (live + e2e-celld) # 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 \ +# E2E_UI_ORIGIN=http://localhost:8791 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 @@ -25,6 +25,47 @@ env: DISTRIBUTED_INTERNAL_SECRET: test-only-internal-secret-change-me-2026 jobs: + storage: + name: celld SQL transactions and crash recovery + runs-on: ubuntu-latest + timeout-minutes: 20 + 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: tests/celld/worker -> tests/celld/worker/target + shared-key: celld-storage + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install test runtimes + run: | + sudo apt-get update && sudo apt-get install -y sqlite3 + npm install -g esbuild + cargo install worker-build --locked + curl -fsSL https://celld.dev/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Build isolated fault-probe Worker + working-directory: tests/celld/worker + run: worker-build --release --features storage-conformance + - name: Prove SQL atomicity, fencing, autonomous recovery and growth + run: node tests/celld/storage-conformance.mjs + - name: Retain storage proof results and logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: celld-storage-proof + path: | + /tmp/distributed-cell-storage-*/results.json + /tmp/distributed-cell-storage-*/celld-*.log + if-no-files-found: ignore + e2e-celld: name: e2e-celld workspace tests runs-on: ubuntu-latest @@ -133,12 +174,13 @@ jobs: 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 PUBLIC_ORIGIN=http://localhost:8791 + export AUTH_URL="$PUBLIC_ORIGIN" export AUTH_USE_SECURE_COOKIES=false export AUTH_TRUST_HOST=true export PUBLIC_E2E_PROFILE=celld-nats export UI_BIND=localhost - export UI_URL=http://localhost:5180 + export UI_URL=http://localhost:8791 target/debug/distributed dev tests/e2e-celld \ > tests/e2e-celld/.ci-distributed-dev.log 2>&1 & @@ -165,13 +207,13 @@ jobs: fi grep -F 'lifecycle dev: process api ready http://127.0.0.1:8791' \ tests/e2e-celld/.ci-distributed-dev.log - grep -F 'lifecycle dev: process ui ready http://localhost:5180' \ + grep -F 'lifecycle dev: process ui ready http://localhost:8791' \ tests/e2e-celld/.ci-distributed-dev.log 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) + "http://localhost:8791/" 2>/dev/null || echo 000) if [ "$code" = "200" ] || [ "$code" = "302" ] || [ "$code" = "303" ]; then ok=1 break @@ -230,7 +272,7 @@ jobs: npx playwright test todos.user.spec.ts chat.user.spec.ts --project chromium-user env: - E2E_UI_ORIGIN: http://localhost:5180 + E2E_UI_ORIGIN: http://localhost:8791 E2E_API_ORIGIN: http://127.0.0.1:8791 CI: true diff --git a/.github/workflows/integration-e2e-ui.yaml b/.github/workflows/integration-e2e-ui.yaml index 3b3567301..16db9d94a 100644 --- a/.github/workflows/integration-e2e-ui.yaml +++ b/.github/workflows/integration-e2e-ui.yaml @@ -106,7 +106,8 @@ jobs: - name: Start application with Distributed dev run: | set -euo pipefail - export AUTH_URL="${E2E_UI_ORIGIN:-http://localhost:5180}" + export PUBLIC_ORIGIN="${E2E_UI_ORIGIN:-http://localhost:8791}" + export AUTH_URL="$PUBLIC_ORIGIN" export AUTH_USE_SECURE_COOKIES=false export AUTH_TRUST_HOST=true @@ -114,7 +115,8 @@ jobs: echo $! > .distributed-dev.pid ok=0 - for i in $(seq 1 240); do + # Match the CLI's bounded cold-runtime + UI readiness budget. + for i in $(seq 1 720); do if grep -Fq 'lifecycle dev: ready generation=' .distributed-dev.log; then ok=1 break @@ -122,6 +124,7 @@ jobs: if ! kill -0 "$(cat .distributed-dev.pid)" 2>/dev/null; then echo "distributed dev exited before readiness" tail -200 .distributed-dev.log + ps -eo pid,ppid,etime,stat,comm exit 1 fi sleep 0.5 @@ -129,11 +132,14 @@ jobs: if [ "$ok" != "1" ]; then echo "distributed dev failed to become ready" tail -200 .distributed-dev.log + # Executable names identify quiet build children without exposing + # credentials from process arguments or environment variables. + ps -eo pid,ppid,etime,stat,comm exit 1 fi grep -F 'lifecycle dev: process api ready http://127.0.0.1:8791' .distributed-dev.log - grep -F 'lifecycle dev: process ui ready http://localhost:5180' .distributed-dev.log + grep -F 'lifecycle dev: process ui ready http://localhost:8791' .distributed-dev.log tail -40 .distributed-dev.log - name: Install Playwright + Chromium @@ -141,12 +147,15 @@ jobs: npm install npx playwright install chromium --with-deps + - name: Verify candidate-key SSR and live delivery end to end + run: cargo test --manifest-path ../../Cargo.toml --features graphql,sqlite --test graphql_unique_key_live projected_candidate_keys_reach_chromium_over_http_and_websocket -- --ignored --exact --nocapture + - name: Run browser e2e run: | npm run test:browser npm run test:lifecycle-reload env: - E2E_UI_ORIGIN: http://localhost:5180 + E2E_UI_ORIGIN: http://localhost:8791 E2E_API_ORIGIN: http://127.0.0.1:8791 CI: true @@ -155,9 +164,12 @@ jobs: uses: actions/upload-artifact@v4 with: name: e2e-ui-playwright-report + # Only the explicit paths below; never include e2e/.auth or env files. + include-hidden-files: true path: | tests/e2e-ui/playwright-report tests/e2e-ui/test-results + tests/e2e-ui/.distributed-dev.log if-no-files-found: ignore retention-days: 7 diff --git a/.github/workflows/integration-gateway.yaml b/.github/workflows/integration-gateway.yaml new file mode 100644 index 000000000..059896222 --- /dev/null +++ b/.github/workflows/integration-gateway.yaml @@ -0,0 +1,222 @@ +name: Application gateway + +on: + pull_request: + paths: + - 'src/**' + - 'distributed_macros/**' + - 'Cargo.toml' + - 'build.rs' + - 'migrations/**' + - 'docs/gateway/**' + - 'tests/e2e-ui/**' + - 'distributed_cli/src/lifecycle/**' + - 'tests/e2e-ui/ui/src/auth.ts' + - 'tests/e2e-ui/ui/src/lib/server/**' + - 'tests/e2e-ui/ui/src/routes/api/auth/**' + - 'tests/graphql_identity/**' + - 'tests/graphql_causal_transport/**' + - 'tests/graphql_query_protocol/**' + - 'tests/edge_query_delivery*' + - 'js/src/replica/**' + - 'js/tests/replica*' + - 'tests/gateway*' + - 'tests/gateway*/**' + - '.github/workflows/integration-gateway.yaml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + targets: wasm32-unknown-unknown + components: rustfmt, clippy + - name: Format contract and fixture sources + run: rustfmt --edition 2021 --check src/gateway/mod.rs tests/gateway_routing.rs tests/gateway_extensions.rs tests/gateway-portable/src/lib.rs + - name: Test portable routing and adapter contracts + run: cargo test --manifest-path tests/gateway-portable/Cargo.toml --locked + - name: Check fixture lints + run: cargo clippy --manifest-path tests/gateway-portable/Cargo.toml --locked --all-targets -- -D warnings + - name: Compile the UI/auth consumer to Wasm + run: cargo check --manifest-path tests/gateway-portable/Cargo.toml --locked --target wasm32-unknown-unknown + - name: Test and compile portable GraphQL operation policy + run: | + cargo test --manifest-path tests/gateway-portable/Cargo.toml --locked --features gateway-delivery + cargo check --manifest-path tests/gateway-portable/Cargo.toml --locked --features gateway-delivery --target wasm32-unknown-unknown + python3 tests/gateway-portable/check_dependencies.py --features gateway-delivery + - name: Verify native and Wasm dependency isolation + run: python3 tests/gateway-portable/check_dependencies.py + + auth: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + - name: Verify gateway and backend identity + run: cargo test -p distributed --no-default-features --features gateway,graphql,sqlite --test graphql_identity + - name: Install pinned auth fixture dependencies + working-directory: tests/gateway-auth + run: npm ci + - name: Install Chromium + working-directory: tests/gateway-auth + run: npx playwright install --with-deps chromium + - name: Run production Auth.js lifecycle and browser security cases + working-directory: tests/gateway-auth + run: npm test + + native: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + components: clippy + - name: Test real native HTTP streams and upgrades + run: cargo test --manifest-path tests/gateway-native/Cargo.toml --locked + - name: Check native fixture lints + run: cargo clippy --manifest-path tests/gateway-native/Cargo.toml --locked --all-targets -- -D warnings + - name: Verify UI/auth executor dependency isolation + run: python3 tests/gateway-native/check_dependencies.py + + graphql: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + - name: Verify embedded and remote GraphQL protocols + run: cargo test -p distributed --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --test gateway_graphql --test gateway_graphql_operation --test graphql_causal_transport --test graphql_query_protocol --test graphql_identity --test graphql_sqlite --test sqlite_repository + + - name: Verify shared query cancellation, expiry and deadlines + run: cargo test -p distributed --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --lib gateway::native::flight::tests + - name: Verify shared live replay, expiry, backpressure and teardown + run: cargo test -p distributed --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --lib gateway::native::live::tests + - name: Verify transactional cache coverage and Atomic rollback + run: | + cargo test -p distributed --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --lib graphql::delivery::versions::tests + cargo test -p distributed --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --lib sqlite_direct_projection_and_ledger_replay_commit_atomically + + freshness: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + - uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Prove primary routing with paused physical standby + run: python3 tests/gateway-postgres/run.py + - name: Verify client freshness and command protocol + run: | + npm ci --prefix js + npm --prefix js run build + node --test js/tests/replica-command-runtime.test.mjs js/tests/replica-protocol.test.mjs js/tests/replica-revalidation.test.mjs js/tests/replica-graphql-transport.test.mjs + - name: Verify generated delivery metadata + run: cargo test -p distributed_cli client_compiler + + worker: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + targets: wasm32-unknown-unknown + components: clippy + - name: Install pinned local Worker and auth fixtures + run: | + cargo install worker-build --version 0.8.5 --locked + npm ci --prefix tests/gateway-worker + npm ci --prefix tests/gateway-auth + cd tests/gateway-auth + npx playwright install --with-deps chromium + - name: Check Wasm build and dependency boundary + run: | + cargo check --manifest-path tests/gateway-worker/Cargo.toml --locked --target wasm32-unknown-unknown + cargo clippy --manifest-path tests/gateway-worker/Cargo.toml --locked --target wasm32-unknown-unknown -- -D warnings + python3 tests/gateway-worker/check_dependencies.py + - name: Exercise actual workerd HTTP, WebSockets and Auth.js lifecycle + run: | + node tests/gateway-worker/proxy-runtime.mjs + node tests/gateway-worker/run.mjs + - name: Measure actual native and workerd origin/client savings + run: cargo test --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --test graphql_query_protocol separate_origin_and_client_savings -- --ignored --nocapture + - uses: actions/upload-artifact@v4 + if: always() + with: + name: gateway-delivery-load + path: tests/gateway-worker/artifacts/load-report.json + - name: Prove actual DO coordination, SQL reduction, restart and cancellation + run: cargo test --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --test graphql_query_protocol worker_ -- --ignored --nocapture --test-threads=1 + + application: + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + targets: wasm32-unknown-unknown + - name: Install isolated browser fixture + run: | + cargo install wasm-pack --locked + npm ci --prefix tests/gateway-auth + npm ci --prefix tests/e2e-ui + cd tests/gateway-auth + npx playwright install --with-deps chromium + - name: Verify explicit application mounts + run: cargo test --no-default-features --features graphql,sqlite,application-runtime,gateway --test application_composition --test application_plans --test gateway_mounts + - name: Build coherent application and run public-origin browser flows + run: node tests/e2e-ui/gateway/run.mjs + - name: Verify dev lifecycle and browser flows through the gateway + run: GATEWAY_SKIP_BUILD=1 GATEWAY_DEV=1 node tests/e2e-ui/gateway/run.mjs + - name: Verify controlled reloads across gateway and application restarts + run: GATEWAY_SKIP_BUILD=1 GATEWAY_DEV=1 GATEWAY_LIFECYCLE=1 node tests/e2e-ui/gateway/run.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: gateway-application + path: tests/e2e-ui/gateway/artifacts/ diff --git a/.github/workflows/integration-nats.yaml b/.github/workflows/integration-nats.yaml index 7385a4b92..951e3025f 100644 --- a/.github/workflows/integration-nats.yaml +++ b/.github/workflows/integration-nats.yaml @@ -30,3 +30,7 @@ jobs: done - name: Run NATS transport integration tests run: cargo test --test nats_transport --features nats --verbose + - name: Verify retained history and durable filter reconciliation + env: + DISTRIBUTED_ARCHIVE_TEST_NATS_URL: nats://localhost:4222 + run: cargo test --lib --features nats bus::nats_bus::archive_tests -- --include-ignored diff --git a/.github/workflows/integration-postgres.yaml b/.github/workflows/integration-postgres.yaml index 0dac77468..1543702c5 100644 --- a/.github/workflows/integration-postgres.yaml +++ b/.github/workflows/integration-postgres.yaml @@ -44,3 +44,19 @@ jobs: cargo test --test postgres_transport --no-default-features --features postgres --verbose cargo test --test distributed_read_model --no-default-features --features postgres --verbose cargo test --test sql_lock_manager --no-default-features --features postgres --verbose + - name: Verify source fencing and snapshot rebuild against PostgreSQL + env: + DISTRIBUTED_SNAPSHOT_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/distributed_snapshots + DISTRIBUTED_REBUILD_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/distributed_rebuild + run: | + docker exec ${{ job.services.postgres.id }} createdb -U postgres distributed_snapshots + docker exec ${{ job.services.postgres.id }} createdb -U postgres distributed_rebuild + cargo test --lib --no-default-features --features graphql,postgres projection::source_snapshot_tests::source_snapshots_postgres_reordering_and_restart -- --exact + cargo test --lib --no-default-features --features graphql,postgres projection::source_snapshot_tests::snapshot_rebuild_postgres -- --exact + - name: Verify unique-key GraphQL joins and authorization + env: + DISTRIBUTED_UNIQUE_KEY_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/distributed_unique_key_test + run: | + docker exec ${{ job.services.postgres.id }} createdb -U postgres distributed_unique_key_test + cargo test --lib --no-default-features --features graphql,postgres graphql::engine::composite_relationship_tests::unique_key_join_postgres_authorization_and_manifest -- --ignored --exact + cargo test --lib --no-default-features --features postgres sqlx_repo::projection_protocol::reads::candidate_key_snapshot_lookup_postgres -- --ignored --exact diff --git a/Cargo.toml b/Cargo.toml index d187188c7..fc02e1729 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,17 @@ required-features = ["graphql", "sqlite"] [features] default = [] application-runtime = [] +# Portable gateway declarations and dispatch only; runtime adapters are separate. +gateway = ["dep:url"] +# Native HTTP adapter remains independent of GraphQL and SQL. +gateway-native = ["gateway", "http", "reqwest/stream", "dep:hyper", "dep:hyper-util", "dep:tower", "tokio/io-util"] +# Whole-operation selection is portable; execution adapters are separately gated. +gateway-graphql = ["gateway", "dep:async-graphql-parser"] +# Shared portable identity, freshness and delivery contracts. +gateway-delivery = ["gateway-graphql"] +# workers-rs ingress and delivery contracts, without native executors. +gateway-worker = ["gateway-delivery", "dep:worker", "dep:futures-channel"] +gateway-graphql-native = ["gateway-native", "gateway-graphql", "graphql", "dep:tokio-tungstenite"] runtime = ["application-runtime"] emitter = ["dep:event-emitter-rs"] metrics = [] @@ -53,8 +64,13 @@ graphql = ["dep:async-graphql", "dep:async-graphql-axum", "dep:hmac", "dep:jsonw async-nats = { version = "0.50", optional = true } async-trait = "0.1" async-graphql = { version = "7", optional = true } +async-graphql-parser = { version = "7", optional = true } +tokio-tungstenite = { version = "0.30", optional = true } async-graphql-axum = { version = "7", optional = true } axum = { version = "0.8", optional = true } +hyper = { version = "1", features = ["http1", "server"], optional = true } +hyper-util = { version = "0.1", features = ["tokio"], optional = true } +tower = { version = "0.5", features = ["util"], optional = true } base64 = "0.23.0" futures = { version = "0.3", optional = true } lapin = { version = "4", optional = true } @@ -63,6 +79,7 @@ reqwest = { version = "0.13", default-features = false, features = ["json", "rus jsonwebtoken = { version = "9", optional = true } bitcode = { version = "0.6.9", features = ["serde"] } event-emitter-rs = { version = "0.1.4", optional = true } +futures-channel = { version = "0.3", features = ["sink"], optional = true } futures-util = { version = "0.3", default-features = false, features = ["alloc"] } hmac = { version = "0.12", optional = true } opentelemetry = { version = "0.32", default-features = false, features = ["trace"], optional = true } @@ -79,6 +96,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "time", tracing = { version = "0.1", optional = true } tracing-opentelemetry = { version = "0.33", default-features = false, optional = true } uuid = { version = "1", features = ["v7"] } +url = { version = "2", optional = true } worker = { version = "0.8", features = ["queue"], optional = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] @@ -92,7 +110,7 @@ sha2 = "0.10" tonic-build = { version = "0.14", default-features = false, features = ["transport"] } [dev-dependencies] -axum = "0.8" +axum = { version = "0.8", features = ["ws"] } distributed_cli = { path = "distributed_cli" } tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" diff --git a/README.md b/README.md index 1a8b209ea..906cdcd10 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,105 @@ mutation SaveTodo { } ``` +#### Full-state projections and out-of-order events + +Broker delivery order is not necessarily aggregate commit order. For a read +model whose rows are complete snapshots owned by one aggregate stream, opt in +to source-version fencing: + +```rust,ignore +distributed::projection! { + pub const TODOS: ProjectionDescriptor = { + name: "project_todos", + version: 2, + epoch: "todos-source-snapshots-v1", + model: Todos, + source: aggregate_snapshot, + on { + events: [TodoCreatedDomainEvent, TodoCompletedDomainEvent], + mutation: SaveTodo, + input: { todo: body }, + }, + on { + events: [TodoPurgedDomainEvent], + mutation: DeleteTodo, + input: { todo_id: aggregate_id }, + }, + }; +} +``` + +The framework compares canonical `(aggregate_sequence, publication_ordinal)` +within the owning `(aggregate_type, aggregate_id)` stream. A late snapshot +cannot overwrite a newer row or resurrect a deleted one. Even deletion before +creation leaves a durable tombstone. A newer snapshot can recreate the row. +Source fences, physical rows, row revisions, broker checkpoints and causal +observations commit atomically in the memory, SQLite and PostgreSQL adapters. +A stale input confirms the current row revision without generating a fake row +update; a concurrent change makes that confirmation retry through the normal +projection conflict path. + +Use this only for complete replacement snapshots with stable row keys. Every +affected key must be owned by one aggregate stream; joins, counters, partial +patches and relationship side effects need their own ordering/fold semantics. +This mode rejects delta operations, expression partitions and direct placement. +Different aggregate streams cannot take over an existing fenced key. Matching +source versions with conflicting occurrence content fail closed. + +Migration `0005_projection_source_snapshots` persists the fence alongside each +record, including tombstones; compaction does not remove it. Enabling this on +an existing unversioned projection requires an explicit read-model rebuild +from retained canonical events. Merely changing the projection version or epoch +does not infer a source version for existing rows. The normal ordered-delivery +contract and transport identity checks still apply; this is not a replacement +for reliable broker delivery or an incremental-event reorder buffer. + +Custom program factories can use `ProjectionProgram::with_source_snapshots()`; +the policy is part of the canonical program identity. Browser optimism continues +to use the same mutation program, and authoritative confirmation uses committed +row revisions rather than comparing browser timestamps. + +#### Rebuilding existing snapshot projections + +Use explicit maintenance, not a startup fallback or republishing loop: + +```rust,ignore +use distributed::projection::rebuild::SnapshotProjectionRebuild; + +// Stop producers, drain outboxes, stop consumers, and back up the read model. +// Reuse the same generated SurfaceProjector mounted by the application. +let rebuild = SnapshotProjectionRebuild::begin(&repository, &projector).await?; +// Read history AFTER capturing the target inventory. Another authoritative +// archive can supply these canonical occurrences; rebuilding is bus-neutral. +let events = nats_bus.retained_domain_events().await?; +let plan = rebuild.from_complete_history(&events)?; +println!("{} records to rebuild", plan.record_count()); +plan.apply(&repository).await?; +``` + +Memory, SQLite and PostgreSQL commit complete rows/tombstones, source fences, +new record revisions and live-query changes atomically. A concurrent update, +insertion or deletion invalidates the captured inventory. Failure rolls back the +whole projection. Broker checkpoints, inbox receipts, command ledgers and prior +causal observations are preserved; maintenance does not claim a domain command +occurred. No domain handlers, external effects or queue sends run. + +The archive must cover every existing record and retain its stored source +occurrence, where present. Conflicting duplicate occurrences, missing aggregate +sequence prefixes and stale source heads fail closed. The caller must establish +history completeness: even a broker stream starting at sequence one may have +been created after its aggregates. The NATS reader verifies a stable, gap-free +retained stream without creating consumers or acknowledging messages, but cannot +prove that unpublished events or earlier deleted streams never existed. + +This first API is bounded offline maintenance for one active local, +unit-partition snapshot binding: at most 10,000 records and 100,000 occurrences / +64 MiB of canonical history. It intentionally rejects delta folds and histories +whose publication sequences have gaps. It does not migrate schemas/topologies, +perform online shadow swaps, or make several independent projections one +transaction. Keep services stopped if a multi-projection maintenance run fails; +capture fresh inventories and rerun before resuming them. + 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. @@ -629,6 +728,17 @@ CQRS is the architectural split between write-side aggregates and query-side rea Published messages are a separate boundary. An aggregate event record is not automatically a domain event. When other services, projections, or transports need a fact or command, create an `OutboxMessage` and commit it with the aggregate. The outbox payload can represent a domain event, integration event, command, or any other transport message. +The outbox stores delivery work, not publication history. Successful delivery +deletes the row under its active lease. Failed or ambiguous sends retain the row +for retry or inspection. A crash after transport acceptance but before deletion +can deliver the same event ID again; consumers must remain idempotent. Event +history belongs in the event store, and command retry results belong in command +receipts. Neither depends on retaining delivered outbox rows. + +In v5, `OutboxStore::complete` and `complete_many` remove delivered rows instead +of retaining `Published` records. Repeating settlement of a removed row returns +`NotFound`; stale claims on existing rows still return `InvalidState`. + The existing names and serialized fields such as `EventRecord::event_name` remain part of the compatibility contract. Terminology cleanup should clarify usage without renaming stored event records unless a migration path is explicitly designed. ## Pluggable by Default @@ -702,6 +812,46 @@ impl TryFrom<&EventRecord> for TodoEvent { /* ... */ } impl Aggregate for Todo { /* ... */ } ``` +### Flat events and optimistic updates + +With `domain = event`, recorder parameters are the flat outward event body. +Literal arguments in public transitions also inform generated optimistic +projections—there is no second status mapping to maintain: + +```rust,ignore +#[sourced(entity, aggregate_type = "review")] +impl Review { + pub fn approve(&mut self, id: String) -> distributed::SourcedResult { + // Validate the decision here, before recording it. + self.record_status(id, "approved".into())?; + Ok(()) + } + + #[event("review.status_recorded", version = 1, domain = event)] + fn record_status(&mut self, id: String, status: String) { + self.entity.set_id(id); + self.status = status; + } +} +``` + +A portable command selecting `domain_commands::Approve` carries the known +`status = "approved"` into its projection preview. The projection still defines +which read-model fields it updates; permissions and confirmed events still +decide what the client can ultimately retain. + +Inference recognizes scalar literals, literal string ownership conversions, +and optional literals using fully qualified standard constructors (for example, +`::core::option::Option::Some(true)`). Bare `Some`/`None` remain unknown because +they can be shadowed. Inference preserves numeric types. Computed IDs, clocks, +variables, arbitrary calls, and unsupported expressions stay unknown. When +calls to the same recorder disagree, only their shared constants are retained. +This does not predict authorization or guarantee an event will occur. Snapshot +events continue to infer known values from recorder state assignments. + +See [the executable contract tests](tests/sourced/flat_preview.rs), including +the generated GraphQL client manifest and conflicting/dynamic-value cases. + ### Durable Stream Identity `Aggregate::aggregate_type()` provides the type component of a persistence stream's identity (the pair `(aggregate_type, aggregate_id)`). The default uses Rust's type name for development convenience, but **production persistence should set an explicit, stable durable name**: @@ -1133,6 +1283,43 @@ let message = OutboxMessage::encode_for_entity( )?; ``` +### Facts derived by event handlers + +An event handler may verify an external result without making another aggregate +decision—for example, extracting document metadata after an upload. Derive a +typed fact from the incoming canonical occurrence: + +```rust,ignore +#[derive(serde::Serialize, distributed::DomainEvent)] +#[domain_event(name = "document.indexed", version = 1)] +struct DocumentIndexed { + document_id: String, + word_count: u64, +} + +let indexed = source.derive( + "document-indexer", + "metadata-v1", + &DocumentIndexed { document_id, word_count }, +)?; +``` + +The same source, producer, output key and body produce identical canonical bytes +and an identical event ID. Different outputs or changed bodies have different +IDs. `indexed.derivation()` identifies the immediate source and producer; the +aggregate fields retain the original transition's provenance, not an invented +commit. Timestamp, causation, correlation and trace metadata come from the source. +Ordinary typed projections can consume the derived fact. Aggregate-snapshot +projections reject it because it does not establish a new aggregate version. + +Publish the canonical bytes using the existing bus API and `indexed.id()` as +the message ID. Propagate its causal metadata. Acknowledge the incoming delivery +only after every output publish succeeds; a failure retries the source and may +republish an accepted prefix with the same IDs. This is at-least-once delivery, +not an atomic external effect plus publish. Aggregate decisions still use a +transactional outbox. Bound external reads and make effects idempotent before +using this pattern. + ### Publishing the Outbox How a committed row reaches the bus depends on whether a bus is attached to the @@ -1285,7 +1472,7 @@ in `bus` (no concrete broker dependency). **Two confirmation thresholds** (do not collapse them): -1. **Producer publish** — when an outbox row may be marked published (SQL commit, +1. **Producer publish** — when an outbox row may be deleted (SQL commit, broker confirm/ack, Knative 2xx, in-memory accept). Unknown outcomes stay retryable. 2. **Consumer ack** — only after the handler (and optional inbox receipt) committed. Never silently ack a handler error. @@ -1297,6 +1484,46 @@ use distributed::bus::{run_source, RunOptions}; run_source(service, source, RunOptions::idempotent()).await?; ``` +## Command contracts + +Command semantics live in `distributed::command`, independently of the query +transport. Use `CommandInput` and `CommandOutput` to describe Serde input/output +shapes, and `command::{typed_command, Succeeded, Eventual, Atomic, PreparedCommand}` +to declare and prepare commands. The GraphQL adapter derives its surface from +those contracts; GraphQL naming restrictions are checked when exposing them. + +```rust +# use distributed::command; +use distributed::{CommandInput, CommandOutput}; +use distributed::command::{typed_command, Succeeded}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, CommandInput)] +struct RenameInput { id: String, title: String } + +#[derive(Serialize, CommandOutput)] +struct RenameOutput { id: String } + +# fn main() { +let declaration = typed_command::>("todo.rename"); +# } +``` + +This is a breaking Rust API change. Replace `GraphqlInput`/`GraphqlOutput` +with `CommandInput`/`CommandOutput` throughout the DTO's nested types and +move command imports from `distributed::graphql` to `distributed::command`. +Manual metadata implementations now use `CommandInputType`/`CommandOutputType`, +`command_type()`, and `CommandTypeDef`/`CommandTypeField`. The old GraphQL +command re-exports, metadata types, and derives have been removed. + +GraphQL remains the application's UI gateway: it exposes the command contract +alongside queries and subscriptions. Command handlers describe their data and +consistency through the core API, independently of where that gateway runs. + +`Atomic` continues to obtain its response shape from the relational read +model and requires the existing transaction proof. Moving command ownership +does not change Eventual projection confirmation or Atomic response sealing. + ## Microservice Framework (`microsvc`) The `microsvc` module provides a convention-based async command/event handler framework. Register handlers on typed `Routes` bundles, collect them into a non-generic `Service`, then expose that service over HTTP, gRPC, the bus, or direct dispatch. @@ -1614,6 +1841,22 @@ let loaded = repo PK (same-named columns, or `foreign_key` / `target_foreign_key` in PK order). A one-column `foreign_key` on a composite PK is an error, not a silent `.first()`. +- **Unique-key joins:** direct relationships can set + `references = "namespace,revision"` alongside + `foreign_key = "object_namespace,object_revision"`. The referenced columns + must be a complete declared unique key, such as + `#[unique(columns = ["namespace", "revision"])]`, on the target of + `belongs_to` or the source of `has_many`. Foreign-key columns pair in the + declared reference order. This lets a model retain a surrogate primary key + for row identity while relating by a domain key. Missing/null foreign keys + produce no match; nested target permissions still apply. Omitting `references` + selects the primary key. `references` is not supported on `many_to_many`. +- **Cyclic singular relationships:** use `Option>` for a `belongs_to` + field when two models refer to each other. For example, + `#[readmodel(belongs_to = "Profile", foreign_key = "id")]` + `profile: Option>`. The box only gives Rust a finite-sized type; + GraphQL still exposes one nullable `Profile`, not a list or embedded JSON + column. Ordinary `Option` remains the same singular relationship. - **Writes:** `ReadModelWritePlan` / workspace `upsert` + `commit` (same transaction as events when staged on `CommitBatch`). - **Internal loads:** PK-anchored includes — @@ -1689,9 +1932,8 @@ distributed = { version = "0.1", features = ["graphql", "postgres"] } ### Mount on a service ```rust,ignore -use distributed::graphql::{ - claim, col, read, typed_command, Eventual, GraphqlEngine, -}; +use distributed::command::{typed_command, Eventual}; +use distributed::graphql::{claim, col, read, GraphqlEngine}; use distributed::microsvc::{Routes, Service}; let routes = Routes::new() @@ -1762,6 +2004,12 @@ ModelPermissions::new() Row predicates can bind session claims (`claim("x-user-id")`, …) so multi-tenant RLS lives in the engine, not ad-hoc handler SQL. +With the causal protocol enabled, required session presets must be present and +valid for their declared codecs before execution. Missing or malformed values +return `BAD_REQUEST` for both ordinary and streaming requests, without running +resolvers or issuing a partial protocol envelope. The error does not disclose +claim names or values; configure the identity mapping to supply those inputs. + ### Identity (first-class OIDC) Auth is a **built-in GraphQL concern**, not a separate product you wire after the @@ -2092,6 +2340,37 @@ a smaller target: model fields, event methods, handler bodies, and projection shapes. Boilerplate service setup, manifest discovery, schema output, and GitOps artifacts stay deterministic. +For an existing typed `Service`, derive the logical application from its command +inventory and the same full GraphQL `Surface` used by the runtime: + +```rust,ignore +let surface = distributed::SurfaceSpec::from_surface("catalog", &full_surface)?; +let application = service.application("catalog", surface)?; +let manifest = application.manifest(); +``` + +Command namespaces such as `orders.submit` supply module names; there is no +second module list to maintain. Assembly rejects commands missing from the +Surface or exposed by the Surface without a Service owner. Role-selected client +exports remain authorization views of that full contract. Complete application +manifests are bounded at 4 MiB; each opaque JSON contract remains bounded at +1 MiB. A generated Surface contract uses the complete-manifest budget rather +than the opaque-value cap; its typed contents and the existing collection, +string and nesting limits are still validated. The complete artifact, including +all surfaces and module inventories, must fit within 4 MiB. + +An event-driven policy that emits through another aggregate can explicitly carry +the incoming command's causal identity before recording its events: + +```rust,ignore +ctx.inherit_causation(&mut downstream)?; +downstream.record(observation)?; +``` + +This requires an incoming causation ID and does not manufacture one for external +events. NATS preserves the message's payload content type and keeps reserved +transport headers out of user metadata across delivery. + ```bash cargo install distributed_cli # installs `distributed` @@ -2112,6 +2391,13 @@ distributed describe # print the ApplicationManifest as JSON distributed schema --dialect postgres # render migration SQL from read models ``` +Application manifest wire version 2 stores module declarations and selected +Surface contracts without repeating top-level `commands`, `events`, `models`, +or `projections`. The Rust `ApplicationManifest` decoder reconstructs those +inventories and validates their ownership; its convenience fields are unchanged. +Portable artifacts remain bounded to 4 MiB. Version 1 artifacts must be +regenerated with the matching CLI; they are not accepted as version 2. + For a full application, run `distributed build` or `distributed dev` from its Cargo workspace root. The CLI discovers the typed application, runtime binary, conventional `ui/` SvelteKit app, and `@hops-ops/distributed` dependency. A diff --git a/build.rs b/build.rs index a0f3b91bb..c406446f3 100644 --- a/build.rs +++ b/build.rs @@ -161,9 +161,10 @@ fn emit_migration_inventory() { } fn emit_dialect(generated: &mut String, name: &str, dialect: Dialect, inventory: &Inventory) { - generated.push_str("#[cfg(feature = \""); - generated.push_str(dialect.name()); - generated.push_str("\")]\n"); + match dialect { + Dialect::Sqlite => generated.push_str("#[cfg(any(feature = \"sqlite\", all(feature = \"workers-rs\", target_arch = \"wasm32\")))]\n"), + Dialect::Postgres => generated.push_str("#[cfg(feature = \"postgres\")]\n"), + } generated.push_str("pub(crate) const "); generated.push_str(name); generated.push_str(": &[EmbeddedMigration] = &[\n"); diff --git a/distributed_cli/README.md b/distributed_cli/README.md index e444c85ca..95ff55e7e 100644 --- a/distributed_cli/README.md +++ b/distributed_cli/README.md @@ -59,6 +59,13 @@ document, boundary binding, or UI build therefore leaves the prior generation active. Lifecycle receipts, the application manifest, and generated client trees are tool-owned state under `.distributed/lifecycle/`. +During `distributed dev`, each process launch has a supervisor-owned identity. +Client-only generations can retain an unchanged API process: the active cohort +explicitly admits that instance, and its GraphQL responses advertise the current +generation. Preparing, replaced and retired instances cannot dispatch mutations. +Rollback launches receive new identities; schema compatibility alone never +authorizes an old process. Upgrade the CLI and runtime together and restart dev +after an upgrade so every member receives the matching lifecycle contract. Rust binaries remain in Cargo's target directory and SvelteKit output remains in its adapter-selected output directory. diff --git a/distributed_cli/src/cli.rs b/distributed_cli/src/cli.rs index a8f46d0cc..0ca5b3757 100644 --- a/distributed_cli/src/cli.rs +++ b/distributed_cli/src/cli.rs @@ -19,8 +19,8 @@ use std::sync::Arc; use std::time::Duration; use crate::client_compiler::{ - compile_client, ClientCompileInput, ClientDocument, ClientSurfaceSelector, - GeneratedClientFile, GeneratedClientProject, + compile_client, ClientCompileInput, ClientDocument, ClientSurfaceSelector, GeneratedClientFile, + GeneratedClientProject, }; use crate::contracts::{ contracts_accept, contracts_check, unknown_scope_diagnostic, ContractAcceptScope, @@ -44,7 +44,7 @@ use crate::{ MetricsTarget, PostCreateAction, ServiceScaffoldSpec, ServiceTransport, StoreTarget, }; -const DISTRIBUTED_MANIFEST_SCHEMA_VERSION: u64 = 1; +const DISTRIBUTED_MANIFEST_SCHEMA_VERSION: u64 = 2; const DISTRIBUTED_CLIENT_MANIFEST_VERSION: u64 = 2; /// Top-level standalone CLI arguments for the `distributed` binary. @@ -2129,9 +2129,8 @@ fn stale_generated_client_files( .starts_with("/** GENERATED by distributed client. Do not edit. */") || (compiler_manifest_version == Some(1) && relative == "routes.ts" - && contents.starts_with( - "/** GENERATED framework-neutral `@load` ownership plan. */", - )); + && contents + .starts_with("/** GENERATED framework-neutral `@load` ownership plan. */")); if !has_compatible_marker { return Err(format!( "refusing to remove {} because its compiler ownership marker is missing", @@ -2516,36 +2515,22 @@ fn github_repo_create_args(slug: &str) -> Vec<&str> { } fn validate_manifest_json(envelope: &serde_json::Value) -> Result<(), Box> { - let Some(schema_version) = envelope + let schema_version = envelope .get("schema_version") .and_then(serde_json::Value::as_u64) - else { - return Err("manifest JSON is missing numeric schema_version".into()); - }; + .ok_or("manifest JSON is missing numeric schema_version")?; if schema_version != DISTRIBUTED_MANIFEST_SCHEMA_VERSION { - return Err(format!( - "unsupported Distributed manifest schema version {schema_version}; expected {DISTRIBUTED_MANIFEST_SCHEMA_VERSION}" - ) - .into()); + return Err(format!("unsupported Distributed manifest schema version {schema_version}; expected {DISTRIBUTED_MANIFEST_SCHEMA_VERSION}").into()); } - // `describe` emits ApplicationManifest JSON (logical composition artifact), - // not the retired DistributedManifestEnvelope { project: ... } shape. if envelope .get("name") .and_then(serde_json::Value::as_str) - .map(str::is_empty) - .unwrap_or(true) + .filter(|name| !name.is_empty()) + .is_none() { return Err("application manifest JSON is missing non-empty string name".into()); } - for field in [ - "modules", - "commands", - "events", - "projections", - "models", - "surfaces", - ] { + for field in ["modules", "surfaces"] { if envelope .get(field) .and_then(serde_json::Value::as_array) @@ -2554,6 +2539,13 @@ fn validate_manifest_json(envelope: &serde_json::Value) -> Result<(), Box, @@ -72,7 +74,10 @@ impl ClientSurfaceSelector { eligible_roles: impl IntoIterator>, schema_roles: impl IntoIterator>, ) -> Self { - let mut eligible_roles = eligible_roles.into_iter().map(Into::into).collect::>(); + let mut eligible_roles = eligible_roles + .into_iter() + .map(Into::into) + .collect::>(); let mut schema_roles = schema_roles.into_iter().map(Into::into).collect::>(); eligible_roles.sort(); eligible_roles.dedup(); diff --git a/distributed_cli/src/client_compiler/projection_delta/preview.rs b/distributed_cli/src/client_compiler/projection_delta/preview.rs index 0245253eb..cdbb0747e 100644 --- a/distributed_cli/src/client_compiler/projection_delta/preview.rs +++ b/distributed_cli/src/client_compiler/projection_delta/preview.rs @@ -116,6 +116,16 @@ struct CompiledPureArg { } impl CompiledCommandProjection { + /// Only emitted mutations consume command presets. Authored occurrences + /// can lower to recovery without retaining any of their input expressions. + pub(crate) fn trusted_preset_names(&self) -> BTreeSet { + let mut names = BTreeSet::new(); + for operation in &self.preview.operations { + operation.mutation.collect_trusted_presets(&mut names); + } + names + } + pub(crate) fn affected_models(&self) -> BTreeSet { let mut models = BTreeSet::new(); for operation in &self.preview.operations { @@ -333,6 +343,31 @@ enum PreviewMutation { } impl PreviewMutation { + fn collect_trusted_presets(&self, names: &mut BTreeSet) { + match self { + Self::Upsert { scope, fields, .. } + | Self::Patch { + scope, set: fields, .. + } => { + scope.collect_trusted_presets(names); + for field in fields { + field.value.collect_trusted_presets(names); + } + } + Self::Delete { scope } => scope.collect_trusted_presets(names), + Self::Link { source, target, .. } | Self::Unlink { source, target, .. } => { + source.collect_trusted_presets(names); + target.collect_trusted_presets(names); + } + Self::InvalidateRelationship { source, .. } => source.collect_trusted_presets(names), + Self::InvalidateModel { partition, .. } => { + if let Some(partition) = partition { + partition.collect_trusted_presets(names); + } + } + } + } + fn canonical_scope(&self) -> Result { let scope_json = |scope: &PreviewScope| { serde_json::to_string(scope).map_err(|error| { @@ -430,6 +465,50 @@ impl PreviewMutation { } } +impl PreviewScope { + fn collect_trusted_presets(&self, names: &mut BTreeSet) { + self.partition.collect_trusted_presets(names); + for field in &self.key { + field.value.collect_trusted_presets(names); + } + } +} + +impl PreviewPartition { + fn collect_trusted_presets(&self, names: &mut BTreeSet) { + if let Self::Expression { expression, .. } = self { + expression.collect_trusted_presets(names); + } + } +} + +impl PreviewExpression { + fn collect_trusted_presets(&self, names: &mut BTreeSet) { + match self { + Self::TrustedPreset { name, .. } => { + names.insert(name.clone()); + } + Self::List { values } + | Self::Transform { + arguments: values, .. + } => { + for value in values { + value.collect_trusted_presets(names); + } + } + Self::Object { fields } => { + for field in fields { + field.value.collect_trusted_presets(names); + } + } + Self::Input { .. } + | Self::GeneratedDefault { .. } + | Self::Constant { .. } + | Self::Null => {} + } + } +} + /// The variant declaration order is the canonical operation-kind order shared /// with the authoritative ProjectionDelta wire. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -1957,6 +2036,60 @@ fn model_recovery( mod tests { use super::*; + #[test] + fn emitted_mutation_presets_include_nested_values_keys_and_partitions_only() { + let preset = |name: &str| PreviewExpression::TrustedPreset { + name: name.into(), + codec: "string".into(), + }; + let scope = PreviewScope { + model: "Todo".into(), + partition: PreviewPartition::Expression { + expression: preset("tenant"), + requires: PreviewPartitionRequirement::CurrentCachePartition, + }, + key: vec![PreviewKeyField { + ordinal: 0, + field: "id".into(), + value: preset("actor"), + }], + }; + let mutation = PreviewMutation::Upsert { + scope: scope.clone(), + fields: vec![PreviewField { + field: "title".into(), + value: PreviewExpression::Object { + fields: vec![PreviewObjectField { + name: "nested".into(), + value: PreviewExpression::List { + values: vec![ + preset("label"), + preset("actor"), + PreviewExpression::Constant { + value: ManifestProjectionValue::String("trusted_preset".into()), + }, + ], + }, + }], + }, + }], + replace: vec![], + }; + let mut names = BTreeSet::new(); + mutation.collect_trusted_presets(&mut names); + assert_eq!(names, ["actor", "label", "tenant"].map(String::from).into()); + names.clear(); + PreviewMutation::Delete { scope }.collect_trusted_presets(&mut names); + assert_eq!(names, ["actor", "tenant"].map(String::from).into()); + names.clear(); + PreviewMutation::InvalidateModel { + model: "Todo".into(), + partition: Some(PreviewPartition::Unit), + } + .collect_trusted_presets(&mut names); + assert!(names.is_empty()); + } + fn event() -> ManifestProjectionEventRef { ManifestProjectionEventRef { id: "event:test:v1".into(), diff --git a/distributed_cli/src/client_compiler/projection_delta/wire.rs b/distributed_cli/src/client_compiler/projection_delta/wire.rs index 63830091e..623b8a790 100644 --- a/distributed_cli/src/client_compiler/projection_delta/wire.rs +++ b/distributed_cli/src/client_compiler/projection_delta/wire.rs @@ -210,7 +210,9 @@ impl ProjectionDeltaIdentity { #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub(crate) enum ProjectionSurfaceIdentity { - Role { name: String }, + Role { + name: String, + }, Application { name: String, eligible_roles: Vec, diff --git a/distributed_cli/src/client_compiler/render/artifact.rs b/distributed_cli/src/client_compiler/render/artifact.rs index a2f56f84b..dea841df2 100644 --- a/distributed_cli/src/client_compiler/render/artifact.rs +++ b/distributed_cli/src/client_compiler/render/artifact.rs @@ -41,6 +41,8 @@ struct ArtifactProtocol<'a> { version: u32, #[serde(rename = "schemaHash")] schema_hash: &'a str, + #[serde(rename = "protocolHash")] + protocol_hash: &'a str, surface: &'a super::super::manifest::ManifestSurface, operation: &'a str, #[serde(rename = "trustedPresets")] @@ -256,6 +258,7 @@ pub(super) fn render_operation_artifact_json( protocol: ArtifactProtocol { version: 1, schema_hash: &manifest.schema_fingerprint, + protocol_hash: &manifest.protocol_fingerprint, surface: &manifest.surface, operation: &operation.query_hash, trusted_presets: &manifest.trusted_presets, diff --git a/distributed_cli/src/client_compiler/render/commands.rs b/distributed_cli/src/client_compiler/render/commands.rs index 1998b390c..2de3d690e 100644 --- a/distributed_cli/src/client_compiler/render/commands.rs +++ b/distributed_cli/src/client_compiler/render/commands.rs @@ -13,6 +13,54 @@ use super::common::quoted_property; const COMMAND_ARTIFACT_VERSION: u32 = 2; +pub(super) fn render_lazy_commands( + manifest: &ClientManifest, +) -> Result { + let catalog = manifest + .commands + .iter() + .map(|command| { + ( + command.name.clone(), + serde_json::json!({ + "operationHash": command.operation_hash, + "hasInput": !matches!(command.input, ManifestCommandShape::None), + }), + ) + }) + .collect::>(); + let catalog = serde_json::to_string_pretty(&catalog).map_err(|error| { + ClientCompileError::manifest("client.render.lazy_commands", error.to_string()) + })?; + let pures = if pure_function_inventory(manifest)?.is_empty() { + "import('./commands.js').then(({ COMMANDS }) => ({ entries: COMMANDS }))" + } else { + "Promise.all([import('./commands.js'), import('./pures.js')]).then(([{ COMMANDS }, { PURE_FUNCTIONS }]) => ({ entries: COMMANDS, pureFunctions: PURE_FUNCTIONS }))" + }; + Ok(format!( + r#"/** GENERATED by distributed client. Do not edit. */ + +import {{ createLazyReplicaCommandRuntime }} from '@hops-ops/distributed/replica/lazy'; +import type {{ DistributedReplica, ReplicaCommandTransport, ReplicaLazyCommandRuntime }} from '@hops-ops/distributed/replica'; +import type {{ COMMANDS, GeneratedCommandRuntimeOptions }} from './commands.js'; +import {{ COMMAND_STATUS }} from './protocol.js'; + +const COMMAND_CATALOG = {catalog} as const; + +/** Register authority now; import one shared runtime on preload or first command. */ +export function createLazyCommands( + replica: DistributedReplica, + transport: ReplicaCommandTransport, + options?: GeneratedCommandRuntimeOptions +): ReplicaLazyCommandRuntime {{ + return createLazyReplicaCommandRuntime(replica, transport, + {{ commands: COMMAND_CATALOG, status: COMMAND_STATUS }}, + () => {pures}, options); +}} +"# + )) +} + pub(super) fn render_commands(manifest: &ClientManifest) -> Result { validate_command_namespaces(&manifest.commands)?; let projectors = serde_json::to_string_pretty(&manifest.projectors).map_err(|error| { @@ -155,7 +203,9 @@ fn pure_function_inventory( } /// Generate `pures.ts` mapping pure fn ids to hosts / hand imports. -pub(super) fn render_pures(manifest: &ClientManifest) -> Result, ClientCompileError> { +pub(super) fn render_pures( + manifest: &ClientManifest, +) -> Result, ClientCompileError> { let inventory = pure_function_inventory(manifest)?; if inventory.is_empty() { return Ok(None); @@ -168,9 +218,7 @@ pub(super) fn render_pures(manifest: &ClientManifest) -> Result, .iter() .any(|(_, d)| matches!(d, PureDelivery::WasmPackage { .. })); if needs_wasm { - sections.push( - "import { createWasmJsonPure } from '@hops-ops/distributed/replica';".into(), - ); + sections.push("import { createWasmJsonPure } from '@hops-ops/distributed/replica';".into()); } let mut entries = Vec::new(); let mut ready_calls = Vec::new(); @@ -470,11 +518,26 @@ fn command_artifact_json( direct_projection_json(direct, manifest)?, ); } - if !command.extensions.trusted_presets.is_empty() { - artifact.insert( - "trustedPresets".into(), - serde_json::json!(command.extensions.trusted_presets), - ); + let mut referenced_presets = projection + .as_ref() + .map(CompiledCommandProjection::trusted_preset_names) + .unwrap_or_default(); + if let Some(ManifestEffectExpression::TrustedPreset { name }) = command + .extensions + .direct_projection + .as_ref() + .and_then(|direct| direct.partition.as_ref()) + { + referenced_presets.insert(name.clone()); + } + let trusted_presets = command + .extensions + .trusted_presets + .iter() + .filter(|descriptor| referenced_presets.contains(&descriptor.name)) + .collect::>(); + if !trusted_presets.is_empty() { + artifact.insert("trustedPresets".into(), serde_json::json!(trusted_presets)); } artifact.insert( "revalidation".into(), diff --git a/distributed_cli/src/client_compiler/render/project.rs b/distributed_cli/src/client_compiler/render/project.rs index 581bb009c..a070313e4 100644 --- a/distributed_cli/src/client_compiler/render/project.rs +++ b/distributed_cli/src/client_compiler/render/project.rs @@ -8,7 +8,7 @@ use super::super::manifest::{canonical_json_value, ClientManifest, ManifestSurfa use super::super::{ ClientCompileError, GeneratedClientFile, GeneratedClientProject, GeneratedOperationSummary, }; -use super::commands::render_commands; +use super::commands::{render_commands, render_lazy_commands}; use super::common::json_string; use super::operation::render_operation_module; @@ -40,6 +40,12 @@ pub(crate) fn render_project( path: "commands.ts".into(), contents: render_commands(manifest)?, }); + if !manifest.commands.is_empty() { + files.push(GeneratedClientFile { + path: "lazy-commands.ts".into(), + contents: render_lazy_commands(manifest)?, + }); + } let pures = super::commands::render_pures(manifest)?; let has_pures = pures.is_some(); if let Some(pures) = pures { @@ -345,6 +351,8 @@ fn render_sveltekit( ]); if !manifest.commands.is_empty() { value_exports.insert("createCommands".into()); + value_exports.insert("createLazyCommands".into()); + value_exports.insert("provideDistributedLazy".into()); } for command in &manifest.commands { value_exports.insert(format!("Command_{}", command.mutation_field)); @@ -405,6 +413,8 @@ fn render_sveltekit( "} from './commands.js';", "", "export type { GeneratedCommands } from './commands.js';", + "import { createLazyCommands } from './lazy-commands.js';", + "export { createLazyCommands } from './lazy-commands.js';", ] .join("\n"), ); @@ -483,6 +493,18 @@ fn render_sveltekit( .map(str::to_string), ); sections.push(bindings.join("\n")); + if !manifest.commands.is_empty() { + sections.push(format!(r#"/** Opt-in deferred commands; query, SSR and boundary loading stay unchanged. */ +export function provideDistributedLazy( + options: Omit, 'createCommands' | 'reload'> & {{ reload?: Omit }} +): DistributedSvelteKitClient {{ + return provideDistributedSvelteKitClient(createDistributedSvelteKit({{ + ...options, + createCommands: createLazyCommands, + reload: {{ ...options.reload, key: {reload_key} }} + }})); +}}"#)); + } Ok(format!("{}\n", sections.join("\n\n"))) } diff --git a/distributed_cli/src/client_compiler/runtime_bridge_tests.rs b/distributed_cli/src/client_compiler/runtime_bridge_tests.rs index d6055ec48..29ad43386 100644 --- a/distributed_cli/src/client_compiler/runtime_bridge_tests.rs +++ b/distributed_cli/src/client_compiler/runtime_bridge_tests.rs @@ -4,6 +4,58 @@ use super::render::{render_operation_artifact_json, render_operation_module}; use super::tests::manifest; use super::{ClientDocument, ClientSurfaceSelector}; +#[test] +fn unique_key_runtime_bridge_artifact_is_byte_exact() { + let mut value = manifest(); + value["models"][0]["fields"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "name": "ownerTitle", "scalar": "String", "codec": "string", "nullable": true + })); + value["models"][0]["filter_input"]["fields"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "name": "ownerTitle", "operators": ["_eq"] + })); + for root in value["roots"].as_array_mut().unwrap() { + if let Some(fields) = root + .get_mut("filter") + .and_then(|filter| filter.get_mut("fields")) + .and_then(|fields| fields.as_array_mut()) + { + fields.push(serde_json::json!({"name": "ownerTitle", "operators": ["_eq"]})); + } + if let Some(fields) = root + .get_mut("order") + .and_then(|order| order.get_mut("fields")) + .and_then(|fields| fields.as_array_mut()) + { + fields.push(serde_json::json!("ownerTitle")); + } + } + value["models"][0]["relationships"][0]["key_mapping"] = serde_json::json!({ + "kind": "direct", "local": ["tenantId", "ownerTitle"], "remote": ["tenantId", "title"] + }); + value["models"][0]["relationships"][0]["maintenance"] = serde_json::json!("local"); + super::manifest::refresh_schema_fingerprint(&mut value); + let manifest = ClientManifest::parse(value, &ClientSurfaceSelector::role("user")).unwrap(); + let document = ClientDocument::new( + "src/routes/unique-key/+page.graphql", + "query UniqueKeyBridge @load @live { todos(limit: 25) { id title owner { id title } } }", + ); + let operation = compile_document(&document, &manifest).unwrap(); + let artifact = format!( + "{}\n", + render_operation_artifact_json(&operation, &manifest).unwrap() + ); + assert_eq!( + artifact, + include_str!("../../tests/fixtures/unique-key-bridge-operation.json") + ); +} + const RUNTIME_BRIDGE_QUERY: &str = r#" query RustRuntimeBridge($id: ID!, $tenantId: ID!) { todo(id: $id, tenantId: $tenantId) { diff --git a/distributed_cli/src/client_compiler/tests.rs b/distributed_cli/src/client_compiler/tests.rs index 90d1f0ae0..52dd91697 100644 --- a/distributed_cli/src/client_compiler/tests.rs +++ b/distributed_cli/src/client_compiler/tests.rs @@ -2,9 +2,7 @@ use serde_json::{json, Value as JsonValue}; use sha2::{Digest, Sha256}; use super::manifest::{refresh_schema_fingerprint, ClientManifest}; -use super::{ - compile_client, ClientCompileInput, ClientDocument, ClientSurfaceSelector, -}; +use super::{compile_client, ClientCompileInput, ClientDocument, ClientSurfaceSelector}; fn fingerprint(label: &str) -> String { let digest = Sha256::digest(label.as_bytes()); @@ -1183,22 +1181,20 @@ fn component_load_compiles_to_a_framework_neutral_island_inventory() { #[test] fn multiple_load_islands_may_share_one_future_boundary() { - let project = compile_client( - ClientCompileInput::new( - manifest(), - ClientSurfaceSelector::role("user"), - vec![ - ClientDocument::new( - "src/lib/todos/TodoCount.graphql", - "query TodoCount @load { todos { id } }", - ), - ClientDocument::new( - "src/lib/todos/TodoTitles.graphql", - "query TodoTitles @load { todos { title } }", - ), - ], - ), - ) + let project = compile_client(ClientCompileInput::new( + manifest(), + ClientSurfaceSelector::role("user"), + vec![ + ClientDocument::new( + "src/lib/todos/TodoCount.graphql", + "query TodoCount @load { todos { id } }", + ), + ClientDocument::new( + "src/lib/todos/TodoTitles.graphql", + "query TodoTitles @load { todos { title } }", + ), + ], + )) .expect("route ownership is no longer one-operation-per-route"); assert_eq!(project.islands.len(), 2); @@ -3110,6 +3106,62 @@ fn command_protocol_and_extensions_are_preserved_exactly() { ["values"][2]["source"] = json!({"kind": "trusted_preset", "name": "priority", "codec": "int32"}); refresh_schema_fingerprint(&mut preset_i64_value); + // An unknown record identity drops the mutation, including its preset + // expression. The generated inventory must follow the compiled result, + // while the surface retains the complete trusted context contract. + let mut recovery_preset = preset_i64_value.clone(); + recovery_preset["commands"][0]["extensions"]["projection"]["preview_occurrences"][0] + ["values"][1]["source"] = json!({"kind": "unknown"}); + refresh_schema_fingerprint(&mut recovery_preset); + for (fixture, retained) in [(preset_i64_value.clone(), true), (recovery_preset, false)] { + let compiled = compile_client(input_with_manifest(fixture, "query Todos { todos { id } }")) + .expect("compile preset inventory after preview lowering"); + let source = file(&compiled, "commands.ts"); + let declaration = source + .split("export const Command_createTodo:") + .nth(1) + .unwrap(); + let body = declaration + .split_once(" = ") + .unwrap() + .1 + .split_once("\n};") + .unwrap() + .0; + let artifact: JsonValue = serde_json::from_str(&format!("{body}\n}}")).unwrap(); + assert_eq!( + artifact["protocol"]["trustedPresets"], + json!([{"name": "priority", "codec": "int32"}]) + ); + if retained { + assert_eq!( + artifact["trustedPresets"], + json!([{"name": "priority", "codec": "int32"}]) + ); + assert!(!artifact["projection"]["preview"]["operations"] + .as_array() + .unwrap() + .is_empty()); + } else { + let fixture: JsonValue = serde_json::from_str(include_str!( + "../../tests/fixtures/generated-recovery-preset-command.json" + )) + .unwrap(); + assert_eq!( + artifact, fixture, + "JS bridge fixture must match compiler output" + ); + assert!(artifact.get("trustedPresets").is_none()); + assert!(artifact["projection"]["preview"]["operations"] + .as_array() + .unwrap() + .is_empty()); + assert!(!artifact["projection"]["preview"]["recoveries"] + .as_array() + .unwrap() + .is_empty()); + } + } let mut invalid_u64_source = input_i64_value.clone(); invalid_u64_source["projection_programs"][0]["arms"][0]["operations"][0]["fields"][1] ["assignment"]["expression"]["value_type"] = json!({"type": "u64"}); @@ -4079,3 +4131,29 @@ fn source_paths_fail_closed_before_they_can_inject_generated_typescript() { assert_eq!(error.code, "client.documents.invalid_path"); assert!(!error.message.contains("export const compromised")); } + +#[test] +fn generated_lazy_commands_defer_definitions_and_preserve_command_signatures() { + let project = compile_client(ClientCompileInput::new( + generated_command_types_manifest(), + ClientSurfaceSelector::role("user"), + vec![ClientDocument::new( + "todos.graphql", + "query Todos { todos { id } }", + )], + )) + .expect("compile lazy commands"); + let lazy = file(&project, "lazy-commands.ts"); + assert!(lazy.contains("import type { COMMANDS, GeneratedCommandRuntimeOptions }")); + assert!(lazy.contains("import('./commands.js')")); + assert!(lazy.contains("\"hasInput\": false")); + assert!(lazy.contains("\"hasInput\": true")); + let wrapper = file(&project, "sveltekit.ts"); + assert!(wrapper.contains("export function provideDistributedLazy(")); + assert!(wrapper.contains("createCommands: createLazyCommands")); + assert!(wrapper.contains("createCommands: createGeneratedCommands")); + assert_eq!( + lazy.replace("'./commands.js'", "'./generated-commands.js'"), + include_str!("../../tests/fixtures/generated-lazy-commands.ts") + ); +} diff --git a/distributed_cli/src/contracts/classification.rs b/distributed_cli/src/contracts/classification.rs index a07661381..6ef321512 100644 --- a/distributed_cli/src/contracts/classification.rs +++ b/distributed_cli/src/contracts/classification.rs @@ -111,10 +111,11 @@ pub fn decisions_are_distinct(changes: &[ClassifiedChange]) -> bool { // Distinctness is only meaningful when both families appear. let has_wire = seen.contains(&LifecycleDecision::AcceptManifestWire); let has_protocol = seen.contains(&LifecycleDecision::AcceptProtocolSemantic); - !(has_wire && has_protocol && changes.iter().any(|c| { - c.decision == LifecycleDecision::AcceptManifestWire - && c.path.contains("protocol") - })) + !(has_wire + && has_protocol + && changes.iter().any(|c| { + c.decision == LifecycleDecision::AcceptManifestWire && c.path.contains("protocol") + })) } #[cfg(test)] diff --git a/distributed_cli/src/contracts/closeout.rs b/distributed_cli/src/contracts/closeout.rs index 22cf7ed1d..1297983de 100644 --- a/distributed_cli/src/contracts/closeout.rs +++ b/distributed_cli/src/contracts/closeout.rs @@ -70,10 +70,7 @@ mod tests { #[test] fn local_chain_closeout_accepts_matching_predecessors() { - let app = ArtifactIdentity::new( - ContractArtifactKind::ApplicationManifest, - "sha256:app", - ); + let app = ArtifactIdentity::new(ContractArtifactKind::ApplicationManifest, "sha256:app"); let plan = ArtifactIdentity::new(ContractArtifactKind::DeploymentPlan, "sha256:plan"); let result = close_local_contract_chain(&app, &plan, true, None); assert!(result.diagnostics.is_empty()); @@ -90,10 +87,7 @@ mod tests { )); fs::create_dir_all(&root).unwrap(); fs::write(root.join("a.js"), b"1").unwrap(); - let app = ArtifactIdentity::new( - ContractArtifactKind::ApplicationManifest, - "sha256:app", - ); + let app = ArtifactIdentity::new(ContractArtifactKind::ApplicationManifest, "sha256:app"); let plan = ArtifactIdentity::new(ContractArtifactKind::DeploymentPlan, "sha256:plan"); let program = ClientProgramDescriptor::builder("e2e-ui") .surface(ClientProgramSurface { diff --git a/distributed_cli/src/contracts/mod.rs b/distributed_cli/src/contracts/mod.rs index 68f3fe2c3..201165ff7 100644 --- a/distributed_cli/src/contracts/mod.rs +++ b/distributed_cli/src/contracts/mod.rs @@ -8,8 +8,8 @@ mod artifact; mod catalog; mod chain; -mod closeout; mod classification; +mod closeout; mod diagnostic; mod migrations; mod program; @@ -31,10 +31,10 @@ pub use catalog::{ MAX_CATALOG_GLOB_MATCHES, MAX_CATALOG_JSON_DEPTH, }; pub use chain::{check_predecessor_chain, ObservedPredecessor}; -pub use closeout::{classify_release_programs, close_local_contract_chain}; pub use classification::{ classify_snapshot_diff, decisions_are_distinct, ClassifiedChange, LifecycleDecision, }; +pub use closeout::{classify_release_programs, close_local_contract_chain}; pub use diagnostic::{ ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, SafeDiagnosticValue, }; diff --git a/distributed_cli/src/contracts/program.rs b/distributed_cli/src/contracts/program.rs index 0b9da7d20..d6a717676 100644 --- a/distributed_cli/src/contracts/program.rs +++ b/distributed_cli/src/contracts/program.rs @@ -118,7 +118,9 @@ impl ClientProgramDescriptor { return Err("program must declare at least one surface".into()); } if self.assets.len() > MAX_PROGRAM_ASSETS { - return Err(format!("program exceeds max asset count {MAX_PROGRAM_ASSETS}")); + return Err(format!( + "program exceeds max asset count {MAX_PROGRAM_ASSETS}" + )); } let mut paths = BTreeSet::new(); for asset in &self.assets { @@ -291,7 +293,10 @@ fn contract_set_id( ); } for artifact in artifacts { - material.insert(format!("artifact:{}", artifact.path), artifact.digest.clone()); + material.insert( + format!("artifact:{}", artifact.path), + artifact.digest.clone(), + ); } let bytes = serde_json::to_vec(&material).unwrap_or_default(); identity_digest("distributed.client-program.contract-set.v1", &bytes) @@ -319,7 +324,10 @@ fn program_id( fn collect_assets(root: &Path) -> Result, String> { if !root.is_dir() { - return Err(format!("asset root `{}` is not a directory", root.display())); + return Err(format!( + "asset root `{}` is not a directory", + root.display() + )); } let root = root.canonicalize().map_err(|e| e.to_string())?; let mut assets = Vec::new(); @@ -439,7 +447,10 @@ mod tests { fs::write(root.join("app.js"), b"console.log(1)").unwrap(); let first = base_builder(&root).build().unwrap(); let second = base_builder(&root).build().unwrap(); - assert_eq!(first.canonical_bytes().unwrap(), second.canonical_bytes().unwrap()); + assert_eq!( + first.canonical_bytes().unwrap(), + second.canonical_bytes().unwrap() + ); assert_eq!( first.classify_against(&second).unwrap(), ProgramCompatibility::Current diff --git a/distributed_cli/src/contracts/snapshots.rs b/distributed_cli/src/contracts/snapshots.rs index 6c387e620..94a6a1d1e 100644 --- a/distributed_cli/src/contracts/snapshots.rs +++ b/distributed_cli/src/contracts/snapshots.rs @@ -161,10 +161,7 @@ fn flatten_value( Value::String(text) if text.len() > MAX_SNAPSHOT_VALUE_BYTES => { out.insert( prefix.to_string(), - Value::String(format!( - "", - text.len() - )), + Value::String(format!("", text.len())), ); } other => { @@ -243,10 +240,7 @@ mod tests { .unwrap(); let changes = diff_snapshots(&left, &drifted).changes; assert_eq!(changes.len(), 1); - assert_eq!( - changes[0].path, - "models.TodoView.fields.title.nullable" - ); + assert_eq!(changes[0].path, "models.TodoView.fields.title.nullable"); assert_eq!(changes[0].before, Some(json!(false))); assert_eq!(changes[0].after, Some(json!(true))); } diff --git a/distributed_cli/src/contracts/tests.rs b/distributed_cli/src/contracts/tests.rs index 9c2bb4538..11b4bb372 100644 --- a/distributed_cli/src/contracts/tests.rs +++ b/distributed_cli/src/contracts/tests.rs @@ -1259,7 +1259,7 @@ fn migration_inventory_is_deterministic_and_preserves_runtime_order() { .iter() .map(|migration| migration.version) .collect::>(); - assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!(versions, vec![1, 2, 3, 4, 5, 6]); assert_eq!( inventory.canonical_bytes().expect("canonical inventory"), inventory diff --git a/distributed_cli/src/contracts/transaction.rs b/distributed_cli/src/contracts/transaction.rs index cdd6b6643..4bd354285 100644 --- a/distributed_cli/src/contracts/transaction.rs +++ b/distributed_cli/src/contracts/transaction.rs @@ -5,9 +5,7 @@ use super::catalog::ContractCatalog; use super::chain::{check_predecessor_chain, ObservedPredecessor}; -use super::diagnostic::{ - ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, -}; +use super::diagnostic::{ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode}; use super::ContractArtifactKind; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -25,7 +23,9 @@ pub enum ContractAcceptScope { ApplicationManifest, DeploymentPlan, /// Exact client-program scope: `program:`. - Program { id: String }, + Program { + id: String, + }, } impl ContractAcceptScope { @@ -190,10 +190,7 @@ pub fn contracts_accept( }) } -fn rollback( - root: &Path, - prior: &BTreeMap>>, -) -> Result<(), String> { +fn rollback(root: &Path, prior: &BTreeMap>>) -> Result<(), String> { for (relative, contents) in prior { let path = resolve_under_root(root, relative)?; match contents { @@ -224,9 +221,7 @@ fn resolve_under_root(root: &Path, relative: &str) -> Result { "accept path `{relative}` escapes or is not a portable relative path" )); } - let root = root - .canonicalize() - .unwrap_or_else(|_| root.to_path_buf()); + let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let candidate = root.join(relative); if let Ok(canonical) = candidate.canonicalize() { if !canonical.starts_with(&root) { diff --git a/distributed_cli/src/generate/service_crate.rs b/distributed_cli/src/generate/service_crate.rs index 77521fe5b..759fbcd1b 100644 --- a/distributed_cli/src/generate/service_crate.rs +++ b/distributed_cli/src/generate/service_crate.rs @@ -213,7 +213,7 @@ async fn main() -> Result<(), {error_type}> {{ .map(|model| format!(" .read_model::<{}>()\n", model.view_ident)) .collect::(); format!( -r#"use distributed::{{ + r#"use distributed::{{ Application, ApplicationManifest, ReadModelCatalog, SurfaceSpec, }}; @@ -519,13 +519,13 @@ impl CommandAggregate { }; let command_types = if self.query_api { - r#"#[derive(Clone, Debug, Deserialize, distributed::GraphqlInput)] + r#"#[derive(Clone, Debug, Deserialize, distributed::CommandInput)] pub struct CommandInput { pub id: String, pub name: Option, } -#[derive(Clone, Debug, Serialize, distributed::GraphqlOutput)] +#[derive(Clone, Debug, Serialize, distributed::CommandOutput)] pub struct CommandOutput { pub command: String, pub id: String, @@ -597,7 +597,7 @@ impl {model_struct} {{ .map(|model| (model.type_ident.as_str(), model.name.as_str())) .unwrap_or(("CommandAggregate", "CommandAggregate")); return format!( - r#"use distributed::graphql::{{typed_command, Succeeded, PreparedCommand, TypedCommand}}; + r#"use distributed::command::{{typed_command, Succeeded, PreparedCommand, TypedCommand}}; use distributed::microsvc::{{CausalCommandContext, HandlerError}}; use crate::models::{{CommandInput, CommandOutput, {model_type}}}; diff --git a/distributed_cli/src/js_framework.rs b/distributed_cli/src/js_framework.rs index 117602a96..b4c001833 100644 --- a/distributed_cli/src/js_framework.rs +++ b/distributed_cli/src/js_framework.rs @@ -182,9 +182,7 @@ impl JavascriptFrameworkPackage { pub(crate) fn wasm_pack_launcher(&self, ui_root: &Path) -> PathBuf { match &self.source { - JavascriptPackageSource::Local { root } => { - root.join("node_modules/wasm-pack/run.js") - } + JavascriptPackageSource::Local { root } => root.join("node_modules/wasm-pack/run.js"), JavascriptPackageSource::Registry => ui_root.join("node_modules/wasm-pack/run.js"), } } diff --git a/distributed_cli/src/lib.rs b/distributed_cli/src/lib.rs index 32c899050..3fde9d3df 100644 --- a/distributed_cli/src/lib.rs +++ b/distributed_cli/src/lib.rs @@ -33,31 +33,32 @@ pub use client_compiler::{ }; pub use contracts::{ check_migration_history, check_migration_inventory, check_predecessor_chain, - classify_release_programs, classify_snapshot_diff, close_local_contract_chain, contracts_accept, - contracts_check, diff_snapshots, snapshot_from_json, ArtifactIdentity, ArtifactPredecessor, - ArtifactProvenance, BaselineAvailability, ClassifiedChange, ClientDeclaration, ClientInventory, - ClientProgramArtifact, ClientProgramAsset, ClientProgramDescriptor, ClientProgramSurface, - ContractAcceptScope, ContractArtifactKind, ContractCatalog, ContractCheckResult, - ContractDiagnostic, ContractDiagnosticCode, ContractEntry, ContractError, ContractScope, - ContractsAcceptReport, ContractsCheckReport, EnvironmentPolicyReference, LifecycleDecision, - MigrationDialect, MigrationEntry, MigrationFile, MigrationHistoryCheck, MigrationInventory, - ObservedPredecessor, ProgramCompatibility, SafeDiagnosticValue, - SemanticSnapshot, SnapshotChange, SnapshotDiff, SnapshotEntry, MAX_CATALOG_BYTES, - MAX_CATALOG_DIRECTORIES, MAX_CATALOG_DIRECTORY_DEPTH, MAX_CATALOG_DIRECTORY_ENTRIES, - MAX_CATALOG_ENTRIES, MAX_CATALOG_FILES, MAX_CATALOG_GLOB_MATCHES, MAX_CATALOG_JSON_DEPTH, - MAX_MIGRATIONS, MAX_MIGRATION_INVENTORY_BYTES, MAX_MIGRATION_SQL_BYTES, MAX_SNAPSHOT_DEPTH, - MAX_SNAPSHOT_PATHS, MAX_SNAPSHOT_VALUE_BYTES, MIGRATION_INVENTORY_PATH, - MIGRATION_INVENTORY_SCHEMA_VERSION, MIGRATION_OWNER, MIGRATION_SCOPE, + classify_release_programs, classify_snapshot_diff, close_local_contract_chain, + contracts_accept, contracts_check, diff_snapshots, snapshot_from_json, ArtifactIdentity, + ArtifactPredecessor, ArtifactProvenance, BaselineAvailability, ClassifiedChange, + ClientDeclaration, ClientInventory, ClientProgramArtifact, ClientProgramAsset, + ClientProgramDescriptor, ClientProgramSurface, ContractAcceptScope, ContractArtifactKind, + ContractCatalog, ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, + ContractEntry, ContractError, ContractScope, ContractsAcceptReport, ContractsCheckReport, + EnvironmentPolicyReference, LifecycleDecision, MigrationDialect, MigrationEntry, MigrationFile, + MigrationHistoryCheck, MigrationInventory, ObservedPredecessor, ProgramCompatibility, + SafeDiagnosticValue, SemanticSnapshot, SnapshotChange, SnapshotDiff, SnapshotEntry, + MAX_CATALOG_BYTES, MAX_CATALOG_DIRECTORIES, MAX_CATALOG_DIRECTORY_DEPTH, + MAX_CATALOG_DIRECTORY_ENTRIES, MAX_CATALOG_ENTRIES, MAX_CATALOG_FILES, + MAX_CATALOG_GLOB_MATCHES, MAX_CATALOG_JSON_DEPTH, MAX_MIGRATIONS, + MAX_MIGRATION_INVENTORY_BYTES, MAX_MIGRATION_SQL_BYTES, MAX_SNAPSHOT_DEPTH, MAX_SNAPSHOT_PATHS, + MAX_SNAPSHOT_VALUE_BYTES, MIGRATION_INVENTORY_PATH, MIGRATION_INVENTORY_SCHEMA_VERSION, + MIGRATION_OWNER, MIGRATION_SCOPE, }; pub use generate::{generate_service_scaffold, package_name}; pub use lifecycle::{ - run_lifecycle_build, run_lifecycle_dev, ArtifactNodeReceipt, BuildDrift, DistributedSourceIdentity, - GenerationManifest, LifecycleBuildConfig, LifecycleBuildOptions, LifecycleBuildReport, - LifecycleConfig, LifecycleDevConfig, LifecycleDevOptions, LifecycleDevProbe, - LifecycleDevProcess, LifecycleDevReport, LifecycleError, LifecycleExecutor, LifecycleGraph, LifecycleNode, - ReleaseManifest, ReleaseMember, GENERATION_MANIFEST_SCHEMA_VERSION, - LIFECYCLE_BUILD_CONFIG_SCHEMA_VERSION, LIFECYCLE_CONFIG_SCHEMA_VERSION, - LIFECYCLE_GRAPH_SCHEMA_VERSION, NODE_RECEIPT_SCHEMA_VERSION, + run_lifecycle_build, run_lifecycle_dev, ArtifactNodeReceipt, BuildDrift, + DistributedSourceIdentity, GenerationManifest, LifecycleBuildConfig, LifecycleBuildOptions, + LifecycleBuildReport, LifecycleConfig, LifecycleDevConfig, LifecycleDevOptions, + LifecycleDevProbe, LifecycleDevProcess, LifecycleDevReport, LifecycleError, LifecycleExecutor, + LifecycleGraph, LifecycleNode, ReleaseManifest, ReleaseMember, + GENERATION_MANIFEST_SCHEMA_VERSION, LIFECYCLE_BUILD_CONFIG_SCHEMA_VERSION, + LIFECYCLE_CONFIG_SCHEMA_VERSION, LIFECYCLE_GRAPH_SCHEMA_VERSION, NODE_RECEIPT_SCHEMA_VERSION, RELEASE_MANIFEST_SCHEMA_VERSION, }; pub use skills::{embedded_skills, generate_skills, EmbeddedFile, EmbeddedSkill, SkillsInitSpec}; diff --git a/distributed_cli/src/lifecycle/build.rs b/distributed_cli/src/lifecycle/build.rs index c8dcdb66f..2779de55f 100644 --- a/distributed_cli/src/lifecycle/build.rs +++ b/distributed_cli/src/lifecycle/build.rs @@ -424,9 +424,7 @@ pub fn run_lifecycle_project_build( let drift = if request.check { match request.check_baseline { LifecycleCheckBaseline::Workspace => compare_workspace_outputs(&root, &generation)?, - LifecycleCheckBaseline::ActiveGeneration => { - compare_active_outputs(&out, &generation)? - } + LifecycleCheckBaseline::ActiveGeneration => compare_active_outputs(&out, &generation)?, } } else { if request @@ -487,7 +485,9 @@ fn compare_active_outputs( generation: &GenerationManifest, ) -> Result, LifecycleError> { let active = read_active_generation(out)?; - let active_root = active.as_ref().map(|identity| out.join("generations").join(identity)); + let active_root = active + .as_ref() + .map(|identity| out.join("generations").join(identity)); let mut drift = Vec::new(); for receipt in generation.receipts.values() { for (output, built_identity) in &receipt.output_identities { @@ -675,10 +675,7 @@ fn execute_node( .env("DISTRIBUTED_LIFECYCLE_ROOT", root) .env("DISTRIBUTED_LIFECYCLE_STAGE", stage) .env("DISTRIBUTED_LIFECYCLE_NODE", node_id) - .env( - "DISTRIBUTED_LIFECYCLE_CHECK", - if check { "1" } else { "0" }, - ) + .env("DISTRIBUTED_LIFECYCLE_CHECK", if check { "1" } else { "0" }) .stdout(stdout) .stderr(Stdio::piped()); let mut child = command.spawn().map_err(|error| { diff --git a/distributed_cli/src/lifecycle/dev.rs b/distributed_cli/src/lifecycle/dev.rs index 25ecedaed..068a8212a 100644 --- a/distributed_cli/src/lifecycle/dev.rs +++ b/distributed_cli/src/lifecycle/dev.rs @@ -285,13 +285,16 @@ pub fn run_lifecycle_project_dev( validate_restart_nodes(&dev, &graph)?; // Initial coherent generation is an absolute serving barrier. + // Observe before building/starting children: edits during startup must not + // become a new baseline without ever being built. + let mut snapshot = lifecycle_input_snapshot(&root, catalog, &graph)?; let mut initial_options = options.build.clone(); initial_options.nodes = None; initial_options.activation_inputs = None; initial_options.cancel = Some(Arc::clone(&options.stop)); let initial = run_lifecycle_project_build(&options.project, &initial_options)?; let state = DevStateStore::new(&root, &options.project.out)?; - state.write_active(&initial)?; + state.write_active(&initial, &BTreeMap::new())?; for process in dev.processes.values_mut() { process.env.insert( "DISTRIBUTED_LIFECYCLE_DIR".to_string(), @@ -299,6 +302,10 @@ pub fn run_lifecycle_project_dev( ); } let mut children = ChildSet::start(&root, &dev, &initial, &options.stop)?; + if let Err(error) = state.write_active(&initial, &children.members) { + let _ = children.shutdown(Duration::from_millis(dev.shutdown_ms)); + return Err(error); + } if options.progress { for (name, process) in &dev.processes { if let Some(url) = &process.url { @@ -311,7 +318,6 @@ pub fn run_lifecycle_project_dev( dev.processes.keys().cloned().collect::>().join(",") ); } - let mut snapshot = lifecycle_input_snapshot(&root, catalog, &graph)?; let mut final_generation = initial.generation_id.clone(); let mut active = initial.clone(); let mut rebuilds = 0; @@ -416,13 +422,18 @@ pub fn run_lifecycle_project_dev( // acknowledgements to this attempt so a persisted response from a // rejected/superseded attempt can never authorize the next one. let transition = state.begin_transition()?; - state.write_preparing(&active, &generation, transition.id(), dev.prepare_ms)?; + state.write_preparing( + &active, + &generation, + transition.id(), + dev.prepare_ms, + &children.members, + )?; if let Err(error) = state.wait_for_prepare( transition.id(), Duration::from_millis(dev.prepare_ms), &options.stop, ) { - state.write_active(&active)?; if options.stop.load(Ordering::SeqCst) && error.reason() == LifecycleErrorReason::Canceled { @@ -433,6 +444,7 @@ pub fn run_lifecycle_project_dev( "pending generation preparation failed: {error}; prior generation is no longer serving: {serving}" )) })?; + state.write_active(&active, &children.members)?; if options.progress { eprintln!( "lifecycle dev: rejected generation={} during preparation; prior generation remains active: {}", @@ -460,15 +472,14 @@ pub fn run_lifecycle_project_dev( if options.stop.load(Ordering::SeqCst) && error.reason() == LifecycleErrorReason::Canceled { - state.write_active(&active)?; return Ok(()); } - state.write_active(&active)?; children.ensure_running().map_err(|serving| { LifecycleError::new(format!( "pending generation readiness failed: {error}; prior generation rollback is not serving: {serving}" )) - })?; + })?; + state.write_active(&active, &children.members)?; if options.progress { eprintln!( "lifecycle dev: rejected generation={} during readiness; prior generation remains active: {}", @@ -493,7 +504,8 @@ pub fn run_lifecycle_project_dev( ); return match rollback { Ok(()) => { - state.write_active(&active)?; + children.ensure_running()?; + state.write_active(&active, &children.members)?; if options.progress { eprintln!( "lifecycle dev: rejected generation={} during activation; prior generation remains active: {}", @@ -510,7 +522,8 @@ pub fn run_lifecycle_project_dev( } final_generation = generation.generation_id.clone(); active = generation.clone(); - state.write_active(&active)?; + children.ensure_running()?; + state.write_active(&active, &children.members)?; if options.progress { eprintln!( "lifecycle dev: activated generation={} invalidated={} restarted={}", @@ -528,8 +541,10 @@ pub fn run_lifecycle_project_dev( Ok(()) })(); + let retired = state.write_active(&active, &BTreeMap::new()); let shutdown = children.shutdown(Duration::from_millis(dev.shutdown_ms)); result?; + retired?; shutdown?; Ok(LifecycleDevReport { initial_generation: initial.generation_id, @@ -554,6 +569,7 @@ struct DevLifecycleState<'a> { schema_version: u32, phase: &'static str, active: DevGenerationState<'a>, + members: &'a BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] pending: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -580,11 +596,16 @@ impl DevStateStore { }) } - fn write_active(&self, active: &LifecycleBuildReport) -> Result<(), LifecycleError> { + fn write_active( + &self, + active: &LifecycleBuildReport, + members: &BTreeMap, + ) -> Result<(), LifecycleError> { self.write(&DevLifecycleState { schema_version: 1, phase: "active", active: generation_state(active), + members, pending: None, transition_id: None, deadline_unix_ms: None, @@ -617,12 +638,14 @@ impl DevStateStore { pending: &LifecycleBuildReport, transition_id: &str, prepare_ms: u64, + members: &BTreeMap, ) -> Result<(), LifecycleError> { let deadline = unix_ms()?.saturating_add(prepare_ms); self.write(&DevLifecycleState { schema_version: 1, phase: "preparing", active: generation_state(active), + members, pending: Some(generation_state(pending)), transition_id: Some(transition_id), deadline_unix_ms: Some(deadline), @@ -865,8 +888,36 @@ fn changed_paths( .collect() } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DevMemberState { + instance_id: String, + generation_id: String, +} + +impl DevMemberState { + fn new(generation: &LifecycleBuildReport) -> Result { + let identity = tempfile::Builder::new() + .prefix("member-") + .rand_bytes(16) + .tempfile() + .map_err(dev_io("allocate lifecycle process identity"))?; + let instance_id = identity + .path() + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| LifecycleError::new("invalid lifecycle process identity"))? + .to_owned(); + Ok(Self { + instance_id, + generation_id: generation.generation_id.clone(), + }) + } +} + struct ChildSet { children: BTreeMap, + members: BTreeMap, } impl ChildSet { @@ -878,11 +929,17 @@ impl ChildSet { ) -> Result { let mut set = Self { children: BTreeMap::new(), + members: BTreeMap::new(), }; for (name, process) in &config.processes { - match spawn_process(root, name, process, generation) { - Ok(child) => { + let spawned = DevMemberState::new(generation).and_then(|member| { + spawn_process(root, name, process, generation, &member.instance_id) + .map(|child| (child, member)) + }); + match spawned { + Ok((child, member)) => { set.children.insert(name.clone(), child); + set.members.insert(name.clone(), member); } Err(error) => { let _ = set.shutdown(Duration::from_millis(config.shutdown_ms)); @@ -907,6 +964,12 @@ impl ChildSet { } fn ensure_running(&mut self) -> Result<(), LifecycleError> { + if self.children.len() != self.members.len() || self.children.keys().ne(self.members.keys()) + { + return Err(LifecycleError::new( + "lifecycle process cohort is incomplete", + )); + } for (name, child) in &mut self.children { if let Some(status) = child.try_wait().map_err(|error| { LifecycleError::new(format!("failed to inspect dev process `{name}`: {error}")) @@ -946,20 +1009,27 @@ impl ChildSet { self.children.insert(name.clone(), previous_child); return Err(error); } - let replacement = - spawn_process(root, name, process, generation).and_then(|mut child| { - if let Err(error) = - wait_ready(root, name, process, generation, &mut child, stop) - { - let _ = - stop_child(name, &mut child, Duration::from_millis(config.shutdown_ms)); - return Err(error); - } - Ok(child) - }); + let replacement = DevMemberState::new(generation).and_then(|member| { + spawn_process(root, name, process, generation, &member.instance_id).and_then( + |mut child| { + if let Err(error) = + wait_ready(root, name, process, generation, &mut child, stop) + { + let _ = stop_child( + name, + &mut child, + Duration::from_millis(config.shutdown_ms), + ); + return Err(error); + } + Ok((child, member)) + }, + ) + }); match replacement { - Ok(child) => { + Ok((child, member)) => { self.children.insert(name.clone(), child); + self.members.insert(name.clone(), member); replaced.insert(name.clone()); } Err(error) => { @@ -1008,21 +1078,21 @@ impl ChildSet { for name in names { let process = &config.processes[name]; if let Some(mut child) = self.children.remove(name) { - if let Err(error) = stop_child( - name, - &mut child, - Duration::from_millis(config.shutdown_ms), - ) { + if let Err(error) = + stop_child(name, &mut child, Duration::from_millis(config.shutdown_ms)) + { self.children.insert(name.clone(), child); return Err(error); } } - let mut child = spawn_process(root, name, process, replacement)?; + let member = DevMemberState::new(replacement)?; + let mut child = spawn_process(root, name, process, replacement, &member.instance_id)?; if let Err(error) = wait_ready(root, name, process, replacement, &mut child, stop) { let _ = stop_child(name, &mut child, Duration::from_millis(config.shutdown_ms)); return Err(error); } self.children.insert(name.clone(), child); + self.members.insert(name.clone(), member); } Ok(()) } @@ -1187,6 +1257,7 @@ fn spawn_process( name: &str, process: &LifecycleDevProcess, generation: &LifecycleBuildReport, + instance_id: &str, ) -> Result { let cwd = resolve_working_dir(root, process.cwd.as_deref(), process.external_cwd)?; let args = process @@ -1205,6 +1276,7 @@ fn spawn_process( .env("DISTRIBUTED_TOPOLOGY_ID", &generation.graph_id) .env("DISTRIBUTED_COMPATIBILITY_ID", &generation.compatibility_id) .env("DISTRIBUTED_MEMBER_ID", name) + .env("DISTRIBUTED_PROCESS_INSTANCE_ID", instance_id) .stdin(Stdio::null()); #[cfg(unix)] command.process_group(0); diff --git a/distributed_cli/src/lifecycle/project.rs b/distributed_cli/src/lifecycle/project.rs index 705e8cc75..71716d137 100644 --- a/distributed_cli/src/lifecycle/project.rs +++ b/distributed_cli/src/lifecycle/project.rs @@ -1009,7 +1009,8 @@ fn lifecycle_dev( let ui_host = std::env::var("UI_HOST").unwrap_or_else(|_| "localhost".to_string()); let ui_bind = std::env::var("UI_BIND").unwrap_or_else(|_| ui_host.clone()); let ui_port = std::env::var("UI_PORT").unwrap_or_else(|_| "5180".to_string()); - let ui_url = std::env::var("UI_URL") + let ui_url = std::env::var("PUBLIC_ORIGIN") + .or_else(|_| std::env::var("UI_URL")) .or_else(|_| std::env::var("AUTH_URL")) .unwrap_or_else(|_| format!("http://{ui_host}:{ui_port}")); let cargo_cwd = cargo_root diff --git a/distributed_cli/src/wasm_pures.rs b/distributed_cli/src/wasm_pures.rs index 305286a1f..859f0a986 100644 --- a/distributed_cli/src/wasm_pures.rs +++ b/distributed_cli/src/wasm_pures.rs @@ -86,9 +86,8 @@ pub(crate) fn build_declared_wasm_pures( return Ok(0); }; let ui_lib = ui_root.join("src/lib"); - let wasm_pack_launcher = wasm_pack_launcher.ok_or( - "browser WASM pures require @hops-ops/distributed in the application UI", - )?; + let wasm_pack_launcher = wasm_pack_launcher + .ok_or("browser WASM pures require @hops-ops/distributed in the application UI")?; let compiler_identity = compiler_identity(wasm_pack_launcher)?; let mut outputs = BTreeMap::::new(); for pure in &pures { @@ -437,7 +436,11 @@ fn compiler_identity(launcher: &Path) -> Result> { ) .into()); } - hash.update(file.strip_prefix(package_root)?.to_string_lossy().as_bytes()); + hash.update( + file.strip_prefix(package_root)? + .to_string_lossy() + .as_bytes(), + ); hash.update([0]); hash.update(&content); hash.update([0]); @@ -927,5 +930,4 @@ mod tests { .to_string() .contains("more than one local Cargo package")); } - } diff --git a/distributed_cli/tests/cli_client.rs b/distributed_cli/tests/cli_client.rs index 3b0f73dd4..1d814a2a2 100644 --- a/distributed_cli/tests/cli_client.rs +++ b/distributed_cli/tests/cli_client.rs @@ -584,7 +584,7 @@ fn unmatched_document_glob_fails_before_creating_output() { #[test] fn component_load_intent_remains_framework_neutral_outside_route_conventions() { - let project = project_dir("client-component-load"); + let project = project_dir("client-component-load"); write_document(&project, "queries/todos.graphql", LOAD_TODOS_QUERY); let unplaced = generate(&project, "queries/*.graphql", &[]); @@ -596,10 +596,10 @@ fn component_load_intent_remains_framework_neutral_outside_route_conventions() { "unplaced component island must retain its load intent for the adapter" ); - assert!( - !project.join("generated/routes.ts").exists(), - "the framework-neutral compiler must not invent adapter route ownership" - ); + assert!( + !project.join("generated/routes.ts").exists(), + "the framework-neutral compiler must not invent adapter route ownership" + ); } #[test] diff --git a/distributed_cli/tests/cli_lifecycle.rs b/distributed_cli/tests/cli_lifecycle.rs index 9ec12e873..eda70d1a4 100644 --- a/distributed_cli/tests/cli_lifecycle.rs +++ b/distributed_cli/tests/cli_lifecycle.rs @@ -186,6 +186,7 @@ else fi printf '%s:%s:%s\n' "$name" "$barrier" "$DISTRIBUTED_GENERATION_ID" >> "$root/dev-process.log" printf '%s:%s:%s\n' "$name" "$DEV_FIXTURE_NAME" "$PWD" >> "$root/dev-environment.log" +printf '%s:%s\n' "$name" "$DISTRIBUTED_PROCESS_INSTANCE_ID" >> "$root/dev-instances.log" trap 'exit 0' TERM /bin/sh -c 'trap "" TERM; while :; do sleep 1; done' & descendant=$! @@ -586,12 +587,54 @@ fn dev_waits_for_initial_generation_and_restarts_only_invalidated_processes() { &root.join("dist/distributed/active.json"), Duration::from_secs(5), ); + wait_until(Duration::from_secs(5), || { + dev_state(&root)["members"]["api"].is_object() + }); + let initial_state = dev_state(&root); + let instances = fs::read_to_string(root.join("dev-instances.log")).unwrap(); + for name in ["api", "ui"] { + assert!(instances.lines().any(|line| line + == format!( + "{name}:{}", + initial_state["members"][name]["instanceId"] + .as_str() + .unwrap() + ))); + } + let mut previous_generation = initial_state["active"]["generationId"].clone(); + // Changes outside either process's restart inventory retain both instances + // while their authorized application generation advances repeatedly. + for input in ["client-one", "client-two"] { + fs::write(root.join("plan/input.txt"), input).unwrap(); + wait_until(Duration::from_secs(5), || { + let state = dev_state(&root); + state["phase"] == "active" && state["active"]["generationId"] != previous_generation + }); + let state = dev_state(&root); + assert_eq!(state["members"], initial_state["members"]); + previous_generation = state["active"]["generationId"].clone(); + } fs::write(root.join("src/input.txt"), "second\n").unwrap(); wait_until(Duration::from_secs(5), || { fs::read_to_string(root.join("dev-process.log")).is_ok_and(|log| log.lines().count() == 3) }); + wait_until(Duration::from_secs(5), || { + let state = dev_state(&root); + state["phase"] == "active" && state["active"]["generationId"] != previous_generation + }); + let replaced = dev_state(&root); + assert_ne!( + replaced["members"]["api"]["instanceId"], + initial_state["members"]["api"]["instanceId"] + ); + assert_eq!( + replaced["members"]["api"]["generationId"], + replaced["active"]["generationId"] + ); + assert_eq!(replaced["members"]["ui"], initial_state["members"]["ui"]); let report = supervisor.stop_and_join(); - assert_eq!(report.rebuilds, 1); + assert_eq!(report.rebuilds, 3); + assert!(!root.join("dist/distributed/dev.json").exists()); assert_eq!(report.restarts["api"], 1); assert_eq!(report.restarts["ui"], 0); let log = fs::read_to_string(root.join("dev-process.log")).unwrap(); @@ -617,6 +660,13 @@ fn dev_waits_for_initial_generation_and_restarts_only_invalidated_processes() { } } +fn dev_state(root: &Path) -> Value { + fs::read(root.join("dist/distributed/dev.json")) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or(Value::Null) +} + #[test] fn dev_readiness_failure_restores_processes_and_preserves_active_pointer() { let fixture = temporary_root("dev-rollback"); @@ -632,6 +682,10 @@ fn dev_readiness_failure_restores_processes_and_preserves_active_pointer() { &root.join("dist/distributed/active.json"), Duration::from_secs(5), ); + wait_until(Duration::from_secs(5), || { + dev_state(&root)["members"]["api"].is_object() + }); + let initial_members = dev_state(&root)["members"].clone(); fs::write(root.join("src/input.txt"), "replacement\n").unwrap(); wait_until(Duration::from_secs(5), || { @@ -642,6 +696,17 @@ fn dev_readiness_failure_restores_processes_and_preserves_active_pointer() { fs::read_to_string(root.join("accepted-readiness.log")) .is_ok_and(|log| log.lines().count() == 2) }); + wait_until(Duration::from_secs(5), || { + let state = dev_state(&root); + state["phase"] == "active" + && state["members"]["api"]["instanceId"] != initial_members["api"]["instanceId"] + }); + let rollback = dev_state(&root); + assert_eq!( + rollback["members"]["api"]["generationId"], + initial_members["api"]["generationId"] + ); + assert_eq!(rollback["members"]["ui"], initial_members["ui"]); let report = supervisor.stop_and_join(); assert_eq!(report.initial_generation, report.final_generation); assert_eq!( @@ -709,6 +774,53 @@ fn dev_browser_prepare_timeout_preserves_active_pointer_and_processes() { ); } +#[test] +fn dev_rebuilds_source_edited_while_initial_process_readiness_is_held() { + let fixture = temporary_root("dev-startup-edit"); + let root = fixture.path().to_path_buf(); + write_fixture(&root); + enable_dev(&root); + let path = root.join("distributed.lifecycle.json"); + let mut config: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + config["dev"]["processes"]["api"]["ready"] = serde_json::json!({ + "program": "/bin/test", + "args": ["-f", "{root}/release-readiness"], + "interval_ms": 10, + "timeout_ms": 5000 + }); + fs::write(&path, serde_json::to_vec_pretty(&config).unwrap()).unwrap(); + let supervisor = DevSupervisor::start(root.clone()); + wait_until(Duration::from_secs(5), || { + root.join("dev-process.log").exists() + }); + let before = wait_for_stable_file( + &root.join("dist/distributed/active.json"), + Duration::from_secs(5), + ); + // The initial build is complete but the supervisor cannot finish startup. + fs::write(root.join("src/input.txt"), "edited-during-startup\n").unwrap(); + fs::write(root.join("release-readiness"), "release\n").unwrap(); + wait_until(Duration::from_secs(5), || { + fs::read(root.join("dist/distributed/active.json")).is_ok_and(|active| active != before) + }); + let report = supervisor.stop_and_join(); + assert_ne!(report.initial_generation, report.final_generation); + assert_eq!(report.rebuilds, 1); + assert_eq!(report.restarts["api"], 1); + assert_eq!(report.restarts["ui"], 0); + let active: Value = + serde_json::from_slice(&fs::read(root.join("dist/distributed/active.json")).unwrap()) + .unwrap(); + let artifact = root + .join("dist/distributed") + .join(active["path"].as_str().unwrap()) + .join("generated/application.json"); + assert_eq!( + fs::read_to_string(artifact).unwrap(), + "application:edited-during-startup\n" + ); +} + #[test] fn dev_cancels_a_superseded_executor_before_activation() { let fixture = temporary_root("dev-cancel"); diff --git a/distributed_cli/tests/cli_manifest.rs b/distributed_cli/tests/cli_manifest.rs index a98df5f89..9e53f197d 100644 --- a/distributed_cli/tests/cli_manifest.rs +++ b/distributed_cli/tests/cli_manifest.rs @@ -41,6 +41,15 @@ fn distributed(args: &[&str]) -> String { fn describe_emits_manifest_json() { let json = distributed(&["describe"]); let manifest: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(manifest["schema_version"], 2); + for inventory in ["commands", "events", "models", "projections"] { + assert!(manifest.get(inventory).is_none(), "redundant {inventory}"); + } + // This read-model-only fixture owns models through its Surface, not modules. + assert!(!manifest["surfaces"][0]["models"] + .as_array() + .unwrap() + .is_empty()); assert!(json.contains("\"schema_version\""), "json: {json}"); assert!(json.contains("\"orders\""), "json: {json}"); let framework = manifest["extensions"] diff --git a/distributed_cli/tests/cli_scaffold.rs b/distributed_cli/tests/cli_scaffold.rs index 6710a2e17..da553eb53 100644 --- a/distributed_cli/tests/cli_scaffold.rs +++ b/distributed_cli/tests/cli_scaffold.rs @@ -84,7 +84,8 @@ fn scaffold_generates_a_service_tree() { assert!(deploy_values.starts_with("local: false\npreview: false\n")); assert!(deploy_values.contains("tag: \"\"")); assert!(!read(&out_dir, ".gitops/local/templates/deployment.yaml").contains(".Values.local")); - assert!(read(&out_dir, ".gitops/deploy/templates/deployment.yaml").contains("image.tag must be an immutable build tag")); + assert!(read(&out_dir, ".gitops/deploy/templates/deployment.yaml") + .contains("image.tag must be an immutable build tag")); let cargo = read(&out_dir, "Cargo.toml"); assert!(cargo.contains("\"postgres\""), "Cargo.toml: {cargo}"); diff --git a/distributed_cli/tests/cli_scaffold_compile.rs b/distributed_cli/tests/cli_scaffold_compile.rs index 9811ed7a7..5afbb10dd 100644 --- a/distributed_cli/tests/cli_scaffold_compile.rs +++ b/distributed_cli/tests/cli_scaffold_compile.rs @@ -100,7 +100,15 @@ fn scaffolded_http_tracing_service_compiles() { fn scaffolded_query_api_service_compiles() { let out_dir = scaffold( "compile-query-api-sqlite", - &["--query-api", "--store", "sqlite", "--model", "order"], + &[ + "--query-api", + "--store", + "sqlite", + "--model", + "order", + "--command", + "orders.place", + ], ); cargo_check(&out_dir); } diff --git a/distributed_cli/tests/fixtures/generated-lazy-commands.ts b/distributed_cli/tests/fixtures/generated-lazy-commands.ts new file mode 100644 index 000000000..be31fc2dc --- /dev/null +++ b/distributed_cli/tests/fixtures/generated-lazy-commands.ts @@ -0,0 +1,32 @@ +/** GENERATED by distributed client. Do not edit. */ + +import { createLazyReplicaCommandRuntime } from '@hops-ops/distributed/replica/lazy'; +import type { DistributedReplica, ReplicaCommandTransport, ReplicaLazyCommandRuntime } from '@hops-ops/distributed/replica'; +import type { COMMANDS, GeneratedCommandRuntimeOptions } from './generated-commands.js'; +import { COMMAND_STATUS } from './protocol.js'; + +const COMMAND_CATALOG = { + "todo.import": { + "hasInput": true, + "operationHash": "sha256:e8e54238fd7618fa94e90ae60b1dfac8833943027d04e71be84cb03702f1cebf" + }, + "todo.ping": { + "hasInput": false, + "operationHash": "sha256:3cb3c1e96331b4e98191cc725ab6b01c0e9b04cc7cc0f37f4fa0ef394fee9acf" + }, + "todo.project": { + "hasInput": true, + "operationHash": "sha256:f986d060555cdedfe94621914116306af704d8bb90e75289722a3b7119211d32" + } +} as const; + +/** Register authority now; import one shared runtime on preload or first command. */ +export function createLazyCommands( + replica: DistributedReplica, + transport: ReplicaCommandTransport, + options?: GeneratedCommandRuntimeOptions +): ReplicaLazyCommandRuntime { + return createLazyReplicaCommandRuntime(replica, transport, + { commands: COMMAND_CATALOG, status: COMMAND_STATUS }, + () => import('./generated-commands.js').then(({ COMMANDS }) => ({ entries: COMMANDS })), options); +} diff --git a/distributed_cli/tests/fixtures/generated-operation.ts b/distributed_cli/tests/fixtures/generated-operation.ts index 46a11817f..e533ebd92 100644 --- a/distributed_cli/tests/fixtures/generated-operation.ts +++ b/distributed_cli/tests/fixtures/generated-operation.ts @@ -828,6 +828,7 @@ export const Operation_ScalarInputs: ReplicaOperationArtifact GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandOutputType for OrderView { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "OrderView", vec![ - GraphqlTypeField { + CommandTypeField { name: "order_id".into(), type_name: "String".into(), nullable: false, @@ -36,7 +39,7 @@ impl GraphqlOutputType for OrderView { item_nullable: false, nested: None, }, - GraphqlTypeField { + CommandTypeField { name: "status".into(), type_name: "String".into(), nullable: false, @@ -55,11 +58,11 @@ struct ProjectOrderInput { order_id: String, } -impl GraphqlInputType for ProjectOrderInput { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandInputType for ProjectOrderInput { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "ProjectOrderInput", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "order_id".into(), type_name: "String".into(), nullable: false, diff --git a/distributed_cli/tests/fixtures/runtime-bridge-operation.json b/distributed_cli/tests/fixtures/runtime-bridge-operation.json index 9fde60eca..6c52fbee1 100644 --- a/distributed_cli/tests/fixtures/runtime-bridge-operation.json +++ b/distributed_cli/tests/fixtures/runtime-bridge-operation.json @@ -100,6 +100,7 @@ "protocol": { "version": 1, "schemaHash": "sha256:787216f76fd2c62f867b9d9553710c888f325c6cc5598c4681fb34c1a655e3be", + "protocolHash": "sha256:00fb342f3acb4dc1c1716a43cc3001c748d5f6c500ff831690d820e9e43e2782", "surface": { "kind": "role", "name": "user" diff --git a/distributed_cli/tests/fixtures/unique-key-bridge-operation.json b/distributed_cli/tests/fixtures/unique-key-bridge-operation.json new file mode 100644 index 000000000..9de33c538 --- /dev/null +++ b/distributed_cli/tests/fixtures/unique-key-bridge-operation.json @@ -0,0 +1,333 @@ +{ + "id": "sha256:64d793257325afec58385b9e2e5e3d55fa778bc4d01753a123bd6fab2b67b010", + "document": "query UniqueKeyBridge {\n todos(limit: 25) {\n id\n title\n owner {\n id\n title\n _distributed_tenantId: tenantId\n _distributed_typename: __typename\n }\n _distributed_tenantId: tenantId\n _distributed_typename: __typename\n _distributed_ownerTitle: ownerTitle\n }\n}\n", + "source": { + "path": "src/routes/unique-key/+page.graphql", + "line": 1, + "column": 1 + }, + "variableCodec": { + "version": 2, + "limits": { + "maxDepth": 8, + "maxBoolWidth": 256, + "maxInList": 1000 + }, + "variables": {}, + "defaults": {}, + "inputs": {} + }, + "roots": [ + { + "responseKey": "todos", + "field": "todos", + "cardinality": "many", + "nullable": false, + "arguments": { + "limit": { + "kind": "literal", + "value": 25 + } + }, + "dependencies": [ + "todo_rows" + ], + "coverage": { + "kind": "offset", + "offsetArgument": "offset", + "limitArgument": "limit", + "defaultLimit": 25, + "maxLimit": 100 + }, + "filter": { + "fields": [ + { + "field": "completed", + "scalar": "Boolean", + "codec": "boolean", + "nullable": false, + "operators": [ + "_eq" + ] + }, + { + "field": "id", + "scalar": "ID", + "codec": "string", + "nullable": false, + "operators": [ + "_eq" + ] + }, + { + "field": "ownerTitle", + "scalar": "String", + "codec": "string", + "nullable": true, + "operators": [ + "_eq" + ] + }, + { + "field": "priority", + "scalar": "Int", + "codec": "int32", + "nullable": false, + "operators": [ + "_eq", + "_in", + "_nin" + ] + }, + { + "field": "tenantId", + "scalar": "ID", + "codec": "string", + "nullable": false, + "operators": [ + "_eq" + ] + }, + { + "field": "title", + "scalar": "String", + "codec": "string", + "nullable": true, + "operators": [ + "_eq" + ] + } + ], + "relationships": [ + { + "field": "owner", + "targetModel": "Todo", + "kind": "belongs_to", + "keyMapping": { + "kind": "direct", + "local": [ + "tenantId", + "ownerTitle" + ], + "remote": [ + "tenantId", + "title" + ] + }, + "maintenance": "local", + "dependencies": [ + "todo_rows" + ] + } + ], + "rowPolicy": { + "kind": "unrestricted" + } + }, + "order": { + "fields": [ + { + "field": "completed", + "scalar": "Boolean", + "codec": "boolean", + "nullable": false + }, + { + "field": "id", + "scalar": "ID", + "codec": "string", + "nullable": false + }, + { + "field": "ownerTitle", + "scalar": "String", + "codec": "string", + "nullable": true + }, + { + "field": "priority", + "scalar": "Int", + "codec": "int32", + "nullable": false + }, + { + "field": "tenantId", + "scalar": "ID", + "codec": "string", + "nullable": false + }, + { + "field": "title", + "scalar": "String", + "codec": "string", + "nullable": true + } + ], + "tieBreakers": [ + { + "field": "tenantId", + "scalar": "ID", + "codec": "string", + "nullable": false + }, + { + "field": "id", + "scalar": "ID", + "codec": "string", + "nullable": false + } + ] + }, + "pagination": { + "kind": "offset", + "insert": "local", + "delete": "local", + "reorder": "local", + "stableUpdate": "local" + }, + "selection": { + "typename": "todo", + "storage": { + "kind": "normalized", + "model": "Todo", + "identityFields": [ + "tenantId", + "id" + ] + }, + "members": [ + { + "kind": "scalar", + "responseKey": "id", + "field": "id", + "codec": "string", + "nullable": false + }, + { + "kind": "scalar", + "responseKey": "title", + "field": "title", + "codec": "string", + "nullable": true + }, + { + "kind": "branch", + "semantic": "relationship", + "responseKey": "owner", + "field": "owner", + "cardinality": "one", + "nullable": true, + "dependencies": [ + "todo_rows" + ], + "coverage": { + "kind": "complete" + }, + "relationship": { + "field": "owner", + "targetModel": "Todo", + "kind": "belongs_to", + "keyMapping": { + "kind": "direct", + "local": [ + "tenantId", + "ownerTitle" + ], + "remote": [ + "tenantId", + "title" + ] + }, + "maintenance": "local", + "dependencies": [ + "todo_rows" + ] + }, + "selection": { + "typename": "todo", + "storage": { + "kind": "normalized", + "model": "Todo", + "identityFields": [ + "tenantId", + "id" + ] + }, + "members": [ + { + "kind": "scalar", + "responseKey": "id", + "field": "id", + "codec": "string", + "nullable": false + }, + { + "kind": "scalar", + "responseKey": "title", + "field": "title", + "codec": "string", + "nullable": true + }, + { + "kind": "scalar", + "responseKey": "_distributed_tenantId", + "field": "tenantId", + "codec": "string", + "nullable": false, + "expose": false + }, + { + "kind": "scalar", + "responseKey": "_distributed_typename", + "field": "__typename", + "codec": "string", + "nullable": false, + "expose": false + } + ] + } + }, + { + "kind": "scalar", + "responseKey": "_distributed_tenantId", + "field": "tenantId", + "codec": "string", + "nullable": false, + "expose": false + }, + { + "kind": "scalar", + "responseKey": "_distributed_typename", + "field": "__typename", + "codec": "string", + "nullable": false, + "expose": false + }, + { + "kind": "scalar", + "responseKey": "_distributed_ownerTitle", + "field": "ownerTitle", + "codec": "string", + "nullable": true, + "expose": false + } + ] + } + } + ], + "protocol": { + "version": 1, + "schemaHash": "sha256:d43d65b233d9839f28271b4bdee7326f03b660cac3002013d8f058204e188415", + "protocolHash": "sha256:00fb342f3acb4dc1c1716a43cc3001c748d5f6c500ff831690d820e9e43e2782", + "surface": { + "kind": "role", + "name": "user" + }, + "operation": "sha256:64d793257325afec58385b9e2e5e3d55fa778bc4d01753a123bd6fab2b67b010", + "trustedPresets": [] + }, + "live": { + "id": "sha256:5f1d6c2a80bd0417bfd53ba757436a319fc6d9801af10247cd91cd32a6ac48e9", + "document": "subscription UniqueKeyBridge_Live {\n todos(limit: 25) {\n id\n title\n owner {\n id\n title\n _distributed_tenantId: tenantId\n _distributed_typename: __typename\n }\n _distributed_tenantId: tenantId\n _distributed_typename: __typename\n _distributed_ownerTitle: ownerTitle\n }\n}\n" + } +} diff --git a/distributed_macros/src/command.rs b/distributed_macros/src/command.rs index f3871497d..46fd280b0 100644 --- a/distributed_macros/src/command.rs +++ b/distributed_macros/src/command.rs @@ -193,7 +193,7 @@ pub fn expand( })?; if !function.sig.asyncness.is_some() { return Err(syn::Error::new_spanned( - &function.sig.fn_token, + function.sig.fn_token, "typed command handlers must be async", )); } @@ -287,7 +287,7 @@ pub fn expand( let defaults = args.defaults; let generated_defaults = args.generated_defaults; let mut builder = quote! { - #framework::graphql::typed_command::<#input, #outcome>(#id) + #framework::command::typed_command::<#input, #outcome>(#id) .field_name(#field_name) }; builder.extend(quote! { .roles([#(#roles),*]) }); @@ -315,12 +315,12 @@ pub fn expand( #[cfg(feature = "application-runtime")] #function - #visibility fn #command_name() -> #framework::graphql::TypedCommand<#input, #outcome> { + #visibility fn #command_name() -> #framework::command::TypedCommand<#input, #outcome> { #builder } #visibility static #command_static: ::std::sync::LazyLock< - #framework::graphql::TypedCommand<#input, #outcome> + #framework::command::TypedCommand<#input, #outcome> > = ::std::sync::LazyLock::new(#command_name); #visibility fn #spec_name() -> #framework::application::ApplicationResult< @@ -368,7 +368,7 @@ pub fn expand( + 'static, #aggregate: #framework::Aggregate + Send + Sync + 'static, #input: #framework::__private::serde::de::DeserializeOwned + Send + 'static, - #outcome: #framework::graphql::CommandOutcome, + #outcome: #framework::command::CommandOutcome, { routes .typed_command((#command_static).clone()) diff --git a/distributed_macros/src/command_input_defaults.rs b/distributed_macros/src/command_input_defaults.rs index 24bebfd50..8f5ea5f81 100644 --- a/distributed_macros/src/command_input_defaults.rs +++ b/distributed_macros/src/command_input_defaults.rs @@ -65,7 +65,7 @@ impl CommandInputDefaults { .into_iter() .map(|default| default.expand(&input, &framework)); quote! { - #framework::graphql::__command_input_defaults::<#input>( + #framework::command::__command_input_defaults::<#input>( vec![#(#defaults),*] ) } @@ -123,10 +123,10 @@ impl InputDefault { let marker = marker_path(input, marker_name); match self.generator { InputDefaultGenerator::UuidV7 => quote! { - #framework::graphql::__input_default_uuid_v7::<#input, #marker>() + #framework::command::__input_default_uuid_v7::<#input, #marker>() }, InputDefaultGenerator::Ulid => quote! { - #framework::graphql::__input_default_ulid::<#input, #marker>() + #framework::command::__input_default_ulid::<#input, #marker>() }, } } diff --git a/distributed_macros/src/graphql_types.rs b/distributed_macros/src/command_types.rs similarity index 84% rename from distributed_macros/src/graphql_types.rs rename to distributed_macros/src/command_types.rs index dc2d50953..c67077002 100644 --- a/distributed_macros/src/graphql_types.rs +++ b/distributed_macros/src/command_types.rs @@ -1,4 +1,4 @@ -//! GraphqlInput / GraphqlOutput derive macros. +//! Transport-neutral command data-shape derives. use proc_macro2::TokenStream; use quote::{format_ident, quote}; @@ -23,8 +23,8 @@ impl SerdeDirection { fn derive_name(self) -> &'static str { match self { - Self::Deserialize => "GraphqlInput", - Self::Serialize => "GraphqlOutput", + Self::Deserialize => "CommandInput", + Self::Serialize => "CommandOutput", } } } @@ -41,6 +41,8 @@ enum RenameRule { CamelCase, SnakeCase, ScreamingSnakeCase, + KebabCase, + ScreamingKebabCase, } impl RenameRule { @@ -52,14 +54,12 @@ impl RenameRule { "camelCase" => Ok(Self::CamelCase), "snake_case" => Ok(Self::SnakeCase), "SCREAMING_SNAKE_CASE" => Ok(Self::ScreamingSnakeCase), - "kebab-case" | "SCREAMING-KEBAB-CASE" => Err(syn::Error::new_spanned( - value, - "serde kebab-case field names cannot be represented in GraphQL; use camelCase or snake_case", - )), + "kebab-case" => Ok(Self::KebabCase), + "SCREAMING-KEBAB-CASE" => Ok(Self::ScreamingKebabCase), other => Err(syn::Error::new_spanned( value, format!( - "unsupported serde rename_all rule `{other}` for GraphqlInput/GraphqlOutput" + "unsupported serde rename_all rule `{other}` for CommandInput/CommandOutput" ), )), } @@ -67,6 +67,8 @@ impl RenameRule { fn apply(self, field: &str) -> String { match self { + Self::KebabCase => field.replace('_', "-"), + Self::ScreamingKebabCase => field.replace('_', "-").to_ascii_uppercase(), Self::LowerCase | Self::SnakeCase => field.to_string(), Self::UpperCase | Self::ScreamingSnakeCase => field.to_ascii_uppercase(), Self::PascalCase => { @@ -96,23 +98,23 @@ impl RenameRule { } } -pub fn expand_graphql_input(input: DeriveInput) -> syn::Result { +pub fn expand_command_input(input: DeriveInput) -> syn::Result { let framework = crate::shared::framework_path()?; expand( input, - quote! { #framework::graphql::GraphqlInputType }, - quote! { #framework::graphql::GraphqlInputType }, + quote! { #framework::command::CommandInputType }, + quote! { #framework::command::CommandInputType }, framework, SerdeDirection::Deserialize, ) } -pub fn expand_graphql_output(input: DeriveInput) -> syn::Result { +pub fn expand_command_output(input: DeriveInput) -> syn::Result { let framework = crate::shared::framework_path()?; expand( input, - quote! { #framework::graphql::GraphqlOutputType }, - quote! { #framework::graphql::GraphqlOutputType }, + quote! { #framework::command::CommandOutputType }, + quote! { #framework::command::CommandOutputType }, framework, SerdeDirection::Serialize, ) @@ -125,6 +127,9 @@ fn expand( framework: TokenStream, serde_direction: SerdeDirection, ) -> syn::Result { + let method = quote! { command_type }; + let type_def = quote! { #framework::command::CommandTypeDef }; + let type_field = quote! { #framework::command::CommandTypeField }; let name = &input.ident; let visibility = &input.vis; validate_serde_container_shape(&input.attrs, serde_direction)?; @@ -132,13 +137,13 @@ fn expand( let Data::Struct(data) = &input.data else { return Err(syn::Error::new_spanned( &input, - "GraphqlInput/GraphqlOutput only support structs with named fields", + "CommandInput/CommandOutput only support structs with named fields", )); }; let Fields::Named(fields) = &data.fields else { return Err(syn::Error::new_spanned( &input, - "GraphqlInput/GraphqlOutput require named fields", + "CommandInput/CommandOutput require named fields", )); }; @@ -160,13 +165,12 @@ fn expand( .map(|rule| rule.apply(rust_field_name)) .unwrap_or_else(|| rust_field_name.to_string()) }); - validate_graphql_field_name(&field_name_str, field)?; let (type_name, nullable, list, item_nullable, nested) = - map_type(&field.ty, field, &nested_trait)?; + map_type(&field.ty, field, &nested_trait, &method)?; let effect_path_kind = if !list && nested.is_some() { - quote! { #framework::graphql::EffectInputObjectKind } + quote! { #framework::command::EffectInputObjectKind } } else { - quote! { #framework::graphql::EffectInputTerminalKind } + quote! { #framework::command::EffectInputTerminalKind } }; let effect_wire = effect_input_wire_tokens(&framework, &type_name, list, nested.is_some()); let nested_tokens = match nested { @@ -174,7 +178,7 @@ fn expand( None => quote! { None }, }; field_tokens.push(quote! { - #framework::graphql::GraphqlTypeField { + #type_field { name: #field_name_str.to_string(), type_name: #type_name.to_string(), nullable: #nullable, @@ -189,16 +193,16 @@ fn expand( let nested_ty = effect_nested_type(field_ty); let non_null_ty = effect_non_null_type(field_ty); let nullability = if extract_path_arg(field_ty, "Option").is_some() { - quote! { #framework::graphql::EffectNullable } + quote! { #framework::command::EffectNullable } } else { - quote! { #framework::graphql::EffectRequired } + quote! { #framework::command::EffectRequired } }; effect_input_markers.push(quote! { #[doc(hidden)] #[allow(non_camel_case_types)] #visibility struct #marker; - impl #framework::graphql::EffectInputFieldMarker for #marker { + impl #framework::command::EffectInputFieldMarker for #marker { type Input = #name; type Value = #field_ty; type NonNullValue = #non_null_ty; @@ -217,8 +221,8 @@ fn expand( let type_name_str = name.to_string(); Ok(quote! { impl #trait_path for #name { - fn graphql_type() -> #framework::graphql::GraphqlTypeDef { - #framework::graphql::GraphqlTypeDef::new( + fn #method() -> #type_def { + #type_def::new( #type_name_str, vec![#(#field_tokens),*], ).with_type_id(::std::any::TypeId::of::<#name>()) @@ -236,20 +240,20 @@ fn effect_input_wire_tokens( nested: bool, ) -> proc_macro2::TokenStream { if list { - return quote! { #framework::graphql::EffectWireList }; + return quote! { #framework::command::EffectWireList }; } if nested { - return quote! { #framework::graphql::EffectWireObject }; + return quote! { #framework::command::EffectWireObject }; } match type_name { - "String" | "ID" => quote! { #framework::graphql::EffectWireString }, - "Boolean" => quote! { #framework::graphql::EffectWireBoolean }, - "BigInt" | "Int" => quote! { #framework::graphql::EffectWireBigInt }, - "Float" => quote! { #framework::graphql::EffectWireFloat }, - "JSON" => quote! { #framework::graphql::EffectWireJson }, - "Bytea" => quote! { #framework::graphql::EffectWireBytea }, - "Timestamptz" => quote! { #framework::graphql::EffectWireTimestamp }, - _ => quote! { #framework::graphql::EffectWireUnsupported }, + "String" | "ID" => quote! { #framework::command::EffectWireString }, + "Boolean" => quote! { #framework::command::EffectWireBoolean }, + "BigInt" | "Int" => quote! { #framework::command::EffectWireBigInt }, + "Float" => quote! { #framework::command::EffectWireFloat }, + "JSON" => quote! { #framework::command::EffectWireJson }, + "Bytea" => quote! { #framework::command::EffectWireBytea }, + "Timestamptz" => quote! { #framework::command::EffectWireTimestamp }, + _ => quote! { #framework::command::EffectWireUnsupported }, } } @@ -277,6 +281,7 @@ fn map_type( ty: &Type, span: &syn::Field, nested_trait: &TokenStream, + method: &TokenStream, ) -> syn::Result<(String, bool, bool, bool, Option)> { let mut current = ty; let mut nullable = false; @@ -297,7 +302,7 @@ fn map_type( if extract_path_arg(current, "Vec").is_some() { return Err(syn::Error::new_spanned( ty, - "nested lists are not supported for GraphqlInput/GraphqlOutput fields", + "nested lists are not supported for CommandInput/CommandOutput fields", )); } } @@ -307,7 +312,7 @@ fn map_type( _ => { return Err(syn::Error::new_spanned( span, - "unsupported field type for GraphqlInput/GraphqlOutput", + "unsupported field type for CommandInput/CommandOutput", )); } }; @@ -333,7 +338,7 @@ fn map_type( return Ok((s.to_string(), nullable, list, item_nullable, None)); } - let nested = quote! { <#current as #nested_trait>::graphql_type() }; + let nested = quote! { <#current as #nested_trait>::#method() }; Ok((ident, nullable, list, item_nullable, Some(nested))) } @@ -424,7 +429,7 @@ fn validate_serde_field_shape(attrs: &[Attribute], direction: SerdeDirection) -> return Err(syn::Error::new_spanned( meta, format!( - "#[serde({attribute})] is not supported by {} because it changes the declared GraphQL field shape; define a separate wire type", + "#[serde({attribute})] is not supported by {} because it changes the declared command field shape; define a separate wire type", direction.derive_name(), ), )); @@ -488,7 +493,7 @@ fn validate_serde_container_shape( return Err(syn::Error::new_spanned( meta, format!( - "#[serde({attribute})] is not supported by {} because it changes the declared GraphQL object shape; define a separate wire type", + "#[serde({attribute})] is not supported by {} because it changes the declared command object shape; define a separate wire type", direction.derive_name(), ), )); @@ -544,7 +549,7 @@ fn set_serde_name(found: &mut Option, value: LitStr, key: &str) -> syn:: if found.is_some() { return Err(syn::Error::new_spanned( value, - format!("duplicate serde `{key}` rule for GraphqlInput/GraphqlOutput"), + format!("duplicate serde `{key}` rule for CommandInput/CommandOutput"), )); } *found = Some(value); @@ -567,30 +572,12 @@ fn string_literal(value: &Expr, key: &str) -> syn::Result { } } -fn validate_graphql_field_name(name: &str, span: &syn::Field) -> syn::Result<()> { - let mut chars = name.chars(); - let first_valid = match chars.next() { - Some('_') => !name.starts_with("__"), - Some(first) => first.is_ascii_alphabetic(), - None => false, - }; - if first_valid && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') { - return Ok(()); - } - Err(syn::Error::new_spanned( - span, - format!( - "serde field name `{name}` is not a valid GraphQL name; use #[serde(rename = \"valid_name\")]" - ), - )) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn graphql_safe_rename_all_rules_match_serde_field_rules() { + fn rename_all_rules_match_serde_field_rules() { let field = "long_field_name"; let cases = [ ("lowercase", "long_field_name"), @@ -599,6 +586,8 @@ mod tests { ("camelCase", "longFieldName"), ("snake_case", "long_field_name"), ("SCREAMING_SNAKE_CASE", "LONG_FIELD_NAME"), + ("kebab-case", "long-field-name"), + ("SCREAMING-KEBAB-CASE", "LONG-FIELD-NAME"), ]; for (rule, expected) in cases { let literal = LitStr::new(rule, proc_macro2::Span::call_site()); @@ -616,7 +605,7 @@ mod tests { custom_id: String, } }; - let input_tokens = expand_graphql_input(input).unwrap().to_string(); + let input_tokens = expand_command_input(input).unwrap().to_string(); assert!(input_tokens.contains("\"regularField\"")); assert!(input_tokens.contains("\"inputID\"")); @@ -628,7 +617,7 @@ mod tests { custom_id: String, } }; - let output_tokens = expand_graphql_output(output).unwrap().to_string(); + let output_tokens = expand_command_output(output).unwrap().to_string(); assert!(output_tokens.contains("\"REGULAR_FIELD\"")); assert!(output_tokens.contains("\"OUTPUT_ID\"")); } @@ -638,7 +627,7 @@ mod tests { let nested: DeriveInput = syn::parse_quote! { struct Nested { values: Option>>> } }; - let error = expand_graphql_input(nested).unwrap_err().to_string(); + let error = expand_command_input(nested).unwrap_err().to_string(); assert!(error.contains("nested lists are not supported"), "{error}"); let skipped: DeriveInput = syn::parse_quote! { @@ -647,9 +636,9 @@ mod tests { value: String, } }; - let error = expand_graphql_input(skipped).unwrap_err().to_string(); + let error = expand_command_input(skipped).unwrap_err().to_string(); assert!( - error.contains("changes the declared GraphQL field shape"), + error.contains("changes the declared command field shape"), "{error}" ); @@ -659,7 +648,7 @@ mod tests { value: String, } }; - let error = expand_graphql_input(defaulted).unwrap_err().to_string(); + let error = expand_command_input(defaulted).unwrap_err().to_string(); assert!(error.contains("#[serde(default)]"), "{error}"); let custom: DeriveInput = syn::parse_quote! { @@ -668,21 +657,21 @@ mod tests { value: String, } }; - let error = expand_graphql_input(custom).unwrap_err().to_string(); + let error = expand_command_input(custom).unwrap_err().to_string(); assert!(error.contains("#[serde(deserialize_with)]"), "{error}"); let transparent: DeriveInput = syn::parse_quote! { #[serde(transparent)] struct Transparent { value: String } }; - let error = expand_graphql_output(transparent).unwrap_err().to_string(); + let error = expand_command_output(transparent).unwrap_err().to_string(); assert!(error.contains("#[serde(transparent)]"), "{error}"); let container_default: DeriveInput = syn::parse_quote! { #[serde(default = "default_input")] struct ContainerDefault { value: String } }; - let error = expand_graphql_input(container_default) + let error = expand_command_input(container_default) .unwrap_err() .to_string(); assert!(error.contains("#[serde(default)]"), "{error}"); diff --git a/distributed_macros/src/digest.rs b/distributed_macros/src/digest.rs index fa5378747..9b95a32a8 100644 --- a/distributed_macros/src/digest.rs +++ b/distributed_macros/src/digest.rs @@ -3,8 +3,8 @@ use quote::{format_ident, quote}; use syn::{parse::Parser, Expr, Ident, ItemFn, LitStr, Token}; use crate::shared::{ - ensure_sourced_result_signature, extract_params_with_types, generate_digest_call, - framework_path, wrap_result_body_with_guard, + ensure_sourced_result_signature, extract_params_with_types, framework_path, + generate_digest_call, wrap_result_body_with_guard, }; pub(crate) fn expand_digest(attr: TokenStream2, item: TokenStream2) -> syn::Result { diff --git a/distributed_macros/src/domain_event.rs b/distributed_macros/src/domain_event.rs index b03be56fa..f94934981 100644 --- a/distributed_macros/src/domain_event.rs +++ b/distributed_macros/src/domain_event.rs @@ -4,8 +4,8 @@ use quote::quote; use syn::{Data, DeriveInput, Fields, LitInt, LitStr}; use crate::shared::{ - canonical_object_schema, projection_body_metadata_tokens, schema_fingerprint, - validate_domain_event_name_literal, framework_path, + canonical_object_schema, framework_path, projection_body_metadata_tokens, schema_fingerprint, + validate_domain_event_name_literal, }; pub(crate) fn derive_domain_event(input: TokenStream) -> TokenStream { diff --git a/distributed_macros/src/lib.rs b/distributed_macros/src/lib.rs index b075a46f0..bd24f3f4a 100644 --- a/distributed_macros/src/lib.rs +++ b/distributed_macros/src/lib.rs @@ -2,14 +2,14 @@ mod aggregate; mod application; mod command; mod command_input_defaults; -mod portable_command; +mod command_types; mod digest; mod domain_event; mod domain_state; mod enqueue; -mod graphql_types; mod module; mod mutation; +mod portable_command; // Event-owning `projection!` authoring removed (mutation projectors cutover). mod read_model; mod shared; @@ -182,21 +182,21 @@ pub fn mutation_file(input: TokenStream) -> TokenStream { mutation::expand_file(input) } -/// Derive `GraphqlInputType` for command mutation input structs. -#[proc_macro_derive(GraphqlInput, attributes(serde))] -pub fn derive_graphql_input(input: TokenStream) -> TokenStream { +/// Derive transport-neutral command input metadata from Serde field shapes. +#[proc_macro_derive(CommandInput, attributes(serde))] +pub fn derive_command_input(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); - match graphql_types::expand_graphql_input(input) { + match command_types::expand_command_input(input) { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } } -/// Derive `GraphqlOutputType` for command mutation output structs. -#[proc_macro_derive(GraphqlOutput, attributes(serde))] -pub fn derive_graphql_output(input: TokenStream) -> TokenStream { +/// Derive transport-neutral command output metadata from Serde field shapes. +#[proc_macro_derive(CommandOutput, attributes(serde))] +pub fn derive_command_output(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); - match graphql_types::expand_graphql_output(input) { + match command_types::expand_command_output(input) { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } diff --git a/distributed_macros/src/portable_command.rs b/distributed_macros/src/portable_command.rs index a1a2e6ed0..5ca69efdb 100644 --- a/distributed_macros/src/portable_command.rs +++ b/distributed_macros/src/portable_command.rs @@ -221,16 +221,13 @@ pub fn expand(input: TokenStream) -> syn::Result { 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).declared_in_rust_package(env!("CARGO_PKG_NAME")) - ) - } - }); + let preview_reduce_known_record = args.preview_reduce_known_record.as_ref().map(|preview| { + quote! { + .preview_reduce_known_record( + (#preview).declared_in_rust_package(env!("CARGO_PKG_NAME")) + ) + } + }); let install_body = if let Some(handle) = &args.handle { if args.invoke.is_some() || args.payload.is_some() { diff --git a/distributed_macros/src/read_model/attrs.rs b/distributed_macros/src/read_model/attrs.rs index a21bcb703..86c10bc55 100644 --- a/distributed_macros/src/read_model/attrs.rs +++ b/distributed_macros/src/read_model/attrs.rs @@ -2,7 +2,8 @@ use quote::quote; use syn::{Attribute, DeriveInput, Expr, ExprArray, ExprLit, Field, Lit, LitStr, Meta, Token}; use super::types::{ - option_inner_type, option_string_tokens, validate_relationship_target_type, vec_inner_type, + box_inner_type, option_inner_type, option_string_tokens, validate_relationship_target_type, + vec_inner_type, }; #[derive(Default)] @@ -98,6 +99,7 @@ impl FieldAttrs { pub(super) fn from_field(field: &Field) -> syn::Result { let mut attrs = Self::default(); let mut pending_foreign_key: Option = None; + let mut pending_references: Option = None; let mut pending_through: Option = None; let mut pending_target_foreign_key: Option = None; for attr in &field.attrs { @@ -164,6 +166,11 @@ impl FieldAttrs { if meta.input.peek(Token![=]) { attrs.default = Some(meta.value()?.parse::()?.value()); } + } else if meta.path.is_ident("references") { + if pending_references.is_some() { + return Err(meta.error("relationship references declared more than once")); + } + pending_references = Some(meta.value()?.parse::()?.value()); } else if meta.path.is_ident("foreign_key") { let value = meta.value()?.parse::()?.value(); if attrs.relationship.is_some() { @@ -184,6 +191,7 @@ impl FieldAttrs { } else if meta.path.is_ident("has_many") { let target = meta.value()?.parse::()?.value(); attrs.relationship = Some(RelationshipAttr { + references: None, kind: RelationshipKindAttr::HasMany, target_model: target, foreign_key: None, @@ -193,6 +201,7 @@ impl FieldAttrs { } else if meta.path.is_ident("belongs_to") { let target = meta.value()?.parse::()?.value(); attrs.relationship = Some(RelationshipAttr { + references: None, kind: RelationshipKindAttr::BelongsTo, target_model: target, foreign_key: None, @@ -202,6 +211,7 @@ impl FieldAttrs { } else if meta.path.is_ident("many_to_many") { let target = meta.value()?.parse::()?.value(); attrs.relationship = Some(RelationshipAttr { + references: None, kind: RelationshipKindAttr::ManyToMany, target_model: target, foreign_key: None, @@ -292,6 +302,19 @@ impl FieldAttrs { } } + if let Some(references) = pending_references { + let relationship = attrs.relationship.as_mut().ok_or_else(|| { + syn::Error::new_spanned(field, "`references` requires a direct relationship") + })?; + if matches!(relationship.kind, RelationshipKindAttr::ManyToMany) { + return Err(syn::Error::new_spanned( + field, + "`references` requires a direct relationship", + )); + } + relationship.references = Some(references); + } + if attrs.jsonb && attrs.text { return Err(syn::Error::new_spanned( field, @@ -330,6 +353,7 @@ impl FieldAttrs { )); }; let target_model = &relationship.target_model; + let references = option_string_tokens(relationship.references.as_deref()); let through = option_string_tokens(relationship.through.as_deref()); let target_foreign_key = option_string_tokens(relationship.target_foreign_key.as_deref()); let kind = match relationship.kind { @@ -341,6 +365,7 @@ impl FieldAttrs { }; Ok(Some(quote! { distributed::RelationshipDef { + references: #references, field_name: #field_name.to_string(), kind: #kind, target_model: #target_model.to_string(), @@ -412,17 +437,29 @@ impl FieldAttrs { ), ) })?; + let boxed = box_inner_type(inner); + let inner = boxed.unwrap_or(inner); validate_relationship_target_type( field, inner, &relationship.target_model, field_name, )?; + let hydrated_value = if boxed.is_some() { + quote! { ::std::boxed::Box::new(<#inner as distributed::RelationalReadModel>::from_row(row)?) } + } else { + quote! { <#inner as distributed::RelationalReadModel>::from_row(row)? } + }; + let included_value = if boxed.is_some() { + quote! { value.as_ref() } + } else { + quote! { value } + }; let hydrate = quote! { #field_name => { let mut rows = rows.into_iter(); self.#ident = match rows.next() { - Some(row) => Some(<#inner as distributed::RelationalReadModel>::from_row(row)?), + Some(row) => Some(#hydrated_value), None => None, }; if rows.next().is_some() { @@ -438,7 +475,7 @@ impl FieldAttrs { #field_name => { let mut rows = Vec::new(); if let Some(value) = &self.#ident { - rows.push(distributed::RelationalReadModel::to_row(value)?); + rows.push(distributed::RelationalReadModel::to_row(#included_value)?); } Ok(rows) } @@ -472,6 +509,7 @@ pub(super) struct ForeignKeyParts { #[derive(Clone)] pub(super) struct RelationshipAttr { + pub(super) references: Option, pub(super) kind: RelationshipKindAttr, pub(super) target_model: String, pub(super) foreign_key: Option, diff --git a/distributed_macros/src/read_model/relational.rs b/distributed_macros/src/read_model/relational.rs index 13ccb55ed..a3858d33e 100644 --- a/distributed_macros/src/read_model/relational.rs +++ b/distributed_macros/src/read_model/relational.rs @@ -8,8 +8,8 @@ use crate::shared::{ use super::attrs::{foreign_key_tokens, FieldAttrs, RelationshipKindAttr, StructAttrs}; use super::types::{ - bytes_row_value_tokens, column_type_tokens, default_storage_name, effect_model_wire_tokens, - option_inner_type, option_string_tokens, vec_inner_type, + box_inner_type, bytes_row_value_tokens, column_type_tokens, default_storage_name, + effect_model_wire_tokens, option_inner_type, option_string_tokens, vec_inner_type, }; pub(super) fn expand_relational_read_model( @@ -70,10 +70,10 @@ pub(super) fn expand_relational_read_model( let ty = &field.ty; let marker = format_ident!("__Distributed{}EffectModelField_{}", name, ident); effect_key_fields.push(quote! { - pub #ident: distributed::graphql::TypedEffectExpression<#ty> + pub #ident: distributed::command::TypedEffectExpression<#ty> }); effect_key_values.push(quote! { - distributed::graphql::__effect_key_field::<#marker>(value.#ident) + distributed::command::__effect_key_field::<#marker>(value.#ident) }); } @@ -114,7 +114,9 @@ pub(super) fn expand_relational_read_model( vec_inner_type(&field.ty).expect("relationship shape was validated") } RelationshipKindAttr::BelongsTo => { - option_inner_type(&field.ty).expect("relationship shape was validated") + let inner = + option_inner_type(&field.ty).expect("relationship shape was validated"); + box_inner_type(inner).unwrap_or(inner) } }; let marker = format_ident!("__Distributed{}EffectRelationship_{}", name, ident); @@ -123,7 +125,7 @@ pub(super) fn expand_relational_read_model( #[allow(non_camel_case_types)] #visibility struct #marker; - impl distributed::graphql::EffectRelationshipMarker for #marker { + impl distributed::command::EffectRelationshipMarker for #marker { type Source = #name; type Target = #target_ty; const FIELD: &'static str = #field_name; @@ -153,7 +155,7 @@ pub(super) fn expand_relational_read_model( #[allow(non_camel_case_types)] #visibility struct #effect_marker; - impl distributed::graphql::EffectModelFieldMarker for #effect_marker { + impl distributed::command::EffectModelFieldMarker for #effect_marker { type Model = #name; type Value = #field_ty; type Wire = #effect_wire; @@ -311,10 +313,10 @@ pub(super) fn expand_relational_read_model( } impl ::core::convert::From<#effect_key_name> - for distributed::graphql::TypedEffectKey<#name> + for distributed::command::TypedEffectKey<#name> { fn from(value: #effect_key_name) -> Self { - distributed::graphql::__effect_key::<#name>(vec![#(#effect_key_values),*]) + distributed::command::__effect_key::<#name>(vec![#(#effect_key_values),*]) } } diff --git a/distributed_macros/src/read_model/tests.rs b/distributed_macros/src/read_model/tests.rs index 9cffb946d..48a8ba732 100644 --- a/distributed_macros/src/read_model/tests.rs +++ b/distributed_macros/src/read_model/tests.rs @@ -2,6 +2,63 @@ use super::types::{default_storage_name, to_snake_case}; use super::*; use syn::DeriveInput; +#[test] +fn direct_relationship_references_are_order_independent_and_emitted() { + for attributes in [ + "references = \"namespace,oid\", belongs_to = \"Object\", foreign_key = \"scope,oid\"", + "belongs_to = \"Object\", foreign_key = \"scope,oid\", references = \"namespace,oid\"", + ] { + let input = syn::parse_str(&format!( + "struct Ref {{ id: String, #[readmodel({attributes})] object: Option }}" + )) + .unwrap(); + let expanded = expand_read_model(input).unwrap().to_string(); + assert!( + expanded.contains("references : Some (\"namespace,oid\""), + "{expanded}" + ); + } + for attributes in [ + "references = \"id\"", + "belongs_to = \"Object\", foreign_key = \"id\", references = \"id\", references = \"id\"", + "many_to_many = \"Object\", foreign_key = \"id\", through = \"links\", references = \"id\"", + ] { + let input = syn::parse_str(&format!( + "struct Ref {{ id: String, #[readmodel({attributes})] object: Option }}" + )) + .unwrap(); + assert!(expand_read_model(input).is_err(), "accepted {attributes}"); + } +} + +#[test] +fn boxed_belongs_to_preserves_target_markers_and_checks_inner_type() { + let input: DeriveInput = syn::parse_quote! { + struct Parent { + id: String, + #[readmodel(belongs_to = "Child", foreign_key = "id")] + child: Option>, + } + }; + let expanded = expand_read_model(input).unwrap().to_string(); + assert!(expanded.contains("type Target = Child"), "{expanded}"); + assert!(!expanded.contains("type Target = std :: boxed :: Box")); + assert!(expanded.contains("Box :: new")); + assert!(expanded.contains("value . as_ref ()")); + for ty in [ + "Option>", + "Option>>", + "Option>", + "Box", + "Vec>", + ] { + let input: DeriveInput = syn::parse_str(&format!( + "struct Parent {{ id: String, #[readmodel(belongs_to = \"Child\", foreign_key = \"id\")] child: {ty} }}" + )).unwrap(); + assert!(expand_read_model(input).is_err(), "accepted {ty}"); + } +} + #[test] fn expand_read_model_accepts_named_id_field() { let input: DeriveInput = syn::parse_quote! { diff --git a/distributed_macros/src/read_model/types.rs b/distributed_macros/src/read_model/types.rs index f500a2554..429becc4f 100644 --- a/distributed_macros/src/read_model/types.rs +++ b/distributed_macros/src/read_model/types.rs @@ -54,27 +54,27 @@ pub(super) fn effect_model_wire_tokens( text: bool, ) -> proc_macro2::TokenStream { if jsonb { - return quote! { distributed::graphql::EffectWireJson }; + return quote! { distributed::command::EffectWireJson }; } if text { - return quote! { distributed::graphql::EffectWireString }; + return quote! { distributed::command::EffectWireString }; } let ty = option_inner_type(ty).unwrap_or(ty); let Some(last) = last_type_segment(ty) else { - return quote! { distributed::graphql::EffectWireUnsupported }; + return quote! { distributed::command::EffectWireUnsupported }; }; match last.ident.to_string().as_str() { - "String" | "str" => quote! { distributed::graphql::EffectWireString }, - "bool" => quote! { distributed::graphql::EffectWireBoolean }, + "String" | "str" => quote! { distributed::command::EffectWireString }, + "bool" => quote! { distributed::command::EffectWireBoolean }, "i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64" | "usize" => { - quote! { distributed::graphql::EffectWireBigInt } + quote! { distributed::command::EffectWireBigInt } } - "f32" | "f64" => quote! { distributed::graphql::EffectWireFloat }, - "Vec" if vec_inner_is_u8(last) => quote! { distributed::graphql::EffectWireBytea }, + "f32" | "f64" => quote! { distributed::command::EffectWireFloat }, + "Vec" if vec_inner_is_u8(last) => quote! { distributed::command::EffectWireBytea }, "Vec" | "HashMap" | "BTreeMap" | "Value" => { - quote! { distributed::graphql::EffectWireJson } + quote! { distributed::command::EffectWireJson } } - _ => quote! { distributed::graphql::EffectWireUnsupported }, + _ => quote! { distributed::command::EffectWireUnsupported }, } } @@ -131,6 +131,24 @@ pub(super) fn vec_inner_type(ty: &Type) -> Option<&Type> { }) } +/// One allocation wrapper for a singular relationship, not a model or column. +pub(super) fn box_inner_type(ty: &Type) -> Option<&Type> { + let segment = last_type_segment(ty)?; + if segment.ident != "Box" { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + if args.args.len() != 1 { + return None; + } + match args.args.first()? { + GenericArgument::Type(ty) => Some(ty), + _ => None, + } +} + pub(super) fn validate_relationship_target_type( field: &Field, ty: &Type, diff --git a/distributed_macros/src/sourced.rs b/distributed_macros/src/sourced.rs index e1ca50e83..90caa3434 100644 --- a/distributed_macros/src/sourced.rs +++ b/distributed_macros/src/sourced.rs @@ -224,6 +224,8 @@ struct DomainCommandEvent { domain_event_type: Ident, domain_state: Option, known_state_values: Vec, + body_params: Vec<(Ident, Type)>, + known_body_values: Vec, } #[derive(Clone)] @@ -754,9 +756,11 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res } let state_domain = matches!(event_attr.domain.as_ref(), Some(DomainMode::State)); - let known_state_values = state_domain - .then(|| infer_unconditional_known_state_values(&method.block)) - .unwrap_or_default(); + let known_state_values = if state_domain { + infer_unconditional_known_state_values(&method.block) + } else { + Vec::new() + }; let signature_synthesized = ensure_sourced_result_signature(&mut method.sig, "event", &framework)?; @@ -817,6 +821,12 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res )?, domain_state: state_domain.then(|| args.domain_state.clone()).flatten(), known_state_values, + body_params: if matches!(event_attr.domain, Some(DomainMode::Event)) { + params.clone() + } else { + Vec::new() + }, + known_body_values: Vec::new(), }) } else { None @@ -1181,15 +1191,155 @@ impl<'ast> Visit<'ast> for DomainEventCallFinder<'_> { fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { if is_self_receiver(&node.receiver) { if let Some(command_event) = self.recorders.get(&node.method.to_string()) { + let mut candidate = command_event.clone(); + candidate.known_body_values = candidate + .body_params + .iter() + .zip(&node.args) + .filter_map(|((field, ty), arg)| { + flat_literal(arg, ty).map(|source| KnownStateValue { + field: field.clone(), + source, + }) + }) + .collect(); self.found .entry(command_event.domain_event_type.to_string()) - .or_insert_with(|| command_event.clone()); + .and_modify(|existing| { + // A single event type can occur more than once, including + // through branches. Only values shared by every call are known. + existing.known_body_values.retain(|value| { + candidate.known_body_values.iter().any(|other| { + value.field == other.field + && same_known_value(&value.source, &other.source) + }) + }); + }) + .or_insert(candidate); } } syn::visit::visit_expr_method_call(self, node); } } +fn same_known_value(left: &KnownStateValueSource, right: &KnownStateValueSource) -> bool { + match (left, right) { + (KnownStateValueSource::Null, KnownStateValueSource::Null) => true, + (KnownStateValueSource::Constant(left), KnownStateValueSource::Constant(right)) => { + quote!(#left).to_string() == quote!(#right).to_string() + } + _ => false, + } +} + +/// Recognize data, not executable expressions. In particular, never hoist +/// arbitrary calls/paths or replay a user conversion while building metadata. +fn flat_literal(expression: &Expr, ty: &Type) -> Option { + let Type::Path(ty) = ty else { return None }; + let segment = ty.path.segments.last()?; + let name = segment.ident.to_string(); + let path = ty + .path + .segments + .iter() + .map(|part| part.ident.to_string()) + .collect::>() + .join("::"); + let standard = path == name + || match name.as_str() { + "String" => matches!( + path.as_str(), + "std::string::String" | "alloc::string::String" + ), + "Option" => matches!( + path.as_str(), + "std::option::Option" | "core::option::Option" + ), + _ => { + path == format!("core::primitive::{name}") + || path == format!("std::primitive::{name}") + } + }; + if !standard { + return None; + } + let expression = ungroup_expr(expression); + if name == "Option" { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner)) = args.args.first() else { + return None; + }; + if matches!(expression, Expr::Path(path) if standard_option_variant(&path.path, "None")) { + return Some(KnownStateValueSource::Null); + } + if let Expr::Call(call) = expression { + if call.args.len() == 1 + && matches!(&*call.func, Expr::Path(path) if standard_option_variant(&path.path, "Some")) + { + return flat_literal(call.args.first()?, inner); + } + } + return None; + } + let literal = match expression { + Expr::Lit(literal) => literal, + Expr::MethodCall(call) + if name == "String" + && call.args.is_empty() + && call.turbofish.is_none() + && matches!( + call.method.to_string().as_str(), + "into" | "to_owned" | "to_string" + ) => + { + let Expr::Lit(literal) = ungroup_expr(&call.receiver) else { + return None; + }; + literal + } + _ => return None, + }; + let supported = match &literal.lit { + syn::Lit::Str(_) => name == "String", + syn::Lit::Bool(_) => name == "bool", + syn::Lit::Char(_) => name == "char", + syn::Lit::Int(_) => matches!( + name.as_str(), + "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" + ), + syn::Lit::Float(_) => matches!(name.as_str(), "f32" | "f64"), + _ => false, + }; + supported.then(|| { + let expression = if matches!(&literal.lit, syn::Lit::Int(_) | syn::Lit::Float(_)) { + let primitive = &segment.ident; + // Preserve contextual numeric typing (notably unsuffixed f32 and + // large u64 literals) without evaluating a user-defined conversion. + syn::parse_quote!({ let value: ::core::primitive::#primitive = #literal; value }) + } else { + Expr::Lit(literal.clone()) + }; + KnownStateValueSource::Constant(expression) + }) +} + +fn standard_option_variant(path: &syn::Path, variant: &str) -> bool { + // Bare Some/None can be locally shadowed; do not execute or guess them. + let names = path + .segments + .iter() + .map(|part| part.ident.to_string()) + .collect::>(); + path.leading_colon.is_some() + && names.len() == 4 + && matches!(names[0].as_str(), "core" | "std") + && names[1] == "option" + && names[2] == "Option" + && names[3] == variant +} + fn is_self_receiver(expression: &Expr) -> bool { match expression { Expr::Path(path) => path.path.is_ident("self"), @@ -1233,38 +1383,40 @@ fn expand_domain_commands_module( .map(|event| &event.domain_event_type) .collect::>(); let known_value_items = transition.events.iter().filter_map(|event| { - let state = event.domain_state.as_ref()?; - if event.known_state_values.is_empty() { + let values = if event.domain_state.is_some() { &event.known_state_values } else { &event.known_body_values }; + if values.is_empty() { return None; } let event_type = &event.domain_event_type; - let fields = event.known_state_values.iter().map(|value| { + let helper = if let Some(state) = &event.domain_state { + quote!(distributed::command::__command_projection_state_known_values::) + } else { + quote!(distributed::command::__command_projection_event_preview::) + }; + let fields = values.iter().map(|value| { let field = value.field.to_string(); let source = match &value.source { KnownStateValueSource::Constant(expression) => quote! { - distributed::graphql::__command_projection_preview_constant(#expression) + distributed::command::__command_projection_preview_constant(#expression) }, KnownStateValueSource::Null => quote! { - distributed::graphql::CommandProjectionPreviewSource::Null + distributed::command::CommandProjectionPreviewSource::Null }, }; quote! { (#field, #source) } }); Some(quote! { - distributed::graphql::__command_projection_state_known_values::< - super::#event_type, - #state, - >(vec![#(#fields),*]) + #helper(vec![#(#fields),*]) }) }); let has_known_values = transition .events .iter() - .any(|event| event.domain_state.is_some() && !event.known_state_values.is_empty()); + .any(|event| !event.known_state_values.is_empty() || !event.known_body_values.is_empty()); let known_values_method = has_known_values.then(|| { quote! { fn command_event_known_values( - ) -> Vec { + ) -> Vec { #[allow(unused_imports)] use super::*; vec![#(#known_value_items),*] @@ -1275,17 +1427,17 @@ fn expand_domain_commands_module( "Outward domain-event set for `{aggregate_name}::{method_name}`.\n\n\ Derived from direct `self.()` calls to `#[event(..., domain)]` \ methods in this `#[sourced]` impl. Use with \ - [`distributed::graphql::TypedCommand::emits_events`]." + [`distributed::command::TypedCommand::emits_events`]." ); quote! { #[doc = #doc] pub enum #type_name {} - impl distributed::graphql::CommandEventSet for #type_name { - fn command_event_set() -> distributed::graphql::CommandProjectionEventSet { - distributed::graphql::__command_projection_events([ + impl distributed::command::CommandEventSet for #type_name { + fn command_event_set() -> distributed::command::CommandProjectionEventSet { + distributed::command::__command_projection_events([ #( - distributed::graphql::__command_projection_event_descriptor::< + distributed::command::__command_projection_event_descriptor::< super::#event_types, >() ),* diff --git a/distributed_macros/tests/application.rs b/distributed_macros/tests/application.rs index 3fb9f1146..23a4447d1 100644 --- a/distributed_macros/tests/application.rs +++ b/distributed_macros/tests/application.rs @@ -2,9 +2,9 @@ #![allow(dead_code)] #![allow(unused_imports)] -use distributed::graphql::Succeeded; +use distributed::command::Succeeded; use distributed::microsvc::{CausalCommandContext, HandlerError}; -use distributed::{Aggregate, DomainEvent, Entity, EventRecord, GraphqlInput, GraphqlOutput}; +use distributed::{Aggregate, CommandInput, CommandOutput, DomainEvent, Entity, EventRecord}; use serde::{Deserialize, Serialize}; #[derive(Default)] @@ -32,13 +32,13 @@ impl Aggregate for FixtureAggregate { } } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] pub struct CreateInput { id: String, title: String, } -#[derive(Clone, Serialize, GraphqlOutput)] +#[derive(Clone, Serialize, CommandOutput)] pub struct CreateOutput { id: String, } @@ -66,7 +66,7 @@ pub struct TodoCreated { pub async fn handle( _context: &CausalCommandContext<'_, FixtureAggregate>, _input: CreateInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { unimplemented!() } @@ -98,8 +98,14 @@ fn command_module_and_application_macros_share_one_portable_spec() { assert_eq!(spec.id, "todo.create"); assert_eq!(spec.roles, ["admin", "user"]); assert_eq!(spec.emits[0].name, "todo.created"); - assert!(spec.applies.as_array().is_some_and(|values| !values.is_empty())); - assert!(spec.defaults.as_array().is_some_and(|values| !values.is_empty())); + assert!(spec + .applies + .as_array() + .is_some_and(|values| !values.is_empty())); + assert!(spec + .defaults + .as_array() + .is_some_and(|values| !values.is_empty())); assert!(!spec.effects.is_null()); assert!(!spec.fingerprint.is_empty()); assert_eq!( diff --git a/distributed_macros/tests/command_input_defaults.rs b/distributed_macros/tests/command_input_defaults.rs index c115d1e04..a6104fa3c 100644 --- a/distributed_macros/tests/command_input_defaults.rs +++ b/distributed_macros/tests/command_input_defaults.rs @@ -1,17 +1,17 @@ //! Compile-pass checks for command_input_defaults!. -use distributed::graphql::{typed_command, Succeeded}; -use distributed::{command_input_defaults, GraphqlInput}; +use distributed::command::{typed_command, Succeeded}; +use distributed::{command_input_defaults, CommandInput}; use serde::Deserialize; -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] #[allow(dead_code)] struct PlanInput { id: String, title: String, } -#[derive(Clone, serde::Serialize, distributed::GraphqlOutput)] +#[derive(Clone, serde::Serialize, distributed::CommandOutput)] struct PlanOutput { id: String, } diff --git a/distributed_macros/tests/command_input_defaults_compile_fail/duplicate_input_default.rs b/distributed_macros/tests/command_input_defaults_compile_fail/duplicate_input_default.rs index e5d08c435..0fe6841ba 100644 --- a/distributed_macros/tests/command_input_defaults_compile_fail/duplicate_input_default.rs +++ b/distributed_macros/tests/command_input_defaults_compile_fail/duplicate_input_default.rs @@ -1,6 +1,6 @@ -use distributed::{command_input_defaults, GraphqlInput}; +use distributed::{command_input_defaults, CommandInput}; -#[derive(GraphqlInput)] +#[derive(CommandInput)] struct Input { id: String, } diff --git a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_list.rs b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_list.rs index 407564fbd..71e0cd81f 100644 --- a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_list.rs +++ b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_list.rs @@ -1,6 +1,6 @@ -use distributed::{command_input_defaults, GraphqlInput}; +use distributed::{command_input_defaults, CommandInput}; -#[derive(GraphqlInput)] +#[derive(CommandInput)] struct Input { ids: Vec, } diff --git a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_list.stderr b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_list.stderr index 7111ab6e4..994eef3d9 100644 --- a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_list.stderr +++ b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_list.stderr @@ -11,8 +11,8 @@ note: expected this to be `std::string::String` | ^^^^^^^^^^^ = note: expected struct `std::string::String` found struct `Vec` -note: required by a bound in `distributed::graphql::__input_default_ulid` - --> $WORKSPACE/src/graphql/command_contract/effect_wire.rs +note: required by a bound in `distributed::command::__input_default_ulid` + --> $WORKSPACE/src/command/effect_wire.rs | | pub fn __input_default_ulid() -> CompiledInputDefault | -------------------- required by a bound in this function diff --git a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_nullable.rs b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_nullable.rs index 85b8563d7..39e407d28 100644 --- a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_nullable.rs +++ b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_nullable.rs @@ -1,7 +1,7 @@ -use distributed::{command_input_defaults, GraphqlInput}; +use distributed::{command_input_defaults, CommandInput}; use serde::Deserialize; -#[derive(Deserialize, GraphqlInput)] +#[derive(Deserialize, CommandInput)] struct Input { id: Option, } diff --git a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_nullable.stderr b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_nullable.stderr index 217ed22db..0fc9282ad 100644 --- a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_nullable.stderr +++ b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_nullable.stderr @@ -11,8 +11,8 @@ note: expected this to be `std::string::String` | ^^^^^^^^^^^^^^ = note: expected struct `std::string::String` found enum `std::option::Option` -note: required by a bound in `distributed::graphql::__input_default_uuid_v7` - --> $WORKSPACE/src/graphql/command_contract/effect_wire.rs +note: required by a bound in `distributed::command::__input_default_uuid_v7` + --> $WORKSPACE/src/command/effect_wire.rs | | pub fn __input_default_uuid_v7() -> CompiledInputDefault | ----------------------- required by a bound in this function diff --git a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_wrong_type.rs b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_wrong_type.rs index 79e278999..baef4e689 100644 --- a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_wrong_type.rs +++ b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_wrong_type.rs @@ -1,6 +1,6 @@ -use distributed::{command_input_defaults, GraphqlInput}; +use distributed::{command_input_defaults, CommandInput}; -#[derive(GraphqlInput)] +#[derive(CommandInput)] struct Input { count: i64, } diff --git a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_wrong_type.stderr b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_wrong_type.stderr index 1785685f8..e47640a93 100644 --- a/distributed_macros/tests/command_input_defaults_compile_fail/input_default_wrong_type.stderr +++ b/distributed_macros/tests/command_input_defaults_compile_fail/input_default_wrong_type.stderr @@ -9,8 +9,8 @@ note: expected this to be `std::string::String` | 5 | count: i64, | ^^^ -note: required by a bound in `distributed::graphql::__input_default_uuid_v7` - --> $WORKSPACE/src/graphql/command_contract/effect_wire.rs +note: required by a bound in `distributed::command::__input_default_uuid_v7` + --> $WORKSPACE/src/command/effect_wire.rs | | pub fn __input_default_uuid_v7() -> CompiledInputDefault | ----------------------- required by a bound in this function diff --git a/distributed_macros/tests/command_types_compile_fail.rs b/distributed_macros/tests/command_types_compile_fail.rs new file mode 100644 index 000000000..44321cdf7 --- /dev/null +++ b/distributed_macros/tests/command_types_compile_fail.rs @@ -0,0 +1,7 @@ +//! Compile-time diagnostics specific to CommandInput / CommandOutput. + +#[test] +fn command_types_compile_fail() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/command_types_compile_fail/*.rs"); +} diff --git a/distributed_macros/tests/graphql_compile_fail/nested_lists.rs b/distributed_macros/tests/command_types_compile_fail/nested_lists.rs similarity index 60% rename from distributed_macros/tests/graphql_compile_fail/nested_lists.rs rename to distributed_macros/tests/command_types_compile_fail/nested_lists.rs index 79e900306..60e698d4f 100644 --- a/distributed_macros/tests/graphql_compile_fail/nested_lists.rs +++ b/distributed_macros/tests/command_types_compile_fail/nested_lists.rs @@ -1,6 +1,6 @@ -use distributed::GraphqlInput; +use distributed::CommandInput; -#[derive(GraphqlInput)] +#[derive(CommandInput)] struct NestedLists { values: Option>>>, } diff --git a/distributed_macros/tests/command_types_compile_fail/nested_lists.stderr b/distributed_macros/tests/command_types_compile_fail/nested_lists.stderr new file mode 100644 index 000000000..5a7498fbf --- /dev/null +++ b/distributed_macros/tests/command_types_compile_fail/nested_lists.stderr @@ -0,0 +1,5 @@ +error: nested lists are not supported for CommandInput/CommandOutput fields + --> tests/command_types_compile_fail/nested_lists.rs:5:13 + | +5 | values: Option>>>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_container_default.rs b/distributed_macros/tests/command_types_compile_fail/serde_container_default.rs similarity index 65% rename from distributed_macros/tests/graphql_compile_fail/serde_container_default.rs rename to distributed_macros/tests/command_types_compile_fail/serde_container_default.rs index deb0cb832..43b73b2fe 100644 --- a/distributed_macros/tests/graphql_compile_fail/serde_container_default.rs +++ b/distributed_macros/tests/command_types_compile_fail/serde_container_default.rs @@ -1,6 +1,6 @@ -use distributed::GraphqlInput; +use distributed::CommandInput; -#[derive(GraphqlInput)] +#[derive(CommandInput)] #[serde(default = "default_input")] struct ContainerDefaultInput { value: String, diff --git a/distributed_macros/tests/command_types_compile_fail/serde_container_default.stderr b/distributed_macros/tests/command_types_compile_fail/serde_container_default.stderr new file mode 100644 index 000000000..6a418939b --- /dev/null +++ b/distributed_macros/tests/command_types_compile_fail/serde_container_default.stderr @@ -0,0 +1,5 @@ +error: #[serde(default)] is not supported by CommandInput because it changes the declared command object shape; define a separate wire type + --> tests/command_types_compile_fail/serde_container_default.rs:4:9 + | +4 | #[serde(default = "default_input")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_default.rs b/distributed_macros/tests/command_types_compile_fail/serde_default.rs similarity index 59% rename from distributed_macros/tests/graphql_compile_fail/serde_default.rs rename to distributed_macros/tests/command_types_compile_fail/serde_default.rs index ca4633108..2ccfbcfd5 100644 --- a/distributed_macros/tests/graphql_compile_fail/serde_default.rs +++ b/distributed_macros/tests/command_types_compile_fail/serde_default.rs @@ -1,6 +1,6 @@ -use distributed::GraphqlInput; +use distributed::CommandInput; -#[derive(GraphqlInput)] +#[derive(CommandInput)] struct DefaultedInput { #[serde(default)] value: String, diff --git a/distributed_macros/tests/command_types_compile_fail/serde_default.stderr b/distributed_macros/tests/command_types_compile_fail/serde_default.stderr new file mode 100644 index 000000000..7c3c9e18d --- /dev/null +++ b/distributed_macros/tests/command_types_compile_fail/serde_default.stderr @@ -0,0 +1,5 @@ +error: #[serde(default)] is not supported by CommandInput because it changes the declared command field shape; define a separate wire type + --> tests/command_types_compile_fail/serde_default.rs:5:13 + | +5 | #[serde(default)] + | ^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_skip.rs b/distributed_macros/tests/command_types_compile_fail/serde_skip.rs similarity index 67% rename from distributed_macros/tests/graphql_compile_fail/serde_skip.rs rename to distributed_macros/tests/command_types_compile_fail/serde_skip.rs index e7f761f58..26ff6f4fe 100644 --- a/distributed_macros/tests/graphql_compile_fail/serde_skip.rs +++ b/distributed_macros/tests/command_types_compile_fail/serde_skip.rs @@ -1,6 +1,6 @@ -use distributed::GraphqlOutput; +use distributed::CommandOutput; -#[derive(GraphqlOutput)] +#[derive(CommandOutput)] struct SkippedOutput { #[serde(skip_serializing_if = "Option::is_none")] value: Option, diff --git a/distributed_macros/tests/command_types_compile_fail/serde_skip.stderr b/distributed_macros/tests/command_types_compile_fail/serde_skip.stderr new file mode 100644 index 000000000..6c2a582b3 --- /dev/null +++ b/distributed_macros/tests/command_types_compile_fail/serde_skip.stderr @@ -0,0 +1,5 @@ +error: #[serde(skip_serializing_if)] is not supported by CommandOutput because it changes the declared command field shape; define a separate wire type + --> tests/command_types_compile_fail/serde_skip.rs:5:13 + | +5 | #[serde(skip_serializing_if = "Option::is_none")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_transparent.rs b/distributed_macros/tests/command_types_compile_fail/serde_transparent.rs similarity index 60% rename from distributed_macros/tests/graphql_compile_fail/serde_transparent.rs rename to distributed_macros/tests/command_types_compile_fail/serde_transparent.rs index 238801538..10d99439c 100644 --- a/distributed_macros/tests/graphql_compile_fail/serde_transparent.rs +++ b/distributed_macros/tests/command_types_compile_fail/serde_transparent.rs @@ -1,6 +1,6 @@ -use distributed::GraphqlInput; +use distributed::CommandInput; -#[derive(GraphqlInput)] +#[derive(CommandInput)] #[serde(transparent)] struct TransparentInput { value: String, diff --git a/distributed_macros/tests/command_types_compile_fail/serde_transparent.stderr b/distributed_macros/tests/command_types_compile_fail/serde_transparent.stderr new file mode 100644 index 000000000..9bcd53272 --- /dev/null +++ b/distributed_macros/tests/command_types_compile_fail/serde_transparent.stderr @@ -0,0 +1,5 @@ +error: #[serde(transparent)] is not supported by CommandInput because it changes the declared command object shape; define a separate wire type + --> tests/command_types_compile_fail/serde_transparent.rs:4:9 + | +4 | #[serde(transparent)] + | ^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_with.rs b/distributed_macros/tests/command_types_compile_fail/serde_with.rs similarity index 61% rename from distributed_macros/tests/graphql_compile_fail/serde_with.rs rename to distributed_macros/tests/command_types_compile_fail/serde_with.rs index a921590e1..29cd62f26 100644 --- a/distributed_macros/tests/graphql_compile_fail/serde_with.rs +++ b/distributed_macros/tests/command_types_compile_fail/serde_with.rs @@ -1,6 +1,6 @@ -use distributed::GraphqlOutput; +use distributed::CommandOutput; -#[derive(GraphqlOutput)] +#[derive(CommandOutput)] struct CustomOutput { #[serde(with = "wire_value")] value: String, diff --git a/distributed_macros/tests/command_types_compile_fail/serde_with.stderr b/distributed_macros/tests/command_types_compile_fail/serde_with.stderr new file mode 100644 index 000000000..eb0e9587a --- /dev/null +++ b/distributed_macros/tests/command_types_compile_fail/serde_with.stderr @@ -0,0 +1,5 @@ +error: #[serde(with)] is not supported by CommandOutput because it changes the declared command field shape; define a separate wire type + --> tests/command_types_compile_fail/serde_with.rs:5:13 + | +5 | #[serde(with = "wire_value")] + | ^^^^^^^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail.rs b/distributed_macros/tests/compile_fail.rs index ea5111b5a..a585d2a7a 100644 --- a/distributed_macros/tests/compile_fail.rs +++ b/distributed_macros/tests/compile_fail.rs @@ -5,6 +5,8 @@ //! regressions in diagnostic quality are caught. //! //! Regenerate `.stderr` snapshots with: +//! `rustup component add rust-src` (keeps standard-library source spans aligned +//! with CI), then: //! `TRYBUILD=overwrite cargo test -p distributed_macros` #[test] 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 8c097363e..a61a18d5e 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 @@ -6,13 +6,13 @@ struct ActualInput; id = "todo.mismatch", roles(user), input = ExpectedInput, - outcome = distributed::graphql::Succeeded + outcome = distributed::command::Succeeded )] async fn declared_type_mismatch( _context: &distributed::microsvc::CausalCommandContext<'_, MismatchAggregate>, _input: ActualInput, ) -> Result< - distributed::graphql::PreparedCommand>, + distributed::command::PreparedCommand>, distributed::microsvc::HandlerError, > { unreachable!() diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs b/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs index 38c11ade6..e173a3023 100644 --- a/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs @@ -1,7 +1,7 @@ use std::sync::LazyLock; use distributed::application::{CommandDefinition, CommandSpec, CommandTypeField, CommandTypeSpec}; -use distributed::graphql::CommandConsistency; +use distributed::command::CommandConsistency; fn spec() -> CommandSpec { CommandSpec::try_new( diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_option.rs b/distributed_macros/tests/compile_fail/application_command_duplicate_option.rs index bde61a0fe..bc12b78f4 100644 --- a/distributed_macros/tests/compile_fail/application_command_duplicate_option.rs +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_option.rs @@ -6,13 +6,13 @@ struct Input; id = "todo.rename", roles(user), input = Input, - outcome = distributed::graphql::Succeeded + outcome = distributed::command::Succeeded )] async fn duplicate_option( _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, _input: Input, ) -> Result< - distributed::graphql::PreparedCommand>, + distributed::command::PreparedCommand>, distributed::microsvc::HandlerError, > { unreachable!() diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_role.rs b/distributed_macros/tests/compile_fail/application_command_duplicate_role.rs index d498453c3..b6c08949f 100644 --- a/distributed_macros/tests/compile_fail/application_command_duplicate_role.rs +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_role.rs @@ -5,13 +5,13 @@ struct Input; id = "todo.create", roles(user, user), input = Input, - outcome = distributed::graphql::Succeeded + outcome = distributed::command::Succeeded )] async fn duplicate_role( _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, _input: Input, ) -> Result< - distributed::graphql::PreparedCommand>, + distributed::command::PreparedCommand>, distributed::microsvc::HandlerError, > { unreachable!() diff --git a/distributed_macros/tests/compile_fail/application_command_empty_roles.rs b/distributed_macros/tests/compile_fail/application_command_empty_roles.rs index 6846e99ac..207884b43 100644 --- a/distributed_macros/tests/compile_fail/application_command_empty_roles.rs +++ b/distributed_macros/tests/compile_fail/application_command_empty_roles.rs @@ -5,13 +5,13 @@ struct Input; id = "todo.create", roles(), input = Input, - outcome = distributed::graphql::Succeeded + outcome = distributed::command::Succeeded )] async fn empty_roles( _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, _input: Input, ) -> Result< - distributed::graphql::PreparedCommand>, + distributed::command::PreparedCommand>, distributed::microsvc::HandlerError, > { unreachable!() diff --git a/distributed_macros/tests/compile_fail/application_command_invalid_id.rs b/distributed_macros/tests/compile_fail/application_command_invalid_id.rs index 722f23943..02a3fc5b2 100644 --- a/distributed_macros/tests/compile_fail/application_command_invalid_id.rs +++ b/distributed_macros/tests/compile_fail/application_command_invalid_id.rs @@ -5,13 +5,13 @@ struct Input; id = "../Admin", roles(admin), input = Input, - outcome = distributed::graphql::Succeeded + outcome = distributed::command::Succeeded )] async fn invalid_id( _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, _input: Input, ) -> Result< - distributed::graphql::PreparedCommand>, + distributed::command::PreparedCommand>, distributed::microsvc::HandlerError, > { unreachable!() diff --git a/distributed_macros/tests/compile_fail/application_command_missing_roles.rs b/distributed_macros/tests/compile_fail/application_command_missing_roles.rs index d1aa37d18..46fcc0046 100644 --- a/distributed_macros/tests/compile_fail/application_command_missing_roles.rs +++ b/distributed_macros/tests/compile_fail/application_command_missing_roles.rs @@ -4,13 +4,13 @@ struct Input; #[distributed::command( id = "todo.create", input = Input, - outcome = distributed::graphql::Succeeded + outcome = distributed::command::Succeeded )] async fn missing_roles( _context: &distributed::microsvc::CausalCommandContext<'_, Aggregate>, _input: Input, ) -> Result< - distributed::graphql::PreparedCommand>, + distributed::command::PreparedCommand>, distributed::microsvc::HandlerError, > { unreachable!() 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 f022ebcc3..b744a6296 100644 --- a/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs +++ b/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs @@ -5,7 +5,7 @@ struct WrongInput; id = "todo.create", roles(user), input = WrongInput, - outcome = distributed::graphql::Succeeded + outcome = distributed::command::Succeeded )] async fn wrong_handler( _context: &distributed::microsvc::CausalCommandContext<'_, WrongAggregate>, diff --git a/distributed_macros/tests/graphql_compile_fail.rs b/distributed_macros/tests/graphql_compile_fail.rs deleted file mode 100644 index 2cbead39d..000000000 --- a/distributed_macros/tests/graphql_compile_fail.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Compile-time diagnostics specific to GraphqlInput / GraphqlOutput. - -#[test] -fn graphql_compile_fail() { - let t = trybuild::TestCases::new(); - t.compile_fail("tests/graphql_compile_fail/*.rs"); -} diff --git a/distributed_macros/tests/graphql_compile_fail/nested_lists.stderr b/distributed_macros/tests/graphql_compile_fail/nested_lists.stderr deleted file mode 100644 index b3159e93f..000000000 --- a/distributed_macros/tests/graphql_compile_fail/nested_lists.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: nested lists are not supported for GraphqlInput/GraphqlOutput fields - --> tests/graphql_compile_fail/nested_lists.rs:5:13 - | -5 | values: Option>>>, - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_container_default.stderr b/distributed_macros/tests/graphql_compile_fail/serde_container_default.stderr deleted file mode 100644 index 6ad3ec642..000000000 --- a/distributed_macros/tests/graphql_compile_fail/serde_container_default.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: #[serde(default)] is not supported by GraphqlInput because it changes the declared GraphQL object shape; define a separate wire type - --> tests/graphql_compile_fail/serde_container_default.rs:4:9 - | -4 | #[serde(default = "default_input")] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_default.stderr b/distributed_macros/tests/graphql_compile_fail/serde_default.stderr deleted file mode 100644 index a2fc841e6..000000000 --- a/distributed_macros/tests/graphql_compile_fail/serde_default.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: #[serde(default)] is not supported by GraphqlInput because it changes the declared GraphQL field shape; define a separate wire type - --> tests/graphql_compile_fail/serde_default.rs:5:13 - | -5 | #[serde(default)] - | ^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_kebab_case.rs b/distributed_macros/tests/graphql_compile_fail/serde_kebab_case.rs deleted file mode 100644 index 375917b7e..000000000 --- a/distributed_macros/tests/graphql_compile_fail/serde_kebab_case.rs +++ /dev/null @@ -1,9 +0,0 @@ -use distributed::GraphqlOutput; - -#[derive(GraphqlOutput)] -#[serde(rename_all = "kebab-case")] -struct KebabCase { - field_name: String, -} - -fn main() {} diff --git a/distributed_macros/tests/graphql_compile_fail/serde_kebab_case.stderr b/distributed_macros/tests/graphql_compile_fail/serde_kebab_case.stderr deleted file mode 100644 index 1197d3830..000000000 --- a/distributed_macros/tests/graphql_compile_fail/serde_kebab_case.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: serde kebab-case field names cannot be represented in GraphQL; use camelCase or snake_case - --> tests/graphql_compile_fail/serde_kebab_case.rs:4:22 - | -4 | #[serde(rename_all = "kebab-case")] - | ^^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_skip.stderr b/distributed_macros/tests/graphql_compile_fail/serde_skip.stderr deleted file mode 100644 index dde249172..000000000 --- a/distributed_macros/tests/graphql_compile_fail/serde_skip.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: #[serde(skip_serializing_if)] is not supported by GraphqlOutput because it changes the declared GraphQL field shape; define a separate wire type - --> tests/graphql_compile_fail/serde_skip.rs:5:13 - | -5 | #[serde(skip_serializing_if = "Option::is_none")] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_transparent.stderr b/distributed_macros/tests/graphql_compile_fail/serde_transparent.stderr deleted file mode 100644 index c8d1159dc..000000000 --- a/distributed_macros/tests/graphql_compile_fail/serde_transparent.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: #[serde(transparent)] is not supported by GraphqlInput because it changes the declared GraphQL object shape; define a separate wire type - --> tests/graphql_compile_fail/serde_transparent.rs:4:9 - | -4 | #[serde(transparent)] - | ^^^^^^^^^^^ diff --git a/distributed_macros/tests/graphql_compile_fail/serde_with.stderr b/distributed_macros/tests/graphql_compile_fail/serde_with.stderr deleted file mode 100644 index 0707da8a9..000000000 --- a/distributed_macros/tests/graphql_compile_fail/serde_with.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: #[serde(with)] is not supported by GraphqlOutput because it changes the declared GraphQL field shape; define a separate wire type - --> tests/graphql_compile_fail/serde_with.rs:5:13 - | -5 | #[serde(with = "wire_value")] - | ^^^^^^^^^^^^^^^^^^^ diff --git a/docs/gateway/application.md b/docs/gateway/application.md new file mode 100644 index 000000000..e7455d466 --- /dev/null +++ b/docs/gateway/application.md @@ -0,0 +1,33 @@ +# Application composition + +Declare a gateway alongside the typed service application: + +```rust,ignore +let application = service.application("site", surface)?.with_gateway("public", &gateway)?; +let selector = MountSelector::gateway("public")?; +let runtime = Runtime::default().mount_gateway(&application, selector.clone(), gateway)?; +let adapter = runtime.bind_gateway(&selector, |gateway| build_adapter(gateway))?; +``` + +The manifest records logical binding IDs and capabilities as a versioned +application extension. Physical origins, route paths, secrets and host placement +are excluded. Deployment plans use the existing extension mount algebra. +Selection validates the declaration before a host factory runs. An unselected +factory is never invoked; a gateway mount does not select domain workers, +projectors, stores or an embedded GraphQL server. Native and Wasm UI/auth-only +consumers remain independent of GraphQL/SQL dependencies. + +The e2e-ui sample explicitly combines its existing domain host with the public +native gateway. `/graphql` owns API failures; `/auth` and `/api/auth` reach +SvelteKit, `/` serves UI, and service health/lifecycle/Zitadel ingress paths keep +explicit owners. SvelteKit is an internal upstream with no reverse API proxy. +`PUBLIC_ORIGIN` defaults to `http://localhost:8791`, `UI_INTERNAL_ORIGIN` to +`http://localhost:5180`. Configure OIDC callbacks for the public origin. +`GATEWAY_DELIVERY=none` (default) creates no delivery coordinator; `all` selects +bounded cache/flight/live resources and transactional dependency version hooks. +The logical application still uses the existing command sealing and lifecycle +protocols, including `APPLICATION_RELOADING`. + +Rollback consists of reverting the sample gateway mount and public-origin +configuration together. Disabling delivery independently preserves command and +query correctness and leaves persisted domain data intact. diff --git a/docs/gateway/live-sharing.md b/docs/gateway/live-sharing.md new file mode 100644 index 000000000..1ef09998d --- /dev/null +++ b/docs/gateway/live-sharing.md @@ -0,0 +1,53 @@ +# Shared live GraphQL + +Enable `gateway-graphql-native,gateway-delivery`, select `live_sharing` on the +GraphQL declaration, and bind `NativeDelivery::live(LiveLimits::default())`. +Snapshot caching, concurrent queries and live sharing can be selected separately +with `NativeDeliveryOptions`. The origin needs the authenticated delivery control +path and version coverage described in [snapshot-cache.md](snapshot-cache.md). +Custom embedded executor routers conservatively keep independent subscriptions; +the canonical engine and eligible remote whole-operation executors support sharing. + +Every consumer is admitted by the origin, including credentials supplied through +GraphQL `connection_init`. The gateway passes these credentials to the backend's +existing validator; it does not derive a subject from cookies or unverified JWTs. +A remote client initially uses a temporary origin WebSocket for the real connection +acknowledgement. It owns no subscription and closes before operation coordination. +Thus 100 clients incur 100 admissions and handshake costs but can share one steady +upstream subscription and producer. HTTP control requests do not execute result SQL. + +Groups bind the exact document, variables, origin subject/cache scope, policy, +schema and protocol. Each consumer keeps its own transport ID, expiry, queue and +freshness requirements. Different resume cursors start independent replay. Handoff +requires the same operation plus an exact supported, comparable cursor vector and +matching data; the consumer's replay frames remain queued before future shared +frames. Unknown cursors keep independent streams. Equal data alone never proves +cursor equality. Duplicate suppression hashes the whole data and protocol envelope, +so new confirmation evidence is delivered even when values are unchanged. + +Defaults bound a coordinator to 256 groups, 1,024 consumers per group, 16 pending +frames per consumer, 1 MiB per full frame, eight retained history frames and a +one-hour group lifetime. Native ingress bounds socket/request counts and wire +message sizes as well. Frames share immutable storage across consumer queues. +If a slow consumer cannot preserve all evidence within its queue, it receives +`LIVE_RESET_REQUIRED`; a blocked socket is closed so it must reconnect. No +latest-value replacement silently discards confirmation proof. Group deadline, +upstream loss and incomplete/invalid origin envelopes also require recovery. + +Dropping a consumer releases only its lease. Last leave aborts the actual upstream +stream, socket and origin change-feed receiver, including a pending origin SQL +read. An expired credential cannot continue receiving shared frames or own other +consumers' upstream: the remaining valid consumer reconnects with its own origin +credential and the last proven resume cursor. Normal GraphQL completion drains +queued frames; unexpected transport loss requires reset. Reconnect authenticates +again. Shared outbound sockets do not hibernate for free. + +`NativeDelivery::live_counts()` reports active groups, consumers, cumulative source +attempts, resets, upstream frames, exact duplicate frames and safe handoffs. Source +attempts include credential reconnects; they are not a count of active producers. +`GraphqlEngine::live_subscriber_count()` separately reports actual origin producer +subscriptions. No credentials or subject identifiers appear in these counters. + +Disable `live_sharing` and remove the live delivery resource to restore independent +subscriptions. Existing client resume/reset and response-sealing behavior remains +in force. No migration or new credential is required by live coordination itself. diff --git a/docs/gateway/load-report.json b/docs/gateway/load-report.json new file mode 100644 index 000000000..57c9c38c0 --- /dev/null +++ b/docs/gateway/load-report.json @@ -0,0 +1,839 @@ +{ + "version": 1, + "revision": "eaa2d5d2bc6b104b89d6ccee12fd4fca11791a3e", + "candidateChanges": false, + "node": "v24.20.0", + "platform": "darwin", + "participants": 100, + "repetitions": 1, + "payloadCharacters": 4096, + "measurement": "actual HTTP bodies and WebSocket application frames; excludes headers/TLS/framing", + "limits": { + "snapshotEntries": 128, + "snapshotBytes": 2097152, + "entryBytes": 262144, + "flightGroups": 8, + "consumers": 128, + "flightBytes": 262144, + "liveGroups": 4, + "liveFrameBytes": 65536 + }, + "rows": [ + { + "host": "native", + "mode": "none", + "query": { + "origin": { + "producers": 0, + "resultExecutions": 100, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 74600, + "httpResponseBytes": 519200, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientResponseBytes": 519200, + "latencyMs": { + "min": 147.129375, + "p50": 198.30575000000002, + "p95": 250.39304199999998, + "max": 257.099875 + } + }, + "live": { + "logicalSubscriptions": 100, + "upstreamProducers": 100, + "origin": { + "producers": 100, + "resultExecutions": 200, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 0, + "httpResponseBytes": 0, + "wsRequestBytes": 76290, + "wsResponseBytes": 1104080, + "wsConnections": 100 + }, + "clientFrameBytes": 1104080, + "initialLatencyMs": { + "min": 21.134625000000142, + "p50": 23.260374999999954, + "p95": 24.767499999999927, + "max": 26.905917000000045 + }, + "commitFanoutMs": 299.9157919999998 + }, + "decisions": [], + "subjectControls": "query and live scope differ; separate producer", + "invalidation": "new projection returned; old cache envelope rejected" + }, + { + "host": "native", + "mode": "flights", + "query": { + "origin": { + "producers": 0, + "resultExecutions": 1, + "validations": 101 + }, + "wire": { + "httpRequestBytes": 76194, + "httpResponseBytes": 90330, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientResponseBytes": 528800, + "latencyMs": { + "min": 339.3542910000001, + "p50": 340.51858300000004, + "p95": 342.00416700000005, + "max": 342.293416 + } + }, + "live": { + "logicalSubscriptions": 100, + "upstreamProducers": 100, + "origin": { + "producers": 100, + "resultExecutions": 200, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 0, + "httpResponseBytes": 0, + "wsRequestBytes": 76290, + "wsResponseBytes": 1104080, + "wsConnections": 100 + }, + "clientFrameBytes": 1104080, + "initialLatencyMs": { + "min": 20.867291999999907, + "p50": 23.03570899999977, + "p95": 24.76141699999971, + "max": 26.264917000000423 + }, + "commitFanoutMs": 306.95758300000034 + }, + "decisions": [ + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + null, + 1 + ], + "query": [ + 0, + 0, + 0 + ] + } + ], + "subjectControls": "query and live scope differ; separate producer", + "invalidation": "new projection returned; old cache envelope rejected" + }, + { + "host": "native", + "mode": "snapshots", + "query": { + "origin": { + "producers": 0, + "resultExecutions": 0, + "validations": 100 + }, + "wire": { + "httpRequestBytes": 74700, + "httpResponseBytes": 84200, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientResponseBytes": 528800, + "latencyMs": { + "min": 116.18020800000068, + "p50": 200.55029100000047, + "p95": 250.50162499999988, + "max": 255.24691600000006 + }, + "warmup": { + "origin": { + "producers": 0, + "resultExecutions": 1, + "validations": 2 + }, + "wire": { + "httpRequestBytes": 2241, + "httpResponseBytes": 6972, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientBytes": 5288 + } + }, + "live": { + "logicalSubscriptions": 100, + "upstreamProducers": 100, + "origin": { + "producers": 100, + "resultExecutions": 200, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 0, + "httpResponseBytes": 0, + "wsRequestBytes": 76290, + "wsResponseBytes": 1104780, + "wsConnections": 100 + }, + "clientFrameBytes": 1104780, + "initialLatencyMs": { + "min": 20.942458000000443, + "p50": 22.93787499999962, + "p95": 24.133708000000297, + "max": 25.488624999999956 + }, + "commitFanoutMs": 301.81533299999865 + }, + "decisions": [ + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + { + "fillBypasses": 0, + "hits": 100, + "invalidations": 1, + "misses": 3, + "staleRejections": 1 + }, + 1 + ], + "query": [ + 0, + 0, + 0 + ] + } + ], + "subjectControls": "query and live scope differ; separate producer", + "invalidation": "new projection returned; old cache envelope rejected" + }, + { + "host": "native", + "mode": "live", + "recovery": { + "origin": { + "producers": 0, + "resultExecutions": 1, + "validations": 1 + }, + "wire": { + "httpRequestBytes": 928, + "httpResponseBytes": 842, + "wsRequestBytes": 1610, + "wsResponseBytes": 5470, + "wsConnections": 2 + }, + "clientFrameBytes": 5440, + "cursorResets": 1, + "slowClientResets": 0 + }, + "query": { + "origin": { + "producers": 0, + "resultExecutions": 100, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 74600, + "httpResponseBytes": 519600, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientResponseBytes": 519600, + "latencyMs": { + "min": 140.48958400000083, + "p50": 213.07474999999977, + "p95": 254.67654200000106, + "max": 262.21537499999977 + } + }, + "live": { + "logicalSubscriptions": 100, + "upstreamProducers": 1, + "origin": { + "producers": 1, + "resultExecutions": 2, + "validations": 100 + }, + "wire": { + "httpRequestBytes": 75800, + "httpResponseBytes": 84200, + "wsRequestBytes": 66385, + "wsResponseBytes": 13564, + "wsConnections": 101 + }, + "clientFrameBytes": 1105180, + "initialLatencyMs": { + "min": 20.779709000000366, + "p50": 22.705541999999696, + "p95": 24.23895900000025, + "max": 25.080291999998735 + }, + "commitFanoutMs": 170.7697500000013 + }, + "decisions": [ + { + "live": [ + 0, + 0, + 3, + 0, + 4, + 0, + 0 + ], + "metrics": [ + null, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + } + ], + "subjectControls": "query and live scope differ; separate producer", + "invalidation": "new projection returned; old cache envelope rejected" + }, + { + "host": "workerd", + "mode": "none", + "query": { + "origin": { + "producers": 0, + "resultExecutions": 100, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 74600, + "httpResponseBytes": 519600, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientResponseBytes": 519600, + "latencyMs": { + "min": 225.62924999999996, + "p50": 400.4289169999993, + "p95": 453.3817910000016, + "max": 461.52195799999936 + } + }, + "live": { + "logicalSubscriptions": 100, + "upstreamProducers": 100, + "origin": { + "producers": 100, + "resultExecutions": 200, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 0, + "httpResponseBytes": 0, + "wsRequestBytes": 76290, + "wsResponseBytes": 1105180, + "wsConnections": 100 + }, + "clientFrameBytes": 1105180, + "initialLatencyMs": { + "min": 23.570792000000438, + "p50": 25.99616700000115, + "p95": 28.189124999997148, + "max": 29.651667000001908 + }, + "commitFanoutMs": 346.484375 + }, + "decisions": [], + "subjectControls": "query and live scope differ; separate producer", + "invalidation": "new projection returned; old cache envelope rejected" + }, + { + "host": "workerd", + "mode": "flights", + "query": { + "origin": { + "producers": 0, + "resultExecutions": 1, + "validations": 101 + }, + "wire": { + "httpRequestBytes": 76194, + "httpResponseBytes": 90334, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientResponseBytes": 529200, + "latencyMs": { + "min": 744.4551250000004, + "p50": 755.8567079999993, + "p95": 767.9520410000005, + "max": 769.9147080000002 + } + }, + "live": { + "logicalSubscriptions": 100, + "upstreamProducers": 100, + "origin": { + "producers": 100, + "resultExecutions": 200, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 0, + "httpResponseBytes": 0, + "wsRequestBytes": 144100, + "wsResponseBytes": 1108900, + "wsConnections": 200 + }, + "clientFrameBytes": 1127360, + "initialLatencyMs": { + "min": 22.836959000000206, + "p50": 25.345250000002125, + "p95": 28.244916999999987, + "max": 38.16112500000236 + }, + "commitFanoutMs": 335.93595800000185 + }, + "decisions": [ + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + null, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + null, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + null, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + null, + 1 + ], + "query": [ + 0, + 0, + 0 + ] + } + ], + "subjectControls": "query and live scope differ; separate producer", + "invalidation": "new projection returned; old cache envelope rejected" + }, + { + "host": "workerd", + "mode": "snapshots", + "query": { + "origin": { + "producers": 0, + "resultExecutions": 0, + "validations": 100 + }, + "wire": { + "httpRequestBytes": 74700, + "httpResponseBytes": 84200, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientResponseBytes": 529200, + "latencyMs": { + "min": 40.5157500000023, + "p50": 526.3143750000017, + "p95": 632.445499999998, + "max": 632.5337080000027 + }, + "warmup": { + "origin": { + "producers": 0, + "resultExecutions": 1, + "validations": 2 + }, + "wire": { + "httpRequestBytes": 2241, + "httpResponseBytes": 6976, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientBytes": 5292 + } + }, + "live": { + "logicalSubscriptions": 100, + "upstreamProducers": 100, + "origin": { + "producers": 100, + "resultExecutions": 200, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 0, + "httpResponseBytes": 0, + "wsRequestBytes": 144100, + "wsResponseBytes": 1108900, + "wsConnections": 200 + }, + "clientFrameBytes": 1127360, + "initialLatencyMs": { + "min": 22.649750000000495, + "p50": 25.527957999998762, + "p95": 28.2020420000008, + "max": 40.410916000000725 + }, + "commitFanoutMs": 369.64583400000265 + }, + "decisions": [ + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + { + "fillBypasses": 0, + "hits": 100, + "invalidations": 1, + "misses": 3, + "staleRejections": 1 + }, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + { + "fillBypasses": 0, + "hits": 0, + "invalidations": 1, + "misses": 0, + "staleRejections": 0 + }, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + { + "fillBypasses": 0, + "hits": 0, + "invalidations": 1, + "misses": 0, + "staleRejections": 0 + }, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + { + "fillBypasses": 0, + "hits": 0, + "invalidations": 1, + "misses": 0, + "staleRejections": 0 + }, + 1 + ], + "query": [ + 0, + 0, + 0 + ] + } + ], + "subjectControls": "query and live scope differ; separate producer", + "invalidation": "new projection returned; old cache envelope rejected" + }, + { + "host": "workerd", + "mode": "live", + "recovery": { + "origin": { + "producers": 0, + "resultExecutions": 22, + "validations": 3 + }, + "wire": { + "httpRequestBytes": 2444, + "httpResponseBytes": 2526, + "wsRequestBytes": 3707, + "wsResponseBytes": 123326, + "wsConnections": 5 + }, + "clientFrameBytes": 131320, + "cursorResets": 1, + "slowClientResets": 1 + }, + "query": { + "origin": { + "producers": 0, + "resultExecutions": 100, + "validations": 0 + }, + "wire": { + "httpRequestBytes": 74600, + "httpResponseBytes": 519600, + "wsRequestBytes": 0, + "wsResponseBytes": 0, + "wsConnections": 0 + }, + "clientResponseBytes": 519600, + "latencyMs": { + "min": 143.1261669999949, + "p50": 374.0097079999978, + "p95": 432.9804170000061, + "max": 435.25541599999997 + } + }, + "live": { + "logicalSubscriptions": 100, + "upstreamProducers": 1, + "origin": { + "producers": 1, + "resultExecutions": 2, + "validations": 100 + }, + "wire": { + "httpRequestBytes": 75800, + "httpResponseBytes": 84200, + "wsRequestBytes": 66385, + "wsResponseBytes": 13564, + "wsConnections": 101 + }, + "clientFrameBytes": 1120811, + "initialLatencyMs": { + "min": 22.425999999999476, + "p50": 34.62895800000115, + "p95": 37.213792000002286, + "max": 69.46099999999569 + }, + "commitFanoutMs": 241.35470899999927 + }, + "decisions": [ + { + "live": [ + 0, + 0, + 4, + 1, + 25, + 0, + 0 + ], + "metrics": [ + null, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + null, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + null, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + }, + { + "live": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "metrics": [ + null, + 0 + ], + "query": [ + 0, + 0, + 0 + ] + } + ], + "subjectControls": "query and live scope differ; separate producer", + "invalidation": "new projection returned; old cache envelope rejected" + } + ] +} diff --git a/docs/gateway/metrics.md b/docs/gateway/metrics.md new file mode 100644 index 000000000..6fac87025 --- /dev/null +++ b/docs/gateway/metrics.md @@ -0,0 +1,45 @@ +# Delivery measurements + +`GatewayVersionStore::metrics()` counts actual compiled result SQL separately +from authenticated origin validation requests. `NativeDelivery::metrics()` and +`WorkerCoordinator::metrics()` expose optional `SnapshotMetrics` plus query +admission bypass counts. Cache decisions include hits, misses, resident stale +rejections, fill bypasses and explicit invalidations. These are cumulative, +identifier-free observations within a coordinator lifetime. Live counts expose +active groups/consumers, upstream source attempts, resets, received frames, +duplicates and handoffs; query counts expose active flights/consumers. Export +these through application-owned telemetry, with deployment/host/mode labels only. +No subject, token, document, variables or scope hashes are metric labels. + +Run the actual load fixture after installing `tests/gateway-worker` and +worker-build as described in [worker.md](worker.md): + +```sh +cargo test --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --test graphql_query_protocol separate_origin_and_client_savings -- --ignored --nocapture +``` + +`tests/gateway-worker/artifacts/load-report.json` records the source revision and +whether it was dirty, runtime/platform, repetitions, payload size and resource +limits. Native and workerd each run disabled, coalescing-only, snapshot-only and +live-only modes. A barrier holds actual SQL until100 coalesced consumers join. +Snapshots report warmup separately before100 hits. Every mode holds100 live +consumers during a real projection commit; result SQL, origin validation, steady +producers and all ongoing/temporary origin WebSocket connections are measured. +Different-subject controls prove private isolation. External writes, explicit +cache invalidation, cursor-gap resets and a slow Worker consumer expose recovery +costs. Native bounded-queue behavior is additionally covered by native live tests. + +The metering proxy counts actual HTTP request/response bodies and WebSocket +application frames, including origin authentication/validation control traffic. +Browser/client response bytes are counted independently. Header, TLS and frame +encoding overhead are explicitly excluded. No per-browser byte savings are +inferred from producer savings. Latency distributions and full fanout completion +are reported without universal thresholds. The deterministic correctness gates +are SQL/producer counts, causal data equality, isolation and teardown. + +With only live sharing selected, ordinary HTTP queries do not incur query +validation. The Worker modern WebSocket adapter may perform temporary origin +authentication handshakes even when query-only delivery is selected; those +connections and bytes are included, not hidden as steady-state producers. +Outgoing live sockets do not hibernate. Disable metric export independently of +resource bounds/correctness; coordinator restart starts a new counter series. diff --git a/docs/gateway/query-coalescing.md b/docs/gateway/query-coalescing.md new file mode 100644 index 000000000..a120f4299 --- /dev/null +++ b/docs/gateway/query-coalescing.md @@ -0,0 +1,42 @@ +# Shared concurrent queries + +Mount `NativeDelivery::coalescing(FlightLimits)` with the GraphQL binding's +`coalescing` capability. This creates no snapshot cache. To select both, use +`NativeDelivery::new(NativeDeliveryOptions { snapshots: Some(...), coalescing: +Some(...), ..Default::default() })`. All capabilities remain explicit and independently removable. +The origin uses the authenticated delivery control path and version store +described in [snapshot-cache.md](snapshot-cache.md). + +Every consumer authenticates and validates at the origin before joining. A group +key binds the exact origin subject/scope, operation/variables/extensions, current +dependency validator, and exact freshness requirements. Different or stronger +floors conservatively form different groups; no minimum is weakened to improve +sharing. Mutations, status operations and unknown origin eligibility never join. +The result retains its own complete data and protocol envelope. In-flight sharing +can serve an otherwise successful admitted query that lacks future cache eligibility; +any required minima must still be proven by that response. + +An operation owns one shared future. The registry retains a weak reference, and +each consumer holds a cancellation lease. Dropping one lease leaves the other +consumers' work running. Dropping the last lease immediately drops the upstream +future; no detached task remains as a hidden owner. Each response waits for its +consumer's own credential expiry deadline, and expiry is checked again before +delivery. The origin is also revalidated after result execution to detect a +changed scope/policy. A completed group is removed when its consumers finish; +with the cache disabled, a later nonoverlapping query executes normally. + +Default limits are 256 active groups, 1,024 consumers per group, a 30-second +operation deadline, and 1 MiB of complete response bytes per group. Native ingress +capacity also bounds all active requests. A full registry rejects additional +joins without evicting work needed by existing consumers. Expired generation +identities cannot release a newer same-key group. Oversized streams, errors, +cookie-setting and otherwise nonshareable responses go to one consumer; other +consumers execute normally with their own admission and freshness. They are never +stored as successful shared snapshots. + +`NativeDelivery::flight_counts()` exposes active groups/consumers without +identifiers. Origin metrics distinguish result SQL from per-consumer validation. +Portable `FlightKey`, `FlightLimits` and `FlightRegistry` provide the same bounded +identity, generation and refcount contracts for runtime adapters. They contain +no timers, network clients, SQL pools, or detached tasks. The Worker/DO adapter +owns its own runtime scheduling and cancellation. diff --git a/docs/gateway/snapshot-cache.md b/docs/gateway/snapshot-cache.md new file mode 100644 index 000000000..d00c6fa2a --- /dev/null +++ b/docs/gateway/snapshot-cache.md @@ -0,0 +1,65 @@ +# Origin-validated query snapshots + +Enable `gateway-graphql-native,gateway-delivery` and the origin's SQL adapter. +The portable `gateway-delivery` feature alone has no SQL or native runtime. +Caching is opt-in: mount `NativeBinding::GraphqlWithDelivery` with an explicit +`NativeDelivery::snapshots(SnapshotLimits)` resource and declare `snapshots` +on that binding. An unselected capability allocates no cache. + +On the origin, after framework and application migrations, install +`GatewayVersionStore::install(&pool, application_namespace, physical_tables)` +and pass the resulting store to `GraphqlEngine::builder(...).gateway_versions(store)`. +The inventory must cover every table touched by an eligible compiled query, +including relationship targets and intermediate join tables. Installed projection +proof tables are included automatically because even a no-op projection can +change the response's evidence. This is conservative across projection partitions. + +Each consumer sends an authenticated validation request to the configured origin +using the existing GraphQL endpoint with the reserved `gatewayDelivery` extension. +Origin authentication, schema validation and the compiler's row/field authorization +run for every request. Validation executes a single dependency-vector SQL statement +inside a primary read snapshot; it executes no result SQL. The gateway accepts the +identity and opaque validator only from that origin response. Public request metadata +cannot grant cache scope. The existing executor still owns commands and command status. + +A miss executes the query with its data, causal metadata and validator in the same +SQL snapshot. The gateway revalidates after the fill and installs only if its own +validator is still current. Every hit revalidates on the primary, so delayed or lost +invalidation notifications cannot certify old private data. Authentication/primary +failure never falls back to cached private data. Missing dependency hooks make the +operation bypass reuse; unknown/custom resolver dependencies, commands/status, +errors, partial results, cookie-setting responses, `no-store`, and `Vary: *` also +bypass storage. Required client freshness minima must be covered by the stored proof. + +Defaults are 1,024 entries, 16 MiB total response bytes and 1 MiB per response. +Oversized/streaming responses continue to the consumer without truncation or storage. +Native response-header/read deadlines bound validation and capture. Entries use LRU +eviction; `invalidate_all()` also fences fills that began before the reset. +`GatewayVersionStore::metrics()` reports origin validations separately from actual +result SQL executions; these counters do not contain subjects or query text. + +For explicitly public content, `store.public_snapshot(exact_document, operation_name, +max_age_seconds)` permits 1–86,400 seconds of content age. This applies to all variable +values of that exact operation. It still requires fresh origin admission and preserves +subject isolation. Age starts at the original origin validation and is never extended +by copying an entry, a new admission, or another cache layer. The default policy always +requires the current version vector. + +## Activation and rollback + +Migration `0006_gateway_dependency_versions` is additive and registered in the normal +SQLite/PostgreSQL migration inventory. No application data is backfilled or rewritten. +Activation installs transactional write hooks and starts a new random epoch. Normal +SQL producers, Eventual projectors, Atomic projection commits, deletes, and PostgreSQL +TRUNCATE all update versions in their data transaction. A failed transaction rolls +versions back with the data. Privileged writers that disable/drop hooks are unsupported +until coverage is restored; validation detects missing/disabled hooks and bypasses reuse. +Applications must not manually replace framework hooks with different implementations. + +Install after application migrations, before enabling cache bindings. Rebuild or change +writer coverage in a controlled migration window, reinstall hooks/start a new epoch, +then invalidate gateway stores before reopening traffic. `rotate_epoch` is available +for an explicit rebuild boundary. Epochs are random identities, not inferred wall clocks +or PostgreSQL WAL positions. Disable the cache binding on rollback; leave additive +metadata in place until a separately planned cleanup. Never apply cleanup to a live DB +as part of a gateway cache rollback. diff --git a/docs/gateway/worker.md b/docs/gateway/worker.md new file mode 100644 index 000000000..e54826841 --- /dev/null +++ b/docs/gateway/worker.md @@ -0,0 +1,103 @@ +# Worker ingress and Durable Object delivery + +Enable `gateway-worker` to use `distributed::gateway::worker`. It compiles to +`wasm32-unknown-unknown` with workers-rs and the portable gateway/delivery +contracts. It links no Axum, Tokio runtime, SQLx, GraphQL server or domain bus. +workers-rs includes Tokio with no features for utility types. Commands +continue to execute at the configured backend; the Worker is ingress. + +An application constructs `WorkerGateway` with its portable `GatewayConfig`, +explicit `WorkerBinding` resources, `WorkerOptions` and `WorkerAuth`. Mount +`gateway.fetch(request, env)` from the Worker fetch entrypoint. Bind handlers, +an asset service, UI/auth reverse proxies, or a whole remote GraphQL endpoint. +Routes use the same exact/longest-segment ownership as native ingress. A selected +handler's error is terminal. The public origin is configured, never taken from +incoming forwarding headers. The adapter retains Origin and duplicate cookies, +rewrites private-origin redirects and rejects proxy loops. Fetch requires Host +to match the fetched URL; trusted public authority reaches delegated handlers +through configured origin and forwarded headers. + +`WorkerAuth::new` accepts an application-owned asynchronous session/provider +implementation returning the shared `RequestContext`. It must validate the +credential before constructing identity or a backend bearer credential. +`WorkerAuth::anonymous()` accepts opaque UI/auth cookies but rejects HTTP bearer +input. It does not turn a cookie or decoded JWT into identity. Auth.js remains +the session/callback/refresh/logout owner when delegated; no second identity +store is introduced. GraphQL connection_init credentials are forwarded to and +validated by the backend. WebSocket commands/status stay on that backend socket +identity path. HTTP query reuse for a WebSocket operation requires explicit +origin control-protocol recognition; older/custom origins remain independent. + +## Optional coordination + +A `WorkerDeliveryBinding` declares namespace, epoch, shard count and independent +snapshot/coalescing/live resources. With `delivery: None`, forwarding creates no +coordinator. It also works with a backend that has no delivery control protocol. +Declare a workers-rs Durable Object in application code and construct one +`Rc` in its `new`. Route its fetch method through +`gateway.fetch_coordinated(request, env, coordinator)`. This is an in-process +mount; public headers cannot assert that admission already happened. + +All ingress instances must use the same namespace, binding name, epoch and shard +count. Operation documents, selected operation and canonical variables select a +shard. This routing hash conveys no authorization. The DO repeats provider +admission and obtains a fresh origin validation for **every** consumer before +reuse. The actual cache/flight/live keys retain origin-resolved application, +endpoint, subject scope, schema/policy versions and freshness floors. Private +hits still cost origin validation; 100 eligible query consumers can share one +result SQL execution. WebSocket queries require a control capability check +before the HTTP reuse path and therefore have additional validation overhead. + +Use Worker-sized limits, for example the explicit limits in +[`tests/gateway-worker/src/lib.rs`](../../tests/gateway-worker/src/lib.rs). +Native defaults intentionally do not fit the Worker retained-payload budget. +The adapter rejects selected configurations exceeding 16 MiB of reserved cache, +flight and live payload. Live-frame charges follow actual shared ownership, +including consumer queues retained across handoff, and force explicit reset if +the budget is exhausted. This bounds wire payload, not the entire runtime heap: +parsed JSON, credentials, active requests, sockets and the application's own +allocations need additional memory headroom. Groups, consumers, frame sizes, +queue length, response sizes and operation lifetimes have independent limits. + +Modern GraphQL subscriptions use standard ping/pong payload echo as delivery +credits: the supplied JS transport echoes the payload. Full data and confirmation +proof stay unchanged. A client that does not acknowledge is reset instead of +silently accumulating data. Raw UI and legacy GraphQL sockets cannot assume that +protocol; they have bounded callback queues and cumulative delivery limits, +after which reconnect is required. `websocket_buffer_bytes` bounds a complete +frame and aggregate queued wire bytes per socket; arbitrary UI delivery is +limited to eight times this value per connection. Customize limits for the UI's +protocol. Outgoing origin sockets remain active and do not hibernate. One shared +steady-state producer does not mean one lifetime handshake: every consumer is +independently authenticated at the origin before grouping. + +## Recovery and cancellation + +Coordinator state is volatile. Restart starts empty and requires fresh origin +validation/replay. No invalidation feed is needed for correctness: validators +cover committed projection data and proof state, including external SQL writes. +An epoch or shard change abandons old cache/work; clients reconnect and present +origin-verifiable cursors. Cursor gaps, incompatible freshness and queue overflow +cause origin recovery or explicit reset. Last-leave drops the actual upstream +socket; one expired consumer cannot terminate another valid consumer's group. +The upstream can reconnect with a remaining consumer's current credential. + +Set the `enable_request_signal` compatibility flag. The adapter preserves +Request.signal through rebuilt requests, and cancellation drops the associated +Rust work and shared-flight ownership. Plain HTTP disconnects before headers +did not produce that signal in the pinned local workerd fixture; those requests +remain bounded by the configured deadline. Explicit ingress abort signals, +response-stream cancellation and live last-leave teardown have separate actual +runtime tests. A cancelled command is never retried by the gateway; cancellation +does not imply that backend effects were rolled back. + +The fixture's migration declares a new `DeliveryCoordinator` SQLite DO class; +the adapter stores no cache entries or identity grants in durable storage. For +local reset, stop the runner and change its epoch (or use fresh disposable local +state). Rollback disables delivery bindings while retaining independent remote +forwarding. Do not reuse aggregate-cell classes or namespaces. Nothing in this +fixture provisions or deploys Cloudflare resources. + +Platform references: [request cancellation](https://developers.cloudflare.com/changelog/post/2025-05-22-handle-request-cancellation/), +[WebSocket API](https://developers.cloudflare.com/workers/runtime-apis/websockets/), +[outgoing socket lifecycle](https://developers.cloudflare.com/durable-objects/best-practices/websockets/). diff --git a/examples/graphiql.rs b/examples/graphiql.rs index ac4bd1cc6..7722210ff 100644 --- a/examples/graphiql.rs +++ b/examples/graphiql.rs @@ -18,9 +18,7 @@ use std::sync::Arc; use distributed::graphql::GraphqlEngine; use distributed::microsvc::{serve, Service}; -use distributed::{ - ColumnType, ReadModelCatalog, PrimaryKey, TableColumn, TableKind, TableSchema, -}; +use distributed::{ColumnType, PrimaryKey, ReadModelCatalog, TableColumn, TableKind, TableSchema}; use sqlx::sqlite::SqlitePoolOptions; fn orders_schema() -> TableSchema { diff --git a/js/README.md b/js/README.md index d99fb1e76..ff30b5e97 100644 --- a/js/README.md +++ b/js/README.md @@ -304,6 +304,36 @@ await client.prefetchLocation(target.pathname, { }); ``` +### Optional lazy command loading + +For read-heavy applications, switch the root layout's generated provider import: + +```ts +import { provideDistributedLazy as provideDistributed } from '$distributed'; +``` + +Use the same provider options and `useCommands()` methods. The lazy provider +registers the generated surface authority immediately after hydration, and imports +one shared command runtime and its definitions on the first command. Queries, +SSR, hydration, and route prefetch keep their existing behavior. The default +`provideDistributed` and framework-neutral `createCommands` remain eager. + +To load command code before an interaction, call `await client.preloadCommands()` +when opening an editor or focusing an editing control. This imports code without +sending a command. The first cold command waits for this import before optimistic +feedback can appear; failed imports reject and can be retried. Imports never +replay commands. Pending receipts and status recovery remain owned by the shared +client across route changes. On controlled reload, an existing +`reload.recoverPendingCommands` callback runs after lazy command code is ready; +applications still own that callback's recovery policy. + +Regenerate the client with the matching CLI/runtime release to obtain +`provideDistributedLazy` and `createLazyCommands`. Framework-neutral consumers +can use `createLazyCommands` from the generated `lazy-commands.js` module and +call its `preload()` method. Directly importing `COMMANDS`, command artifacts, +`createCommands`, or command-dependent pure functions elsewhere in the browser +can keep those definitions eager. Verify the production bundle for your app. + Route components import only their generated surface. Static operation wrappers resolve the nearest tree-local client when used: @@ -325,8 +355,20 @@ once. Components do not generate IDs or maintain optimistic/cache recipes. `@load` results are normalized on the server, dehydrated, and restored in the browser without a duplicate first request. Hydration cannot authorize itself: the server sends a separate authority value, and the adapter requires both -values to match. Session, token, tenant, or role changes abort HTTP and live -work, discard the old generation, and reconnect under server-issued scope. +values to match. Credential changes abort old HTTP and live work. Without fresh +server evidence, they also discard the old generation. A refreshed credential +received together with a new authorized seed for the exact active scope can +keep the warm replica and pending optimism. The seed proves authorization; +its data does not overwrite newer local command results or freshness floors. +`createPageDataSessionSource` +provides that transfer automatically; custom session sources can supply +`getHydration()` alongside `getAuth()`. Never derive this authority from a JWT +or reuse an old transfer. Logout, changed scopes, and invalid hydration still +purge the generation. + +The sample auth refresh endpoint returns an authorized seed for the current +route before invalidating SvelteKit page data. Ordinary SPA navigation still +skips server GraphQL work. Confirmed records and indexes under an active scope stay until auth/scope change, stale+revalidate, or a newer authoritative write. Same-scope soft diff --git a/js/package.json b/js/package.json index 3d0a79f1e..9ddee7f00 100644 --- a/js/package.json +++ b/js/package.json @@ -34,7 +34,11 @@ "types": "./dist/react/index.d.ts", "import": "./dist/react/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./replica/lazy": { + "types": "./dist/replica/lazy.d.ts", + "import": "./dist/replica/lazy.js" + } }, "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", diff --git a/js/scripts/pack-smoke.mjs b/js/scripts/pack-smoke.mjs index 1f15fc159..8cd5771b1 100644 --- a/js/scripts/pack-smoke.mjs +++ b/js/scripts/pack-smoke.mjs @@ -82,6 +82,7 @@ function inspectPackageContract(packageJson) { './package.json', './react', './replica', + './replica/lazy', './sveltekit', './sveltekit/vite' ]); @@ -275,6 +276,8 @@ void [ const consumerRuntimeSource = ` import assert from 'node:assert/strict'; import * as rootSurface from '@hops-ops/distributed'; +import * as lazySurface from '@hops-ops/distributed/replica/lazy'; +assert.deepEqual(Object.keys(lazySurface), ['createLazyReplicaCommandRuntime']); import * as replicaSurface from '@hops-ops/distributed/replica'; import * as diagnosticsSurface from '@hops-ops/distributed/diagnostics'; import { diff --git a/js/src/replica/command-runtime.ts b/js/src/replica/command-runtime.ts index 64998cfb6..b68affce6 100644 --- a/js/src/replica/command-runtime.ts +++ b/js/src/replica/command-runtime.ts @@ -33,3 +33,9 @@ export type { ReplicaCommandTransportResult, ReplicaResultObservationRegistration } from './command-runtime/index.js'; + +export type { + ReplicaLazyCommandCatalog, + ReplicaLazyCommandModule, + ReplicaLazyCommandRuntime +} from './command-runtime/lazy.js'; diff --git a/js/src/replica/command-runtime/create.ts b/js/src/replica/command-runtime/create.ts index ba93716af..471ef75cc 100644 --- a/js/src/replica/command-runtime/create.ts +++ b/js/src/replica/command-runtime/create.ts @@ -47,6 +47,7 @@ import { import { ReplicaCommandRuntimeError } from './errors.js'; import { replicaCommandAuthority, + replicaCommandFreshness, replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, replicaCommandReadRecord, @@ -601,6 +602,7 @@ export function createReplicaCommandRuntime< return validated.requiresRevalidation; } const seam = replica[replicaCommandProjectionDelta]!; + if (validated.revalidation !== undefined) replica[replicaCommandFreshness]?.(prepared.commandId, validated.revalidation); const actualPrepared = Object.freeze({ ...prepared, optimistic: Object.freeze({ @@ -1188,6 +1190,7 @@ export function createReplicaCommandRuntime< readRecord: replica[replicaCommandReadRecord]?.bind(replica), pureFunctions: options.pureFunctions }; + replica[replicaCommandFreshness]?.(prepared.commandId, prepared.revalidation); (replica as SemanticReplica).createOptimisticLayer( prepared.commandId, (writer) => @@ -1289,6 +1292,17 @@ export function createReplicaCommandRuntime< ); } + // The HTTP lifecycle gate rejects before dispatch and before a receipt + // exists. Classify only its exact failure shape, never a success/receipt. + if (result.status === 503 && result.data == null && result.extensions === undefined && + result.errors?.length === 1 && result.errors[0].extensions?.code === 'APPLICATION_RELOADING') { + rejectUnmanagedLayer(prepared.commandId); + revalidateInBackground(prepared, authority); + throw new ReplicaCommandRuntimeError('REPLICA_COMMAND_RELOADING', { + commandId: prepared.commandId + }); + } + const rejection = graphqlCommandRejection(result); if (rejection !== undefined) { try { diff --git a/js/src/replica/command-runtime/index.ts b/js/src/replica/command-runtime/index.ts index 88bb07fbd..83880f96c 100644 --- a/js/src/replica/command-runtime/index.ts +++ b/js/src/replica/command-runtime/index.ts @@ -32,3 +32,9 @@ export type { export { ReplicaCommandRuntimeError } from './errors.js'; export { replicaCommandProjectedLifecycleOf } from './lifecycle.js'; export { createReplicaCommandRuntime } from './create.js'; + +export type { + ReplicaLazyCommandCatalog, + ReplicaLazyCommandModule, + ReplicaLazyCommandRuntime +} from './lazy.js'; diff --git a/js/src/replica/command-runtime/lazy.ts b/js/src/replica/command-runtime/lazy.ts new file mode 100644 index 000000000..e2907675e --- /dev/null +++ b/js/src/replica/command-runtime/lazy.ts @@ -0,0 +1,330 @@ +import { matchReplicaTrustedPresetInventory } from '../commands.js'; +import { SHA256 } from './constants.js'; +import { ReplicaCommandRuntimeError } from './errors.js'; +import { defineBoundCommand, freezeCommandTree } from './lib/binding.js'; +import { + commandStatusArtifact, + commandSurfaceContract, + normalizeInventory +} from './lib/inventory.js'; +import { + cloneScope, + cloneSurface, + linkAbortSignals, + sameScope, + sameSurface +} from './lib/util.js'; +import { replicaCommandAuthority } from './symbols.js'; +import type { ReplicaResultEnvelope } from '../types.js'; +import type { + CommandEntry, + ReplicaBoundCommands, + ReplicaCommandAuthorityHost, + ReplicaCommandCallOptions, + ReplicaCommandRuntime, + ReplicaCommandRuntimeOptions, + ReplicaCommandStatusArtifact, + ReplicaCommandSurfaceContract, + ReplicaCommandTransport +} from './types.js'; + +/** Small compiler-owned inventory; definitions and pure implementations stay in the lazy chunk. */ +export type ReplicaLazyCommandCatalog = Readonly<{ + commands: Readonly< + Record> + >; + status: ReplicaCommandStatusArtifact; +}>; + +export type ReplicaLazyCommandModule< + TEntries extends Readonly> +> = Readonly<{ + entries: TEntries; + pureFunctions?: ReplicaCommandRuntimeOptions['pureFunctions']; +}>; + +export type ReplicaLazyCommandRuntime< + TEntries extends Readonly> +> = ReplicaCommandRuntime & Readonly<{ preload(): Promise }>; + +/** + * Register authority synchronously, then load one shared command runtime on demand. + * Only immutable code may be cached by the loader; runtime state belongs to this client. + */ +export function createLazyReplicaCommandRuntime< + TEntries extends Readonly> +>( + replica: ReplicaCommandAuthorityHost, + transport: ReplicaCommandTransport, + catalog: ReplicaLazyCommandCatalog, + load: () => Promise>, + options: Omit = {} +): ReplicaLazyCommandRuntime { + const protocol = catalog.status.protocol; + if (protocol.surface === undefined) + throw new TypeError('lazy commands require a client surface'); + const contract: ReplicaCommandSurfaceContract = Object.freeze({ + protocolVersion: 1, + schemaHash: protocol.schemaHash, + protocolHash: protocol.protocolHash, + surface: cloneSurface(protocol.surface), + trustedPresets: Object.freeze( + protocol.trustedPresets.map((value) => Object.freeze({ ...value })) + ) + }); + const status = commandStatusArtifact(catalog.status, contract); + if (transport.status === undefined) + throw new TypeError( + 'generated command status artifact requires transport.status' + ); + const hashes = Object.freeze( + Object.fromEntries( + Object.entries(catalog.commands).map(([key, value]) => [ + key, + Object.freeze({ ...value }) + ]) + ) + ); + const commands = Object.create(null) as Record; + if (Object.keys(hashes).length === 0) + throw new TypeError('lazy command inventory must not be empty'); + for (const [name, descriptor] of Object.entries(hashes)) { + if ( + typeof descriptor.hasInput !== 'boolean' || + !SHA256.test(descriptor.operationHash) + ) + throw new TypeError('invalid lazy command operation hash'); + defineBoundCommand( + commands, + name, + descriptor.hasInput + ? (input: unknown, callOptions = {}) => invoke(name, input, callOptions) + : (callOptions = {}) => invoke(name, undefined, callOptions) + ); + } + freezeCommandTree(commands); + const registration = replica[replicaCommandAuthority]?.(contract); + const lifetime = new AbortController(); + let disposed = false; + let runtime: ReplicaCommandRuntime | undefined; + let loading: Promise | undefined; + let startTail: Promise | undefined; + // Reserve preparation order before importing. A warm call must not overtake + // earlier cold calls; release after dispatch starts, not after its receipt. + const reserveStart = () => { + const previous = startTail; + let resolve!: () => void; + const tail = new Promise((done) => { + resolve = done; + }); + startTail = tail; + return { + previous, + release() { + const complete = () => { + resolve(); + if (startTail === tail) startTail = undefined; + }; + if (previous === undefined) complete(); + else void previous.then(complete); + } + }; + }; + const readAuthority = () => + registration?.read() ?? { + generation: replica.authorizationGeneration, + scope: replica.scope, + trustedPresets: [] + }; + const assertAlive = () => { + if (disposed) + throw new ReplicaCommandRuntimeError('REPLICA_COMMAND_DISPOSED'); + }; + const preload = (): Promise => { + try { + assertAlive(); + } catch (error) { + return Promise.reject(error); + } + if (runtime !== undefined) return Promise.resolve(); + if (loading !== undefined) return loading; + loading = Promise.all([Promise.resolve().then(load), import('./create.js')]) + .then(([module, implementation]) => { + assertAlive(); + const inventory = normalizeInventory(module.entries); + if ( + inventory.length !== Object.keys(hashes).length || + inventory.some( + ({ key, artifact }) => + !Object.hasOwn(hashes, key) || + artifact.name !== key || + artifact.operationHash !== hashes[key].operationHash || + (artifact.input.kind !== 'none') !== hashes[key].hasInput + ) + ) + throw new TypeError( + 'loaded command inventory does not match its catalog' + ); + const actual = commandSurfaceContract( + inventory.map(({ artifact }) => artifact), + status.protocol.trustedPresets + ); + if ( + actual.schemaHash !== contract.schemaHash || + actual.protocolHash !== contract.protocolHash || + !sameSurface(actual.surface, contract.surface) || + JSON.stringify(actual.trustedPresets) !== + JSON.stringify(status.protocol.trustedPresets) + ) + throw new TypeError( + 'loaded command surface does not match its catalog' + ); + // The real runtime retains all existing validation, dispatch scheduling, + // result observation, optimistic layers, status readers and recovery. + runtime = implementation.createReplicaCommandRuntime( + replica, + transport, + module.entries, + { + ...options, + pureFunctions: { + ...module.pureFunctions, + ...options.pureFunctions + }, + status + } + ); + }) + .finally(() => { + loading = undefined; + }); + return loading; + }; + + async function invoke( + name: string, + input: unknown, + callOptions: ReplicaCommandCallOptions + ) { + assertAlive(); + try { + options.lifecycle?.assertDispatchOpen(); + } catch (cause) { + throw new ReplicaCommandRuntimeError('REPLICA_COMMAND_RELOADING', { + cause + }); + } + const captured = readAuthority(); + if ( + captured.scope === undefined || + captured.scope.schemaHash !== contract.schemaHash || + captured.scope.protocolVersion !== contract.protocolVersion + ) { + throw new ReplicaCommandRuntimeError( + 'REPLICA_COMMAND_AUTHORITY_UNAVAILABLE' + ); + } + matchReplicaTrustedPresetInventory( + contract.trustedPresets, + captured.trustedPresets + ); + const scope = cloneScope(captured.scope); + const current = () => { + assertAlive(); + const next = readAuthority(); + if ( + captured.signal?.aborted || + next.generation !== captured.generation || + next.scope === undefined || + !sameScope(next.scope, scope) + ) { + throw new ReplicaCommandRuntimeError( + 'REPLICA_COMMAND_SCOPE_INVALIDATED' + ); + } + }; + const signals = linkAbortSignals([ + captured.signal, + callOptions.signal, + lifetime.signal + ]); + const reservation = reserveStart(); + try { + current(); + if (signals.signal?.aborted) + throw new ReplicaCommandRuntimeError('REPLICA_COMMAND_ABORTED'); + // Snapshot before the new asynchronous boundary, just as eager preparation + // consumes the caller's input before its first await. + const savedInput = + runtime === undefined || reservation.previous !== undefined + ? structuredClone(input) + : input; + const savedOptions = { + ...callOptions, + ...(callOptions.generators === undefined + ? {} + : { generators: { ...callOptions.generators } }) + }; + if (runtime === undefined) await waitForLoad(preload(), signals.signal); + if (reservation.previous !== undefined) + await waitForLoad(reservation.previous, signals.signal); + current(); + if (signals.signal?.aborted) + throw new ReplicaCommandRuntimeError('REPLICA_COMMAND_ABORTED'); + let command: unknown = runtime!.commands; + for (const part of name.split('.')) + command = (command as Record)[part]; + return hashes[name].hasInput + ? ( + command as ( + input: unknown, + options: ReplicaCommandCallOptions + ) => unknown + )(savedInput, savedOptions) + : (command as (options: ReplicaCommandCallOptions) => unknown)( + savedOptions + ); + } catch (error) { + current(); + throw error; + } finally { + reservation.release(); + signals.dispose(); + } + } + + return Object.freeze({ + commands: commands as ReplicaBoundCommands, + preload, + observeResult: (envelope: ReplicaResultEnvelope) => + runtime?.observeResult(envelope), + pendingCommandIds: () => runtime?.pendingCommandIds() ?? Object.freeze([]), + dispose() { + if (disposed) return; + disposed = true; + lifetime.abort(); + try { + runtime?.dispose(); + } finally { + registration?.dispose(); + } + } + }); +} + +/** A cancelled caller releases its wait immediately; other callers still share the import. */ +function waitForLoad( + load: Promise, + signal: AbortSignal | undefined +): Promise { + if (signal === undefined) return load; + return new Promise((resolve, reject) => { + const abort = () => + reject(new ReplicaCommandRuntimeError('REPLICA_COMMAND_ABORTED')); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + load + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', abort)); + }); +} diff --git a/js/src/replica/command-runtime/symbols.ts b/js/src/replica/command-runtime/symbols.ts index f3bbee6c9..c6705b5f4 100644 --- a/js/src/replica/command-runtime/symbols.ts +++ b/js/src/replica/command-runtime/symbols.ts @@ -47,3 +47,6 @@ export const replicaCommandProjectedLifecycle = Symbol( export const replicaCommandReadRecord = Symbol( 'distributed.replica.command-read-record' ); + +/** @internal Generated dependency registration before dispatch/optimism. */ +export const replicaCommandFreshness = Symbol('distributed.replica.command-freshness'); diff --git a/js/src/replica/command-runtime/types.ts b/js/src/replica/command-runtime/types.ts index 72f3e9af5..20d7900f2 100644 --- a/js/src/replica/command-runtime/types.ts +++ b/js/src/replica/command-runtime/types.ts @@ -26,6 +26,7 @@ import type { import type { ReplicaPureFunction } from '../projection-delta/index.js'; import { replicaCommandAuthority, + replicaCommandFreshness, replicaCommandDirectProjection, replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, @@ -66,6 +67,7 @@ export type ReplicaCommandDirectProjection = Readonly<{ }>; export type ReplicaCommandAuthorityHost = DistributedReplica & { + readonly [replicaCommandFreshness]?: (commandId: string, plan: import('../types.js').ReplicaRevalidationPlan) => void; readonly [replicaCommandAuthority]?: ( contract: ReplicaCommandSurfaceContract ) => ReplicaCommandAuthorityRegistration; diff --git a/js/src/replica/distributed-replica/impl-fetch-live.ts b/js/src/replica/distributed-replica/impl-fetch-live.ts index ff178acbf..650c8182e 100644 --- a/js/src/replica/distributed-replica/impl-fetch-live.ts +++ b/js/src/replica/distributed-replica/impl-fetch-live.ts @@ -57,6 +57,7 @@ export type FetchLiveHost = { ): DistributedProtocolEnvelope; diagnosticEvent(event: ReplicaDiagnosticEventInput): void; resumeCursors(key: string): readonly DistributedLiveCursor[]; + freshness(artifact: ReplicaOperationArtifact, key: string): Readonly> | undefined; }; export function emitWatchState(host: FetchLiveHost, key: string, allowFetch: boolean): void { @@ -151,7 +152,7 @@ export function fetchWatch( document: watch.artifact.document, variables: watch.variables, artifact: watch.artifact, - ...replicaClientRequestExtensions(watch.artifact), + extensions: requestExtensions(host, watch.artifact, watch.key), signal: controller.signal }); let flight: Promise; @@ -251,7 +252,7 @@ export function retainLive( document: watch.artifact.live.document, variables: watch.variables, artifact: watch.artifact, - ...replicaClientRequestExtensions(watch.artifact), + extensions: requestExtensions(host, watch.artifact, watch.key), ...(resume === undefined || resume.length === 0 ? {} : { resume }) @@ -493,3 +494,15 @@ export function releaseLive(host: FetchLiveHost, key: string): void { host.queryState(key).live = 'off'; host.emitState(key, false); } + +function requestExtensions( + host: FetchLiveHost, + artifact: ReplicaOperationArtifact, + key: string +): Readonly> { + const freshness = host.freshness(artifact, key); + return Object.freeze({ + ...replicaClientRequestExtensions(artifact).extensions, + ...(freshness === undefined ? {} : { gatewayFreshness: freshness }) + }); +} diff --git a/js/src/replica/distributed-replica/impl-hydration-orchestrate.ts b/js/src/replica/distributed-replica/impl-hydration-orchestrate.ts index c8801b432..e21655d36 100644 --- a/js/src/replica/distributed-replica/impl-hydration-orchestrate.ts +++ b/js/src/replica/distributed-replica/impl-hydration-orchestrate.ts @@ -84,6 +84,7 @@ export type HydrationHost = { closeActiveTransports(): void; closeAuthorizationGeneration(): void; resumeLiveWatches(): void; + refreshWatches(): void; syncDiagnostics(): void; diagnosticEvent(event: ReplicaDiagnosticEventInput): void; refreshIndexMaintenance(): void; @@ -315,7 +316,8 @@ export function dehydrateReplica(host: HydrationHost): ReplicaDehydratedState { export function hydrateReplica( host: HydrationHost, state: ReplicaDehydratedState, - authoritativeScope: ReplicaAuthoritativeScope + authoritativeScope: ReplicaAuthoritativeScope, + mode: 'merge' | 'reauthorize' = 'merge' ): boolean { const rejected = ( reason: @@ -366,6 +368,9 @@ export function hydrateReplica( ) { return rejected('active-scope-mismatch'); } + if (mode === 'reauthorize' && current === undefined) { + return rejected('active-scope-mismatch'); + } const preserveLocalCommandState = current !== undefined; // Validate the private engine payload before closing transports or changing @@ -398,6 +403,18 @@ export function hydrateReplica( return rejected('metadata-mismatch'); } + if (mode === 'reauthorize') { + // The independent scope authorizes continued use of the current replica. + // The accompanying snapshot may predate an intervening command: do not + // merge its records, clocks, indexes, or freshness evidence into that state. + host.closeActiveTransports(); + host.queryStates.clear(); + host.resumeLiveWatches(); + host.refreshWatches(); + host.syncDiagnostics(); + return true; + } + if (preserveLocalCommandState) { // Warm same-scope re-hydrate (soft nav / second SSR seed): keep confirmed // records and indexes the route seed omitted. Seed keys upsert; purge only diff --git a/js/src/replica/distributed-replica/impl.ts b/js/src/replica/distributed-replica/impl.ts index 8e5654967..83024fefa 100644 --- a/js/src/replica/distributed-replica/impl.ts +++ b/js/src/replica/distributed-replica/impl.ts @@ -1,3 +1,4 @@ +import { replicaCommandFreshness } from '../command-runtime/symbols.js'; import { CacheRevisionConflictError, createCacheEngine, @@ -127,6 +128,7 @@ import { latestCursors, protocolInvalid, recordKeyMatchesModel, + modelFromRecordKey, responsePathKey, sameRecordClock, sameRecordRevision @@ -228,6 +230,7 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { DistributedOpaqueString, AnonymousRecordProtocolClock >(); + readonly #freshnessPlans = new Map(); readonly #optimisticReceipts = new Map(); readonly #renderedOperations = new Map(); readonly #readOperationKeys = new Set(); @@ -344,7 +347,8 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { responseProjectionGeneration ), diagnosticEvent: (event) => self.#diagnosticEvent(event), - resumeCursors: (key) => self.#resumeCursors(key) + resumeCursors: (key) => self.#resumeCursors(key), + freshness: (artifact, key) => self.#freshness(artifact, key) }; return this.#fetchLiveHostCache; } @@ -552,6 +556,9 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { closeActiveTransports: () => self.#closeActiveTransports(), closeAuthorizationGeneration: () => self.#closeAuthorizationGeneration(), resumeLiveWatches: () => self.#resumeLiveWatches(), + refreshWatches: () => { + for (const key of self.#watches.keys()) self.#emitState(key, true); + }, syncDiagnostics: () => self.#syncDiagnostics(), diagnosticEvent: (event) => self.#diagnosticEvent(event), refreshIndexMaintenance: () => self.#refreshIndexMaintenance(), @@ -659,6 +666,53 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { }); } + [replicaCommandFreshness](commandId: string, plan: ReplicaRevalidationPlan): void { + // Validate before dispatch, using the existing generated dependency matcher. + createReplicaRevalidationMatcher(plan); + for (const [id, entry] of this.#freshnessPlans) { + if (entry.generation !== this.#protocolGenerationSequence || this.#engine.optimisticLayerState(id) === undefined) this.#freshnessPlans.delete(id); + } + if (!this.#freshnessPlans.has(commandId) && this.#freshnessPlans.size >= 256) throw new Error('too many pending causal commands'); + this.#freshnessPlans.set(commandId, { generation: this.#protocolGenerationSequence, plan }); + } + + #freshness(artifact: ReplicaOperationArtifact, key: string): Readonly> | undefined { + const scope = this.#protocolGeneration; + const protocolHash = artifact.protocol.protocolHash ?? this.#commandAuthorityContract?.protocolHash; + if (scope === undefined || protocolHash === undefined) return undefined; + const pending: unknown[] = []; + for (const [id, entry] of this.#freshnessPlans) { + if (entry.generation !== this.#protocolGenerationSequence || this.#engine.optimisticLayerState(id) === undefined) { + this.#freshnessPlans.delete(id); + continue; + } + if (!createReplicaRevalidationMatcher(entry.plan)(artifact)) continue; + // Overlap already uses full generated list/filter/count/relationship + // dependencies. An overlapping request must use primary even when an + // incomplete producer only supplied an opaque dependency name. + pending.push({ complete: false, models: [...entry.plan.models], relationships: [] }); + } + const minimum: unknown[] = []; + // Retained clocks live independently of pending layer state. Index scopes + // belong to this query/live plan; never compare a different query's clock. + const group = this.#operationProtocols.get(key); + for (const state of [group?.query, group?.live]) { + for (const [projection, clock] of state?.indexClocks ?? []) { + minimum.push({ kind: 'index', projection, scopeToken: clock.scopeToken, position: clock.position }); + } + } + // Direct Atomic effects have not yet acquired a query index checkpoint. + // Once a proving query retires this fence, its retained index clock covers + // membership (including later deletion) without requiring an absent row. + for (const [recordKey, { clock }] of this.#projectedRecordFences) { + const model = modelFromRecordKey(recordKey); + if (model === undefined || !createReplicaRevalidationMatcher({ dependencies: [], models: [model], relationships: [] })(artifact)) continue; + minimum.push({ kind: 'record', model, scopeToken: clock.scopeToken, incarnation: clock.incarnation, revision: clock.revision }); + } + if (pending.length + minimum.length > 256) throw new Error('causal delivery context exceeds bounded evidence budget'); + return Object.freeze({ version: 1, schemaHash: scope.schemaHash, protocolHash, authorizationGeneration: scope.authorizationGeneration, cacheScope: scope.cacheScope, pending, minimum }); + } + [replicaResultObservation]( observer: (envelope: ReplicaResultEnvelope) => void ): ReplicaResultObservationRegistration { @@ -959,6 +1013,13 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { return hydrateReplica(this.#hydrationHost(), state, authoritativeScope); } + reauthorize( + state: ReplicaDehydratedState, + authoritativeScope: ReplicaAuthoritativeScope + ): boolean { + return hydrateReplica(this.#hydrationHost(), state, authoritativeScope, 'reauthorize'); + } + #bindArtifact( artifact: ReplicaOperationArtifact ): void { diff --git a/js/src/replica/index-maintenance/engine.ts b/js/src/replica/index-maintenance/engine.ts index 5918f2840..a5757e1c7 100644 --- a/js/src/replica/index-maintenance/engine.ts +++ b/js/src/replica/index-maintenance/engine.ts @@ -894,7 +894,12 @@ export function relationshipMembership( ) }; } - if (!sameValue(parent.fields[local], target.fields[remote])) { + // Direct SQL joins use equality, not IS NOT DISTINCT FROM: null never joins. + if ( + parent.fields[local] === null || + target.fields[remote] === null || + !sameValue(parent.fields[local], target.fields[remote]) + ) { return { related: false }; } } diff --git a/js/src/replica/index.ts b/js/src/replica/index.ts index 2bf775d11..773296df3 100644 --- a/js/src/replica/index.ts +++ b/js/src/replica/index.ts @@ -252,3 +252,9 @@ export type { MutationProgram, MutationTarget, } from './mutation-cache.js'; + +export type { + ReplicaLazyCommandCatalog, + ReplicaLazyCommandModule, + ReplicaLazyCommandRuntime +} from './command-runtime/lazy.js'; diff --git a/js/src/replica/lazy.ts b/js/src/replica/lazy.ts new file mode 100644 index 000000000..882f9ea7f --- /dev/null +++ b/js/src/replica/lazy.ts @@ -0,0 +1,7 @@ +/** Dedicated entry point keeps eager command exports out of a lazy client's initial import graph. */ +export { createLazyReplicaCommandRuntime } from './command-runtime/lazy.js'; +export type { + ReplicaLazyCommandCatalog, + ReplicaLazyCommandModule, + ReplicaLazyCommandRuntime +} from './command-runtime/lazy.js'; diff --git a/js/src/replica/types.ts b/js/src/replica/types.ts index 6ce1d0aba..b39705c44 100644 --- a/js/src/replica/types.ts +++ b/js/src/replica/types.ts @@ -517,6 +517,8 @@ export type ReplicaOperationSourceLocation = { }; export type ReplicaOperationProtocol = { + /** Exact generated protocol generation for causal delivery context. */ + readonly protocolHash?: string; readonly version: 1; /** Opaque service schema fingerprint, compared byte-for-byte. */ readonly schemaHash: string; @@ -896,6 +898,16 @@ export interface DistributedReplica { state: ReplicaDehydratedState, authoritativeScope: ReplicaAuthoritativeScope ): boolean; + /** + * Validate a fresh transfer against independent server authority and the + * exact active scope, then restart query/live transports without applying + * the transfer's potentially older data. Preserves command/freshness state. + * Returns false without mutating state when validation fails. + */ + reauthorize( + state: ReplicaDehydratedState, + authoritativeScope: ReplicaAuthoritativeScope + ): boolean; createOptimisticLayer( id: string, update: (writer: ReplicaOptimisticWriter) => void, diff --git a/js/src/sveltekit/lifecycle.ts b/js/src/sveltekit/lifecycle.ts index 9dfe680b6..facb63c35 100644 --- a/js/src/sveltekit/lifecycle.ts +++ b/js/src/sveltekit/lifecycle.ts @@ -92,6 +92,8 @@ type ReloadCapsule = Readonly<{ }>; export interface DistributedReloadLifecycle { + /** Let a prepared coherent transition own reload after a dev socket reconnect. */ + deferDevTransportReload(): Promise; assertDispatchOpen(): void; register(participant: ReloadParticipant): () => void; destroy(): void; @@ -124,7 +126,7 @@ export function validateDistributedReloadLocation(location: URL): string { /** Register one generated client with the shared browser reload transaction. */ export function registerDistributedReloadClient( replica: DistributedReplica, - runtime: Readonly<{ pendingCommandIds?(): readonly string[] }> | undefined, + runtime: Readonly<{ pendingCommandIds?(): readonly string[]; preload?(): Promise }> | undefined, options: DistributedReloadOptions ): () => void { const state = new Map( @@ -177,8 +179,9 @@ export function registerDistributedReloadClient( await declaration.restore(candidate.value); } } - if (saved.pendingCommandIds.length > 0) { - await options.recoverPendingCommands?.(saved.pendingCommandIds); + if (saved.pendingCommandIds.length > 0 && options.recoverPendingCommands !== undefined) { + await runtime?.preload?.(); + await options.recoverPendingCommands(saved.pendingCommandIds); } window.dispatchEvent( new CustomEvent('distributed:reload-restored', { @@ -223,6 +226,8 @@ function createDistributedReloadLifecycle(): DistributedReloadLifecycle { let destroyed = false; let preparing: string | undefined; let reloadRequested = false; + let devTransportDisconnected = false; + const deferredTransports = new Set<() => void>(); let loadedGenerationId = documentGenerationId(); let timer: ReturnType | undefined; let restoration = Promise.resolve(); @@ -271,11 +276,18 @@ function createDistributedReloadLifecycle(): DistributedReloadLifecycle { } return; } - if (state.active.generationId !== loadedGenerationId) { + if ( + state.active.generationId !== loadedGenerationId || + (preparing !== undefined && devTransportDisconnected) + ) { blocked = true; if (!reloadRequested) { reloadRequested = true; - markCapsuleRestoring(); + // A rejected replacement can restart the previous API too. Restore + // the prepared state into that verified rollback generation. + markCapsuleRestoring( + state.active.generationId === loadedGenerationId ? state.active : undefined + ); window.location.reload(); } return; @@ -294,6 +306,14 @@ function createDistributedReloadLifecycle(): DistributedReloadLifecycle { void poll(); return Object.freeze({ + deferDevTransportReload(): Promise { + if (preparing === undefined && !reloadRequested) return Promise.resolve(); + devTransportDisconnected = true; + // Vite awaits its disconnect listeners before its automatic reload. + // The lifecycle poll keeps running and owns the one navigation after + // activation (or verified rollback); page teardown releases this wait. + return new Promise((resolve) => deferredTransports.add(resolve)); + }, assertDispatchOpen(): void { if (blocked) throw new Error('coherent application reload is in progress'); }, @@ -310,6 +330,8 @@ function createDistributedReloadLifecycle(): DistributedReloadLifecycle { destroyed = true; if (timer !== undefined) clearTimeout(timer); participants.clear(); + for (const resolve of deferredTransports) resolve(); + deferredTransports.clear(); } }); } @@ -445,9 +467,9 @@ function readCapsule(): ReloadCapsule | undefined { } } -function markCapsuleRestoring(): void { +function markCapsuleRestoring(rollback?: LifecycleGeneration): void { const capsule = readCapsule(); - if (capsule !== undefined) storeCapsule(Object.freeze({ ...capsule, phase: 'restoring' })); + if (capsule !== undefined) storeCapsule(Object.freeze({ ...capsule, to: rollback ?? capsule.to, phase: 'restoring' })); } function parseLifecycleState(value: unknown): LifecycleDevState { @@ -520,6 +542,7 @@ function documentGenerationId(): string | undefined { function inertLifecycle(): DistributedReloadLifecycle { return Object.freeze({ + async deferDevTransportReload(): Promise {}, assertDispatchOpen(): void {}, register(): () => void { return () => undefined; diff --git a/js/src/sveltekit/replica.ts b/js/src/sveltekit/replica.ts index 59464df11..caf785cb4 100644 --- a/js/src/sveltekit/replica.ts +++ b/js/src/sveltekit/replica.ts @@ -46,9 +46,20 @@ import { type UnknownCommandEntries = Readonly>; +type SessionHydration = Readonly<{ + hydration: SveltekitReplicaHydration; + authority: SveltekitReplicaAuthority; +}>; + export type SveltekitSessionSource = Readonly<{ /** Current credential. HTTP, WS, and commands all call this exact source. */ getAuth(): GqlAuth | Promise; + /** + * Fresh server page data received together with the current credential. + * Only independently authorized, same-scope hydration permits reuse during + * credential rotation. Never synthesize this from a token or cached state. + */ + getHydration?(): SessionHydration | undefined; /** * Notify on token, logout, role, tenant, or session changes. * @@ -100,6 +111,7 @@ export type SveltekitCommandRuntimeLike = Pick< > & Readonly<{ commands: TCommands; + preload?(): Promise; }>; export type SveltekitCommandRuntimeFactory = ( @@ -249,6 +261,8 @@ export type DistributedSvelteKitClient = Readonly<{ artifact: ReplicaOperationArtifact, variables: GraphqlVariables ): Promise; + /** Load deferred command code without dispatching a command. */ + preloadCommands(): Promise; invalidateAuthorization(): void; destroy(): void; }>; @@ -314,7 +328,21 @@ export function createDistributedSvelteKit { + const active = replica?.scope; + if ( + active === undefined || + !sameReplicaScope(active, validatedHydrationAuthority(transfer.authority)) + ) return false; + if ((transfer.hydration.bindings ?? []).some(value => !boundaryIds.includes(value))) { + return false; + } + return transfer.hydration.version === 1 && replica!.reauthorize( + transfer.hydration.state, + validatedHydrationAuthority(transfer.authority) + ); + } ); const configuredUrl = options.url; const transport = createReplicaGraphqlTransport({ @@ -499,6 +527,10 @@ export function createDistributedSvelteKit { + if (destroyed) return Promise.reject(new Error('Distributed SvelteKit client is destroyed')); + return commandRuntime?.preload?.() ?? Promise.resolve(); + }, invalidateAuthorization(): void { if (!destroyed) { boundaryController!.disposeScope(); @@ -558,6 +590,12 @@ export function sessionSourceFromPageData( } return Object.freeze({ getAuth: () => authFromPageData(source.get()), + getHydration: () => { + const data: SveltekitDistributedPageData = source.get(); + return data.distributed === undefined || data.distributedAuthority === undefined + ? undefined + : { hydration: data.distributed, authority: data.distributedAuthority }; + }, ...(source.subscribe === undefined ? {} : { subscribe: source.subscribe.bind(source) }) @@ -991,23 +1029,40 @@ function sameQuerySnapshot( function createAuthorizationFence( source: SveltekitSessionSource, invalidate: () => void, - onError: ((error: unknown) => void) | undefined + onError: ((error: unknown) => void) | undefined, + refresh: (transfer: SessionHydration) => boolean ): Readonly<{ read(): Promise; dispose(): void }> { let current: Readonly | undefined; + const seenHydrations = new WeakSet(); + const seenAuthorities = new WeakSet(); let queue = Promise.resolve(); let disposed = false; const read = (): Promise => { - const candidate = Promise.resolve().then(() => source.getAuth()); + const candidate = Promise.resolve().then(() => { + // Capture the credential and its independent server transfer together, + // before another source notification can replace either value. + const credential = source.getAuth(); + const transfer = source.getHydration?.(); + return Promise.resolve(credential).then((auth) => ({ auth, transfer })); + }); const transition = queue.then(async () => { try { - const next = snapshotAuthCredential(await candidate); + const { auth, transfer } = await candidate; + const next = snapshotAuthCredential(auth); if ( current !== undefined && !sameAuthCredential(current, next) ) { - invalidate(); + const freshTransfer = transfer !== undefined && + !seenHydrations.has(transfer.hydration) && + !seenAuthorities.has(transfer.authority); + if (!freshTransfer || !refresh(transfer)) invalidate(); } current = next; + if (transfer !== undefined) { + seenHydrations.add(transfer.hydration); + seenAuthorities.add(transfer.authority); + } return next; } catch (error) { current = undefined; diff --git a/js/src/sveltekit/vite.ts b/js/src/sveltekit/vite.ts index 97125610f..73d4fdf50 100644 --- a/js/src/sveltekit/vite.ts +++ b/js/src/sveltekit/vite.ts @@ -235,6 +235,7 @@ export type DistributedSvelteKitVitePlugin = Readonly<{ buildStart(this: RollupWatchContextLike): void; resolveId(source: string, importer?: string): string | undefined; load(id: string): string | undefined; + transform(code: string, id: string, options?: Readonly<{ ssr?: boolean }>): string | undefined; transformIndexHtml(): LifecycleHtmlTag[]; handleHotUpdate(context: ViteHotContextLike): Promise; watchChange(id: string): Promise; @@ -448,6 +449,9 @@ export function distributedSvelteKit( }, resolveId(source, importer): string | undefined { if (lifecycleOwnsCompile && frameworkDist !== undefined) { + if (source === '@hops-ops/distributed/replica/lazy') { + return join(frameworkDist, 'replica', 'lazy.js'); + } if (source === '@hops-ops/distributed/replica') { return join(frameworkDist, 'replica', 'index.js'); } @@ -497,6 +501,13 @@ export function distributedSvelteKit( : client.entry; return `export * from ${JSON.stringify(portablePath(entry))};\n`; }, + transform(code, id, options): string | undefined { + if (!lifecycleOwnsCompile || options?.ssr || frameworkDist === undefined) return; + if (id.split('?', 1)[0] !== join(frameworkDist, 'sveltekit', 'lifecycle.js')) return; + // SvelteKit aliases can resolve generated clients before the virtual + // module hook. Attach once to the actual shared browser lifecycle. + return code + `\nif (import.meta.hot) import.meta.hot.on('vite:ws:disconnect', () => distributedReloadLifecycle().deferDevTransportReload());\n`; + }, transformIndexHtml: lifecycleGenerationMeta, async handleHotUpdate(context): Promise { const suppressed = suppressFrameworkHotUpdate(context, frameworkDist); @@ -868,6 +879,7 @@ export function distributedSvelteKitAliases( ]); if (frameworkDist !== undefined) { aliases.push( + ['@hops-ops/distributed/replica/lazy', join(frameworkDist, 'replica', 'lazy.js')], ['@hops-ops/distributed/replica', join(frameworkDist, 'replica')], ['@hops-ops/distributed/sveltekit', join(frameworkDist, 'sveltekit')] ); diff --git a/js/src/websocket.ts b/js/src/websocket.ts index 65c8c0ed1..9e7bc048b 100644 --- a/js/src/websocket.ts +++ b/js/src/websocket.ts @@ -191,7 +191,10 @@ export function subscribe< handlers.onComplete?.(); break; case 'ping': - socket.send(JSON.stringify({ type: 'pong' })); + socket.send(JSON.stringify({ + type: 'pong', + ...(message.payload === undefined ? {} : { payload: message.payload }) + })); break; case 'connection_error': handlers.onError?.(message.payload ?? 'connection error'); diff --git a/js/tests/fixtures/unique-key-artifact.mjs b/js/tests/fixtures/unique-key-artifact.mjs new file mode 100644 index 000000000..b4fc191d7 --- /dev/null +++ b/js/tests/fixtures/unique-key-artifact.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +export const artifact = JSON.parse(readFileSync(new URL( + '../../../distributed_cli/tests/fixtures/unique-key-bridge-operation.json', import.meta.url +), 'utf8')); +export const root = artifact.roots[0]; +export const owner = root.selection.members.find(member => member.field === 'owner'); + +export function frame(position, targetId, targetTitle) { + const records = []; + function wire(selection, values, path) { + records.push({ path, model: selection.storage.model, + scopeToken: `record:${values.id}`, incarnation: '1', revision: position, tombstone: false }); + return Object.fromEntries(selection.members.map(member => { + if (member.kind === 'branch') return [member.responseKey, targetId === null ? null : + wire(member.selection, { id: targetId, title: targetTitle, tenantId: 'tenant-a', + __typename: 'todo' }, [...path, member.responseKey])]; + assert.ok(Object.hasOwn(values, member.field), member.field); + return [member.responseKey, values[member.field]]; + })); + } + const row = wire(root.selection, { id: 'source-id', title: 'source', + ownerTitle: targetTitle, tenantId: 'tenant-a', __typename: 'todo' }, ['todos', '0']); + return { data: { todos: [row] }, extensions: { distributed: { + protocolVersion: artifact.protocol.version, schemaHash: artifact.protocol.schemaHash, + authorizationGeneration: 'auth-1', cacheScope: 'unique-key-cache', + operation: position === '1' ? artifact.id : artifact.live.id, + ...(position === '1' ? {} : { live: { supported: true, reset: false, cursors: [ + { projection: 'unique-key-projector', position, token: `resume:${position}` } + ] } }), + snapshot: { scopeToken: 'unique-key-snapshot', recordsComplete: true, indexesComparable: true, + records, indexes: [{ projection: 'unique-key-projector', scopeToken: 'unique-key-index', position, + resume: { projection: 'unique-key-projector', position, token: `resume:${position}` } }], observations: [] } + } } }; +} diff --git a/js/tests/lazy-command-bundle.test.mjs b/js/tests/lazy-command-bundle.test.mjs new file mode 100644 index 000000000..c8ebfe7c9 --- /dev/null +++ b/js/tests/lazy-command-bundle.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { build } from 'esbuild'; + +// Bundle the compiler's exact fixture, not a hand-written approximation of it. +test('generated lazy factory keeps command definitions and runtime outside the initial browser closure', async () => { + const root = fileURLToPath(new URL('../', import.meta.url)); + const fixture = path.resolve( + root, + '../distributed_cli/tests/fixtures/generated-lazy-commands.ts' + ); + const result = await build({ + entryPoints: [fixture], + bundle: true, + splitting: true, + format: 'esm', + platform: 'browser', + minify: true, + write: false, + metafile: true, + outdir: path.join(root, '.bundle-test'), + alias: { + '@hops-ops/distributed/replica/lazy': path.join( + root, + 'dist/replica/lazy.js' + ), + '@hops-ops/distributed/replica': path.join(root, 'dist/replica/index.js') + }, + plugins: [ + { + name: 'fixture-status', + setup(plugin) { + plugin.onResolve({ filter: /^\.\/protocol\.js$/ }, (args) => + args.importer.includes('/tests/fixtures/') + ? { path: 'status', namespace: 'fixture' } + : undefined + ); + plugin.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ + contents: 'export const COMMAND_STATUS = {};' + })); + } + } + ] + }); + const outputs = result.metafile.outputs; + const initial = Object.keys(outputs).find( + (file) => outputs[file].entryPoint === path.relative(process.cwd(), fixture) + ); + assert.ok(initial); + const seen = new Set(); + function visit(file) { + if (seen.has(file)) return; + seen.add(file); + for (const dependency of outputs[file].imports) { + if (dependency.kind !== 'dynamic-import' && !dependency.external) + visit(dependency.path); + } + } + visit(initial); + const deferredInputs = ['generated-commands.ts', 'command-runtime/create.js']; + for (const suffix of deferredInputs) { + assert.ok( + Object.values(outputs).some((output) => + Object.entries(output.inputs).some( + ([name, info]) => name.endsWith(suffix) && info.bytesInOutput > 0 + ) + ), + `${suffix} must remain available` + ); + assert.ok( + [...seen].every((file) => + Object.entries(outputs[file].inputs).every( + ([name, info]) => !name.endsWith(suffix) || info.bytesInOutput === 0 + ) + ), + `${suffix} must be deferred` + ); + } +}); diff --git a/js/tests/reload-dev-transport.test.mjs b/js/tests/reload-dev-transport.test.mjs new file mode 100644 index 000000000..1dd1e8562 --- /dev/null +++ b/js/tests/reload-dev-transport.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +for (const outcome of ['activation', 'rollback', 'unplanned disconnect']) { + test(`dev reconnect ownership during ${outcome}`, async () => { + // Each case models a fresh browser page with its own lifecycle singleton. + const { distributedReloadLifecycle, registerDistributedReloadClient } = + await import(`../dist/sveltekit/lifecycle.js?${outcome}`); + const previous = Object.fromEntries( + ['window', 'document', 'sessionStorage', 'CustomEvent', 'fetch'] + .map((key) => [key, globalThis[key]]) + ); + const values = new Map(); + const capsuleKey = '@hops-ops/distributed/reload-capsule/v1'; + const generation = (generationId) => ({ + generationId, releaseId: `release-${generationId}`, + topologyId: 'topology', compatibilityId: 'compatible' + }); + let state = { + schemaVersion: 1, phase: 'preparing', active: generation('old'), + pending: generation('next'), transitionId: 'transition-reconnect', + deadlineUnixMs: Date.now() + 10_000 + }; + if (outcome === 'unplanned disconnect') state = { schemaVersion: 1, phase: 'active', active: generation('old') }; + let reloads = 0; + globalThis.sessionStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key) + }; + globalThis.document = { querySelector: () => ({ getAttribute: () => 'old' }) }; + globalThis.window = { + location: { href: 'http://localhost:8791/todos', reload() { reloads++; } }, + dispatchEvent() {} + }; + globalThis.CustomEvent = class { constructor(type, options) { this.type = type; this.detail = options?.detail; } }; + globalThis.fetch = async (_url, options) => options?.method === 'POST' + ? { status: 204, ok: true } + : { status: 200, ok: true, json: async () => state }; + const waitFor = async (condition) => { + const deadline = Date.now() + 3_000; + while (!condition()) { + assert.ok(Date.now() < deadline, 'lifecycle did not reach expected state'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + const unregister = registerDistributedReloadClient( + { scope: undefined, dehydrate: () => ({}), hydrate: () => false }, + undefined, { key: 'surface' } + ); + const lifecycle = distributedReloadLifecycle(); + try { + if (outcome === 'unplanned disconnect') { + await lifecycle.deferDevTransportReload(); + lifecycle.assertDispatchOpen(); + assert.equal(reloads, 0); + assert.equal(values.has(capsuleKey), false); + return; + } + await waitFor(() => values.has(capsuleKey)); + let viteResumed = false; + const deferred = lifecycle.deferDevTransportReload().then(() => { viteResumed = true; }); + await Promise.resolve(); + assert.equal(viteResumed, false); + assert.throws(() => lifecycle.assertDispatchOpen(), /reload/); + const target = outcome === 'activation' ? 'next' : 'old'; + state = { schemaVersion: 1, phase: 'active', active: generation(target) }; + await waitFor(() => reloads === 1); + const capsule = JSON.parse(values.get(capsuleKey)); + assert.equal(capsule.phase, 'restoring'); + assert.equal(capsule.to.generationId, target); + assert.equal(viteResumed, false, 'Vite must not race the controlled navigation'); + lifecycle.destroy(); + await deferred; + } finally { + unregister(); + lifecycle.destroy(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + } + }); +} diff --git a/js/tests/reload-state.test.mjs b/js/tests/reload-state.test.mjs index 481cff722..612ef4e01 100644 --- a/js/tests/reload-state.test.mjs +++ b/js/tests/reload-state.test.mjs @@ -75,7 +75,7 @@ test('reload waits for replica authority and a resumed stale document reloads', participants: [{ key: 'public-surface', replica: { records: [] }, - pendingCommandIds: [], + pendingCommandIds: ['pending-command'], state: [] }] })); @@ -124,8 +124,15 @@ test('reload waits for replica authority and a resumed stale document reloads', return true; } }; - const unregister = registerDistributedReloadClient(replica, undefined, { - key: 'public-surface' + const recoveryOrder = []; + const unregister = registerDistributedReloadClient(replica, { + async preload() { recoveryOrder.push('preload'); }, + }, { + key: 'public-surface', + async recoverPendingCommands(ids) { + assert.deepEqual(ids, ['pending-command']); + recoveryOrder.push('recover'); + } }); try { await new Promise((resolve) => setTimeout(resolve, 50)); @@ -135,6 +142,7 @@ test('reload waits for replica authority and a resumed stale document reloads', scope = { tenant: 'tenant-1', roles: [] }; await waitFor(() => hydrations === 1, 'replica restoration was not retried'); assert.equal(values.get(capsuleKey), undefined); + assert.deepEqual(recoveryOrder, ['preload', 'recover']); activeGeneration = 'generation-c'; await waitFor(() => reloads === 1, 'stale document did not reload after activation'); diff --git a/js/tests/replica-command-artifacts.test.mjs b/js/tests/replica-command-artifacts.test.mjs index b4a0f0f6e..0b7abd29b 100644 --- a/js/tests/replica-command-artifacts.test.mjs +++ b/js/tests/replica-command-artifacts.test.mjs @@ -32,6 +32,23 @@ const GENERATED_DRAINING_COMMAND = JSON.parse( ) ); +test('compiled recovery-only command drops unused presets without weakening validation', () => { + const artifact = JSON.parse(readFileSync(new URL( + '../../distributed_cli/tests/fixtures/generated-recovery-preset-command.json', import.meta.url + ), 'utf8')); + assert.deepEqual(artifact.protocol.trustedPresets, [{ name: 'priority', codec: 'int32' }]); + assert.equal(artifact.trustedPresets, undefined); + assert.deepEqual(artifact.projection.preview.operations, []); + assert.ok(artifact.projection.preview.recoveries.length > 0); + const input = { id: GENERATED_UUID, tenantId: 'tenant-1', title: 'Prepare' }; + assert.doesNotThrow(() => prepareReplicaCommand(artifact, input, { commandId: COMMAND_ID })); + const invalid = { ...artifact, trustedPresets: artifact.protocol.trustedPresets }; + assert.throws( + () => prepareReplicaCommand(invalid, input, { commandId: COMMAND_ID }), + /artifact.trustedPresets\[0\]/ + ); +}); + const scalarField = ( name, typeName = 'String', diff --git a/js/tests/replica-command-runtime.test.mjs b/js/tests/replica-command-runtime.test.mjs index 889bfda19..74e11de41 100644 --- a/js/tests/replica-command-runtime.test.mjs +++ b/js/tests/replica-command-runtime.test.mjs @@ -1,3 +1,4 @@ +import { createLazyReplicaCommandRuntime } from '../dist/replica/command-runtime/lazy.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -712,6 +713,23 @@ test('coherent reload gate rejects before optimism or transport dispatch', async runtime.dispose(); }); +test('HTTP reload gate rolls back optimism and reports a reload, not an invalid receipt', async () => { + for (const status of [503, 200]) { + const replica = new TestReplica(); + const runtime = createReplicaCommandRuntime(replica, { + dispatch() { + assert.ok(replica.layer(COMMAND_A), 'optimism exists before the response'); + return { status, errors: [{message: 'application generation is reloading', + extensions: {code: 'APPLICATION_RELOADING'}}] }; + } + }, {change: artifact()}); + await assert.rejects(runtime.commands.change({id: 'todo-1', title: 'blocked'}, {commandId: COMMAND_A}), + {code: status === 503 ? 'REPLICA_COMMAND_RELOADING' : 'REPLICA_COMMAND_PROTOCOL_INVALID'}); + assert.equal(replica.layer(COMMAND_A), undefined); + runtime.dispose(); + } +}); + test('actual delta rebases later optimism while same-record dispatch retains invocation order', async () => { const replica = new TestReplica(); const first = deferred(); @@ -2797,3 +2815,166 @@ async function directProjectionRuntime() { ); return { replica, runtime }; } + +function lazyRuntime(replica, transport, load, options) { + return createLazyReplicaCommandRuntime(replica, transport, + { commands: { 'todo.upsert': { operationHash: HASH_A, hasInput: true } }, status: STATUS }, + load ?? (async () => ({ entries: { 'todo.upsert': artifact() } })), options); +} + +test('lazy commands register authority before loading, share imports, and snapshot invocation inputs', async () => { + const replica = new TestReplica(); + let registrations = 0; + const register = replica[replicaCommandAuthority].bind(replica); + replica[replicaCommandAuthority] = (contract) => { registrations++; return register(contract); }; + const gate = deferred(); + let loads = 0; + const requests = []; + const runtime = lazyRuntime(replica, { + dispatch: async request => { requests.push(request); return envelope(request); }, + status: async () => { throw new Error('not requested'); } + }, () => { loads++; return gate.promise; }); + assert.equal(registrations, 1); + assert.equal(loads, 0); + assert.deepEqual(runtime.pendingCommandIds(), []); + const input = { id: 'todo-1', title: 'original' }; + const first = runtime.commands.todo.upsert(input, { commandId: COMMAND_A }); + input.title = 'edited while loading'; + const second = runtime.commands.todo.upsert({ id: 'todo-2', title: 'second' }, { commandId: COMMAND_B }); + await tick(); + assert.equal(loads, 1); + assert.equal(requests.length, 0); + gate.resolve({ entries: { 'todo.upsert': artifact() } }); + const receipts = await Promise.all([first, second]); + assert.deepEqual(receipts.map(r => r.commandId), [COMMAND_A, COMMAND_B]); + assert.equal(requests[0].variables.input.title, 'original'); + assert.equal(replica.record('todo-1').fields.title, 'original'); + await runtime.preload(); + assert.equal(loads, 1); + runtime.dispose(); +}); + +for (const failure of ['scope', 'dispose', 'abort']) { + test(`lazy commands reject ${failure} while the chunk is still loading without dispatch`, async () => { + const replica = new TestReplica(); + const gate = deferred(); + const abort = new AbortController(); + let dispatches = 0; + const runtime = lazyRuntime(replica, { + dispatch: async request => { dispatches++; return envelope(request); }, status: async () => {} + }, () => gate.promise); + const pending = runtime.commands.todo.upsert({ id: 'todo-1', title: 'stale' }, { commandId: COMMAND_A, signal: abort.signal }); + if (failure === 'scope') replica.invalidate(); + if (failure === 'dispose') runtime.dispose(); + if (failure === 'abort') abort.abort(); + await assert.rejects(pending, { code: failure === 'scope' ? 'REPLICA_COMMAND_SCOPE_INVALIDATED' : failure === 'dispose' ? 'REPLICA_COMMAND_DISPOSED' : 'REPLICA_COMMAND_ABORTED' }); + assert.equal(dispatches, 0); + gate.resolve({ entries: { 'todo.upsert': artifact() } }); + await tick(); + assert.equal(dispatches, 0); + assert.equal(replica.record('todo-1'), undefined); + runtime.dispose(); + }); +} + +test('lazy chunk failures retry without dispatch and reject mismatched inventory', async () => { + const replica = new TestReplica(); + let loads = 0; + let dispatches = 0; + const runtime = lazyRuntime(replica, { + dispatch: async request => { dispatches++; return envelope(request); }, status: async () => {} + }, async () => { + if (++loads === 1) throw new Error('chunk unavailable'); + return { entries: { 'todo.upsert': artifact() } }; + }); + await assert.rejects(runtime.preload(), /chunk unavailable/); + await runtime.preload(); + assert.equal(loads, 2); + assert.equal(dispatches, 0); + runtime.dispose(); + const mismatch = lazyRuntime(replica, { status: async () => {} }, async () => ({ entries: { 'todo.upsert': { ...artifact(), operationHash: HASH_D } } })); + await assert.rejects(mismatch.preload(), /does not match its catalog/); + mismatch.dispose(); +}); + +test('lazy commands preserve transport retry identity, status recovery and pending IDs', async () => { + const replica = new TestReplica(); + const requests = []; + const runtime = lazyRuntime(replica, { + dispatch(request) { requests.push(request); return Promise.reject(new Error('ambiguous')); }, + status(request) { + return Promise.resolve(statusEnvelope(request, commandMetadata(requests[0], { state: 'rejected', projection: false }))); + } + }); + let recovery; + await assert.rejects(runtime.commands.todo.upsert({ id: 'todo-1', title: 'preview' }, { commandId: COMMAND_A, transportRetries: 1 }), error => { + recovery = error.recovery; + return error.code === 'REPLICA_COMMAND_TRANSPORT_AMBIGUOUS'; + }); + assert.equal(requests.length, 2); + assert.equal(requests[0].variables, requests[1].variables); + assert.deepEqual(runtime.pendingCommandIds(), [COMMAND_A]); + assert.equal(replica.layer(COMMAND_A), 'optimistic'); + await runtime.preload(); + assert.equal((await recovery.status()).state, 'rejected'); + assert.equal(replica.record('todo-1'), undefined); + assert.deepEqual(runtime.pendingCommandIds(), []); + runtime.dispose(); +}); + +test('lazy commands recheck the reload dispatch gate after import', async () => { + const replica = new TestReplica(); + const gate = deferred(); + let reloading = false; + let dispatches = 0; + const runtime = lazyRuntime(replica, { dispatch: async () => { dispatches++; }, status: async () => {} }, () => gate.promise, + { lifecycle: { assertDispatchOpen() { if (reloading) throw new Error('reload'); } } }); + const pending = runtime.commands.todo.upsert({ id: 'todo-1', title: 'preview' }, { commandId: COMMAND_A }); + reloading = true; + gate.resolve({ entries: { 'todo.upsert': artifact() } }); + await assert.rejects(pending, { code: 'REPLICA_COMMAND_RELOADING' }); + assert.equal(dispatches, 0); + assert.equal(replica.record('todo-1'), undefined); + runtime.dispose(); +}); + +test('lazy no-input commands keep options, cancellation, command ID and callbacks in the first argument', async () => { + const replica = new TestReplica(); + const gate = deferred(); + const noInput = { ...artifact({ modeled: false, revalidate: true }), name: 'todo.ping', input: { kind: 'none' } }; + let calls = 0; + let succeeded = 0; + const runtime = createLazyReplicaCommandRuntime(replica, { + dispatch: async request => { calls++; assert.equal(request.commandId, COMMAND_A); return envelope(request, { command: commandReceipt({ commandId: request.commandId, causationId: `cause:${request.commandId}`, state: 'succeeded', consistency: 'eventual', expects: [], observations: [], records: [] }) }); }, + status: async () => {} + }, { commands: { 'todo.ping': { operationHash: HASH_A, hasInput: false } }, status: STATUS }, () => gate.promise); + const abort = new AbortController(); + const cancelled = runtime.commands.todo.ping({ signal: abort.signal, commandId: COMMAND_B }); + abort.abort(); + await assert.rejects(cancelled, { code: 'REPLICA_COMMAND_ABORTED' }); + gate.resolve({ entries: { 'todo.ping': noInput } }); + await runtime.preload(); + const receipt = await runtime.commands.todo.ping({ commandId: COMMAND_A, onSucceeded() { succeeded++; } }); + assert.equal(receipt.commandId, COMMAND_A); + assert.equal(calls, 1); + assert.equal(succeeded, 1); + runtime.dispose(); +}); + +test('calls made after preload cannot overtake earlier commands waiting for the same import', async () => { + const replica = new TestReplica(); + const gate = deferred(); + const requests = []; + const runtime = lazyRuntime(replica, { + dispatch: async request => { requests.push(request.commandId); return envelope(request); }, status: async () => {} + }, () => gate.promise); + const preload = runtime.preload(); + // This continuation runs before the cold callers' import continuations. + const warm = preload.then(() => runtime.commands.todo.upsert({ id: 'todo-3', title: 'third' }, { commandId: COMMAND_C })); + const first = runtime.commands.todo.upsert({ id: 'todo-1', title: 'first' }, { commandId: COMMAND_A }); + const second = runtime.commands.todo.upsert({ id: 'todo-2', title: 'second' }, { commandId: COMMAND_B }); + gate.resolve({ entries: { 'todo.upsert': artifact() } }); + await Promise.all([first, second, warm]); + assert.deepEqual(requests, [COMMAND_A, COMMAND_B, COMMAND_C]); + runtime.dispose(); +}); diff --git a/js/tests/replica-graphql-transport.test.mjs b/js/tests/replica-graphql-transport.test.mjs index a4ad78e61..f5c8b355b 100644 --- a/js/tests/replica-graphql-transport.test.mjs +++ b/js/tests/replica-graphql-transport.test.mjs @@ -197,6 +197,11 @@ test('replica GraphQL live work merges surface binding with resume and closes on } } }); + const credit = { gatewayOperation: '1', gatewayDeliveryAck: 'opaque-credit' }; + socket.message({ type: 'ping', payload: credit }); + assert.deepEqual(socket.sent.at(-1), { type: 'pong', payload: credit }); + socket.message({ type: 'ping' }); + assert.deepEqual(socket.sent.at(-1), { type: 'pong' }); socket.message({ type: 'next', payload: { data: { todos: [{ id: '1' }] } } }); assert.deepEqual(next, [{ data: { todos: [{ id: '1' }] } }]); assert.deepEqual(errors, []); diff --git a/js/tests/replica-index-maintenance.test.mjs b/js/tests/replica-index-maintenance.test.mjs index 7ddda9422..a2537b4ef 100644 --- a/js/tests/replica-index-maintenance.test.mjs +++ b/js/tests/replica-index-maintenance.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createCacheEngine } from '../dist/internal/cache-engine.js'; +import { relationshipMembership } from '../dist/replica/index-maintenance/engine.js'; import { replaceOptimisticLayerOn } from '../dist/replica/distributed-replica/impl-optimistic.js'; import { createReplicaIndexMaintenanceRegistry, @@ -14,6 +15,31 @@ const Todo = Object.freeze({ id: 'Todo', identityFields: Object.freeze(['id']) } const Board = Object.freeze({ id: 'Board', identityFields: Object.freeze(['id']) }); const Card = Object.freeze({ id: 'Card', identityFields: Object.freeze(['id']) }); +test('direct candidate-key membership preserves row identity and SQL null semantics', () => { + const plan = { path: ['object'], relationship: { + keyMapping: { kind: 'direct', local: ['namespace', 'revision'], remote: ['scope', 'version'] } + } }; + const parent = { fields: { id: 'parent-id', namespace: 'team-a', revision: 'v1' } }; + const records = new Map([['parent-key', parent]]); + const branch = { metadata: { parent: 'parent-key' } }; + const target = { fields: { id: 'opaque-object-id', scope: 'team-a', version: 'v1' } }; + const membership = () => relationshipMembership(plan, branch, target, records); + assert.deepEqual(membership(), { related: true }); + target.fields.scope = 'team-b'; + assert.deepEqual(membership(), { related: false }); + target.fields.scope = 'team-a'; + parent.fields.revision = 'v2'; + assert.deepEqual(membership(), { related: false }); + target.fields.version = 'v2'; + assert.deepEqual(membership(), { related: true }); + assert.equal(target.fields.id, 'opaque-object-id'); + parent.fields.revision = null; + target.fields.version = null; + assert.deepEqual(membership(), { related: false }); + delete target.fields.version; + assert.equal(membership().reason.code, 'missing_field'); +}); + const COMPLETE = Object.freeze({ kind: 'complete' }); const COMPLETE_PAGINATION = Object.freeze({ kind: 'complete', diff --git a/js/tests/replica-protocol.test.mjs b/js/tests/replica-protocol.test.mjs index d12f99010..92230bf7c 100644 --- a/js/tests/replica-protocol.test.mjs +++ b/js/tests/replica-protocol.test.mjs @@ -2761,3 +2761,50 @@ test('protocol record scopes remain opaque and never become replica identities', undefined ); }); + +test('issuing-user freshness keeps pending effects separate from confirmed floors', async () => { + const { replicaCommandFreshness } = await import('../dist/replica/command-runtime/symbols.js'); + const artifact = { ...Todos, protocol: { ...Todos.protocol, protocolHash: 'protocol-a' } }; + const requests = []; + let candidate = wireFrame({ position: '1', rows: [{ id: 'todo-1', title: 'base' }] }); + const replica = createDistributedReplica({ transport: { async fetch(request) { requests.push(request); return candidate; } } }); + write(replica, { position: '1', rows: [{ id: 'todo-1', title: 'base' }] }, 'network', artifact); + const watch = replica.watch(artifact, {}); + const plan = { dependencies: ['todos'], models: [Todo.id], relationships: [] }; + replica[replicaCommandFreshness]('cmd-1', plan); + replica.createOptimisticLayer('cmd-1', writer => writer.writeRecord(Todo, 'todo-1', { fields: { title: 'preview' } })); + replica.markOptimisticLayerAccepted('cmd-1', commandMetadata()); + await watch.refresh(); + assert.equal(requests.at(-1).extensions.gatewayFreshness.pending.length, 1); + assert.equal(replica.read(artifact, {}).data.todos[0].title, 'preview'); + candidate = wireFrame({ position: '2', revision: '2', rows: [{ id: 'todo-1', title: 'committed' }], observations: [{ causationId: 'cause-1', projection: 'todos-projector', model: Todo.id, scopeToken: 'expect:todo-1' }] }); + await watch.refresh(); + assert.equal(replica.read(artifact, {}).data.todos[0].title, 'committed'); + candidate = wireFrame({ position: '1', rows: [{ id: 'todo-1', title: 'lagging' }] }); + await watch.refresh(); + const context = requests.at(-1).extensions.gatewayFreshness; + assert.equal(context.pending.length, 0); + assert(context.minimum.some(floor => floor.kind === 'index' && floor.position === '2')); + assert.equal(replica.read(artifact, {}).data.todos[0].title, 'committed'); + watch.destroy(); +}); + +test('Atomic installs a retained outgoing floor without an automatic fetch', async () => { + const { replicaCommandFreshness } = await import('../dist/replica/command-runtime/symbols.js'); + const artifact = { ...Todos, protocol: { ...Todos.protocol, protocolHash: 'protocol-a' } }; + const requests = []; + const replica = createDistributedReplica({ transport: { async fetch(request) { requests.push(request); return wireFrame({ position: '1', rows: [{ id: 'todo-1', title: 'lagging' }] }); } } }); + write(replica, { position: '1', rows: [{ id: 'todo-1', title: 'base' }] }, 'network', artifact); + const watch = replica.watch(artifact, {}); + replica[replicaCommandFreshness]('atomic', { dependencies: ['todos'], models: [Todo.id], relationships: [] }); + replica.createOptimisticLayer('atomic', writer => writer.writeRecord(Todo, 'todo-1', { fields: { title: 'preview' } })); + replica[replicaCommandDirectProjection]('atomic', { model: Todo, identity: 'todo-1', evidence: { model: Todo.id, scopeToken: 'record:todo-1', incarnation: '1', revision: '2', tombstone: false }, fields: { id: 'todo-1', title: 'committed', __typename: Todo.id } }); + assert.equal(requests.length, 0); + assert.equal(replica.read(artifact, {}).data.todos[0].title, 'committed'); + await watch.refresh(); + const context = requests.at(-1).extensions.gatewayFreshness; + assert.equal(context.pending.length, 0); + assert(context.minimum.some(floor => floor.kind === 'record' && floor.revision === '2')); + assert.equal(replica.read(artifact, {}).data.todos[0].title, 'committed'); + watch.destroy(); +}); diff --git a/js/tests/sveltekit-ssr.test.mjs b/js/tests/sveltekit-ssr.test.mjs index f24b93360..2bde3a62e 100644 --- a/js/tests/sveltekit-ssr.test.mjs +++ b/js/tests/sveltekit-ssr.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; +import {createLazyReplicaCommandRuntime} from '../dist/replica/command-runtime/lazy.js'; import test from 'node:test'; import { createDistributedSvelteKit, + createPageDataSessionSource, createDistributedSvelteKitServer, defineDistributedBoundaryBinding, defineDistributedBoundaryOperation @@ -10,6 +12,7 @@ import { import { REACT_FIXTURE_SCHEMA, TodosArtifact, + TodoModel, todoFrame } from './fixtures/adapter-conformance.mjs'; @@ -480,3 +483,102 @@ test('one browser replica refuses to mix user and elevated generated surfaces', ); client.destroy(); }); + + +test('lazy command authority preserves isolated SSR hydration and query prefetch without importing commands', async () => { + const harness = serverHarness(); + const [alice, bob] = await Promise.all([harness.server.load(harness.event('alice')), harness.server.load(harness.event('bob'))]); + const hash = `sha256:${'d'.repeat(64)}`; + const status = {name:'Status',document:'query Status { commandStatus { state } }', operationHash:hash, protocol:{...TodosArtifact.protocol, protocolHash:`sha256:${'c'.repeat(64)}`, operation:hash}}; + let imports=0;let fetches=0; + const clients=[alice,bob].map((data,i)=>createDistributedSvelteKit({ + browser:false, boundaries:[todosBoundary], session:{getAuth:()=>({accessToken:i===0?'alice':'bob'})}, + hydration:data.distributed, authority:data.distributedAuthority, + fetch:async()=>{fetches++;throw Error('hydrated selection must not refetch')}, + createCommands:(replica,transport)=>createLazyReplicaCommandRuntime(replica,transport, + {commands:{'todo.ping':{operationHash:hash,hasInput:false}},status},async()=>{imports++;throw Error('commands must stay deferred')}) + })); + for(const [i,client] of clients.entries()){ + const store=client.operation(TodosArtifact).use(); + const release=store.subscribe(()=>{}); + assert.equal(store.get().data.todos[0].id,i===0?'todo-alice':'todo-bob'); + await client.prefetchLocation('/todos',{search:new URLSearchParams(),session:{},props:{}}); + release(); + } + assert.equal(imports,0);assert.equal(fetches,0); + clients.forEach(client=>client.destroy()); + await assert.rejects(clients[0].preloadCommands(),/destroyed/); +}); + + +test('fresh same-scope SSR authority rotates credentials without emptying the visible replica', async () => { + const harness = serverHarness(); + const first = await harness.server.load(harness.event('alice', '1')); + const second = await harness.server.load(harness.event('alice', '2')); + const pageData = createPageDataSessionSource(first); + const requests = []; + SsrWebSocket.instances.length = 0; + const client = createDistributedSvelteKit({ + boundaries: [todosBoundary], session: pageData.session, + hydration: first.distributed, authority: first.distributedAuthority, + fetch: (url, init) => new Promise(resolve => requests.push({init, resolve})), + webSocket: SsrWebSocket + }); + const todos = client.operation(TodosArtifact).use(); + const values = []; + const unsubscribe = todos.subscribe(snapshot => values.push(snapshot.data.todos?.[0]?.title)); + await flushMicrotasks(); + const oldSocket = SsrWebSocket.instances[0]; + const oldRequest = todos.refetch(); + await flushMicrotasks(); + assert.equal(requests.length, 1); + client.replica.writeResult(TodosArtifact, {}, todoFrame(TodosArtifact, [{id:'todo-alice', title:'confirmed ahead', status:'open'}], {cacheScope:'cache:alice', position:'3'}), 'network'); + client.replica.createOptimisticLayer('pending-edit', writer => writer.writeRecord(TodoModel, 'todo-alice', {fields:{title:'optimistic edit'}})); + pageData.set({...second, accessToken: 'rotated-alice'}); + await flushMicrotasks(); + assert.ok(values.every(value => value === 'alice:1' || value === 'confirmed ahead' || value === 'optimistic edit'), JSON.stringify(values)); + assert.equal(todos.get().data.todos[0].title, 'optimistic edit'); + assert.equal(requests[0].init.signal.aborted, true); + assert.equal(oldSocket.closed, true); + assert.equal(requests.length, 1, 'fresh SSR data needs no replacement browser query'); + const nextSocket = SsrWebSocket.instances.at(-1); + assert.notEqual(nextSocket, oldSocket); + nextSocket.open(); + await flushMicrotasks(); + assert.equal(nextSocket.sent[0].payload.authorization, 'Bearer rotated-alice'); + requests[0].resolve(jsonResponse(todoFrame(TodosArtifact, [{id:'todo-alice',title:'late old credential',status:'open'}], {cacheScope:'cache:alice',position:'3'}))); + await oldRequest; + assert.equal(todos.get().data.todos[0].title, 'optimistic edit', 'closed credential work cannot overwrite the new seed'); + client.replica.rejectOptimisticLayer('pending-edit'); + assert.equal(todos.get().data.todos[0].title, 'confirmed ahead', 'the refresh seed cannot overwrite a newer confirmed command result'); + unsubscribe(); client.destroy(); +}); + +for (const kind of ['missing', 'replayed', 'historical', 'tampered', 'different-scope', 'logout']) { + test(`credential change with ${kind} hydration still purges the old replica`, async () => { + const harness = serverHarness(); + const first = await harness.server.load(harness.event('alice')); + const second = await harness.server.load(harness.event(kind === 'different-scope' ? 'bob' : 'alice', '2')); + const pageData = createPageDataSessionSource(first); + const client = createDistributedSvelteKit({ + boundaries: [todosBoundary], session: pageData.session, + hydration: first.distributed, authority: first.distributedAuthority, + fetch: () => new Promise(() => {}), webSocket: SsrWebSocket + }); + const todos = client.operation(TodosArtifact).use({}, {live:false}); + const unsubscribe = todos.subscribe(() => {}); + await flushMicrotasks(); + let next = {...second, accessToken:'new-credential'}; + if(kind === 'missing') {delete next.distributed;delete next.distributedAuthority;} + if(kind === 'historical') {pageData.set({...first, distributed:undefined, distributedAuthority:undefined});await flushMicrotasks();} + if(kind === 'replayed' || kind === 'historical') next = {...first, accessToken:'new-credential'}; + if(kind === 'tampered') {next.distributed = structuredClone(next.distributed);next.distributed.state.scope.cacheScope = 'cache:forged';} + if(kind === 'logout') next = {session:null}; + pageData.set(next); + await flushMicrotasks(); + assert.equal(todos.get().complete, false); + assert.deepEqual(todos.get().data, {}); + assert.equal(client.replica.scope, undefined); + unsubscribe(); client.destroy(); + }); +} diff --git a/js/tests/sveltekit-vite.test.mjs b/js/tests/sveltekit-vite.test.mjs index cca64d4c8..19baa475c 100644 --- a/js/tests/sveltekit-vite.test.mjs +++ b/js/tests/sveltekit-vite.test.mjs @@ -1143,8 +1143,14 @@ test('supervised Vite defers generated-client compilation to the lifecycle', asy process.env.DISTRIBUTED_LIFECYCLE_PROJECT_ROOT = previousProjectRoot; } }); + const frameworkDist = join(root, 'node_modules/@hops-ops/distributed/dist'); + await mkdir(join(frameworkDist, 'replica'), { recursive: true }); const plugin = distributedSvelteKit(pluginOptions(root, script)); await plugin.configResolved({ root }); + const lazyEntry = join(await realpath(frameworkDist), 'replica/lazy.js'); + assert.equal(plugin.resolveId('@hops-ops/distributed/replica/lazy'), lazyEntry); + const aliases = distributedSvelteKitAliases({ cwd: root, clients: [clients()[0]] }); + assert.equal(aliases['@hops-ops/distributed/replica/lazy'], lazyEntry); await writeFile(activePointer, 'not-json'); assert.equal( plugin.resolveId('./ordinary-relative-import.js', join(root, 'src/app.ts')), diff --git a/js/tests/unique-key-artifact-bridge.test.mjs b/js/tests/unique-key-artifact-bridge.test.mjs new file mode 100644 index 000000000..a3a9d1051 --- /dev/null +++ b/js/tests/unique-key-artifact-bridge.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createDistributedReplica } from '../dist/replica/index.js'; +import { ControlledReplicaTransport } from './fixtures/adapter-conformance.mjs'; + +import { artifact, owner, frame } from './fixtures/unique-key-artifact.mjs'; + +test('Rust-generated candidate-key relationship survives hydration and live reference changes', async () => { + assert.deepEqual(owner.relationship.keyMapping, { + kind: 'direct', local: ['tenantId', 'ownerTitle'], remote: ['tenantId', 'title'] + }); + assert.deepEqual(owner.selection.storage.identityFields, ['tenantId', 'id']); + const transport = new ControlledReplicaTransport(); + const server = createDistributedReplica({ transport }); + const watch = server.watch(artifact, {}, { live: false }); + const pending = watch.refresh(); + await Promise.resolve(); + transport.fetches[0].response.resolve(frame('1', 'target-one', 'first')); + await pending; + assert.deepEqual(server.read(artifact, {}).data.todos[0], { + id: 'source-id', title: 'source', owner: { id: 'target-one', title: 'first' } + }); + watch.destroy(); + const seed = server.dehydrate(); + const browserTransport = new ControlledReplicaTransport(); + const browser = createDistributedReplica({ transport: browserTransport }); + assert.equal(browser.hydrate(seed, seed.scope), true); + assert.deepEqual(browser.read(artifact, {}).data, server.read(artifact, {}).data); + const live = browser.watch(artifact, {}, { live: true }); + const unsubscribe = live.subscribe(() => {}); + assert.equal(browserTransport.fetches.length, 0); + assert.equal(browserTransport.lives.length, 1); + browserTransport.lives[0].observer.next(frame('2', 'target-two', 'second')); + assert.deepEqual(live.get().data.todos[0], { + id: 'source-id', title: 'source', owner: { id: 'target-two', title: 'second' } + }); + browserTransport.lives[0].observer.next(frame('3', null, null)); + assert.equal(live.get().data.todos[0].owner, null); + assert.equal(live.get().data.todos[0].id, 'source-id'); + unsubscribe(); + live.destroy(); + assert.equal(browserTransport.lives[0].closed, true); +}); diff --git a/js/tsconfig.generated-tests.json b/js/tsconfig.generated-tests.json index ed51963c1..3a19aed02 100644 --- a/js/tsconfig.generated-tests.json +++ b/js/tsconfig.generated-tests.json @@ -11,6 +11,7 @@ "outDir": "./dist-type-tests", "paths": { "@hops-ops/distributed/replica": ["./src/replica/index.ts"], + "@hops-ops/distributed/replica/lazy": ["./src/replica/lazy.ts"], "@hops-ops/distributed/react": ["./src/react/index.ts"], "@hops-ops/distributed/sveltekit": ["./src/sveltekit/index.ts"], "@hops-ops/distributed/sveltekit/vite": ["./src/sveltekit/vite.ts"] diff --git a/js/type-tests/replica-command-runtime.ts b/js/type-tests/replica-command-runtime.ts index ee8069ac1..e1e96c0fd 100644 --- a/js/type-tests/replica-command-runtime.ts +++ b/js/type-tests/replica-command-runtime.ts @@ -1,5 +1,7 @@ +import { createLazyReplicaCommandRuntime } from '@hops-ops/distributed/replica/lazy'; import { createReplicaCommandRuntime, + type ReplicaCommandStatusArtifact, type DistributedReplica, type ReplicaCommandArtifact, type ReplicaCommandTransport @@ -54,3 +56,18 @@ runtime.commands.create(); runtime.commands.ping({ id: 'todo-1' }); // @ts-expect-error Result types cannot bleed between generated commands. runtime.commands.create({ id: 'todo-1' }).then((receipt) => receipt.result.pong); + + +declare const status: ReplicaCommandStatusArtifact; +const lazy = createLazyReplicaCommandRuntime(replica, transport, { + commands: { 'todo.create': { operationHash: 'hash', hasInput: true }, 'todo.ping': { operationHash: 'hash', hasInput: false } }, status +}, async () => ({ entries: { 'todo.create': createArtifact, 'todo.ping': pingArtifact } })); +lazy.commands.todo.create({ id: 'todo-1' }).then(receipt => { const ok: boolean = receipt.result.ok; return ok; }); +lazy.commands.todo.ping({ commandId: 'id' }).then(receipt => { const pong: true = receipt.result.pong; return pong; }); +lazy.preload(); +// @ts-expect-error Lazy commands preserve required input. +lazy.commands.todo.create(); +// @ts-expect-error No-input commands still accept only options. +lazy.commands.todo.ping({ id: 'todo-1' }); +// @ts-expect-error Lazy output inference stays specific to each command. +lazy.commands.todo.create({ id: 'todo-1' }).then(receipt => receipt.result.pong); diff --git a/migrations/inventory.json b/migrations/inventory.json index 1580c1173..ccea03955 100644 --- a/migrations/inventory.json +++ b/migrations/inventory.json @@ -48,6 +48,30 @@ "path": "migrations/postgres/0004_command_ledger_atomic_state.sql", "sha256": "bc49ca9c58a294b7c5876c9fcde8a14a8b6110594a06c48b37720d320a22d97e" } + }, + { + "version": 5, + "description": "projection source snapshots", + "sqlite": { + "path": "migrations/sqlite/0005_projection_source_snapshots.sql", + "sha256": "2cb605be4ec190d9b3f156bdbbeb83a76e5f1f37287a8a7f3c2653dafbede442" + }, + "postgres": { + "path": "migrations/postgres/0005_projection_source_snapshots.sql", + "sha256": "2cb605be4ec190d9b3f156bdbbeb83a76e5f1f37287a8a7f3c2653dafbede442" + } + }, + { + "version": 6, + "description": "gateway dependency versions", + "sqlite": { + "path": "migrations/sqlite/0006_gateway_dependency_versions.sql", + "sha256": "c156bc51dddecd49b1beef7bb4069557b024c19cb1271f0ec9cc0efe71939471" + }, + "postgres": { + "path": "migrations/postgres/0006_gateway_dependency_versions.sql", + "sha256": "c156bc51dddecd49b1beef7bb4069557b024c19cb1271f0ec9cc0efe71939471" + } } ] } diff --git a/migrations/postgres/0005_projection_source_snapshots.sql b/migrations/postgres/0005_projection_source_snapshots.sql new file mode 100644 index 000000000..2d7f5b4a1 --- /dev/null +++ b/migrations/postgres/0005_projection_source_snapshots.sql @@ -0,0 +1,3 @@ +-- Fences survive deletion, process restart and change-log compaction. +-- NULL denotes a delivery-ordered (not source-snapshot) projection. +ALTER TABLE projection_records ADD COLUMN source_snapshot TEXT; diff --git a/migrations/postgres/0006_gateway_dependency_versions.sql b/migrations/postgres/0006_gateway_dependency_versions.sql new file mode 100644 index 000000000..287d427cc --- /dev/null +++ b/migrations/postgres/0006_gateway_dependency_versions.sql @@ -0,0 +1,9 @@ +-- Private gateway dependency versions. Runtime cache activation installs +-- per-table transactional hooks after application read-model migrations. +CREATE TABLE IF NOT EXISTS distributed_gateway_versions ( + namespace TEXT NOT NULL, + table_name TEXT NOT NULL, + epoch TEXT NOT NULL, + version BIGINT NOT NULL CHECK (version >= 0), + PRIMARY KEY (namespace, table_name) +); diff --git a/migrations/sqlite/0005_projection_source_snapshots.sql b/migrations/sqlite/0005_projection_source_snapshots.sql new file mode 100644 index 000000000..2d7f5b4a1 --- /dev/null +++ b/migrations/sqlite/0005_projection_source_snapshots.sql @@ -0,0 +1,3 @@ +-- Fences survive deletion, process restart and change-log compaction. +-- NULL denotes a delivery-ordered (not source-snapshot) projection. +ALTER TABLE projection_records ADD COLUMN source_snapshot TEXT; diff --git a/migrations/sqlite/0006_gateway_dependency_versions.sql b/migrations/sqlite/0006_gateway_dependency_versions.sql new file mode 100644 index 000000000..287d427cc --- /dev/null +++ b/migrations/sqlite/0006_gateway_dependency_versions.sql @@ -0,0 +1,9 @@ +-- Private gateway dependency versions. Runtime cache activation installs +-- per-table transactional hooks after application read-model migrations. +CREATE TABLE IF NOT EXISTS distributed_gateway_versions ( + namespace TEXT NOT NULL, + table_name TEXT NOT NULL, + epoch TEXT NOT NULL, + version BIGINT NOT NULL CHECK (version >= 0), + PRIMARY KEY (namespace, table_name) +); diff --git a/src/aggregate/mod.rs b/src/aggregate/mod.rs index 8ee68cf1e..4c89b876b 100644 --- a/src/aggregate/mod.rs +++ b/src/aggregate/mod.rs @@ -2,5 +2,5 @@ mod aggregate; mod repository; pub use aggregate::{hydrate, Aggregate}; -pub(crate) use repository::SnapshotPolicy; pub use repository::{AggregateBuilder, AggregateRepository}; +pub(crate) use repository::{SnapshotPolicy, StreamReads}; diff --git a/src/aggregate/repository.rs b/src/aggregate/repository.rs index a2dc949dc..82aaf2f29 100644 --- a/src/aggregate/repository.rs +++ b/src/aggregate/repository.rs @@ -2,6 +2,7 @@ use std::future::Future; use std::marker::PhantomData; use std::pin::Pin; +use crate::command_ledger::CausalGetStream; use crate::entity::Entity; use crate::outbox::OutboxPublisherConfig; use crate::queued_repo::{GetAllWithOpts, GetWithOpts, ReadOpts, UnlockableRepository}; @@ -69,10 +70,41 @@ type HydrateAllFn = type LoadFn = for<'a> fn( &'a R, &'a StreamIdentity, + StreamReads, ) -> Pin< Box, RepositoryError>> + Send + 'a>, >; +type StreamReadFuture<'a> = + Pin, RepositoryError>> + Send + 'a>>; + +/// The same snapshot algorithm uses ordinary or explicitly non-locking reads. +/// Capturing the functions keeps with_snapshots independent of causal bounds. +pub(crate) struct StreamReads { + pub full: for<'a> fn(&'a R, &'a StreamIdentity) -> StreamReadFuture<'a>, + pub tail: for<'a> fn(&'a R, &'a StreamIdentity, u64) -> StreamReadFuture<'a>, +} + +impl StreamReads { + fn ordinary() -> Self { + Self { + full: |repo, identity| Box::pin(repo.get_stream(identity)), + tail: |repo, identity, version| Box::pin(repo.get_stream_tail(identity, version)), + } + } +} + +impl StreamReads { + fn causal() -> Self { + Self { + full: |repo, identity| Box::pin(repo.get_causal_stream(identity)), + tail: |repo, identity, version| { + Box::pin(repo.get_causal_stream_tail(identity, version)) + }, + } + } +} + impl SnapshotPolicy { /// Construct a policy from its captured hooks. Called by `with_snapshots`, /// which carries the `Snapshottable`/`SnapshotStore`/`GetStream` bounds. @@ -192,6 +224,30 @@ where } } +impl AggregateRepository +where + A: Aggregate + Send, +{ + /// Causal handlers use the same snapshot policy, with non-locking reads. + pub(crate) async fn get_causal( + &self, + identity: &StreamIdentity, + ) -> Result, RepositoryError> + where + R: CausalGetStream, + { + match &self.snapshot { + Some(policy) => (policy.load)(&self.repo, identity, StreamReads::causal()).await, + None => self + .repo + .get_causal_stream(identity) + .await? + .map(hydrate::) + .transpose(), + } + } +} + impl AggregateRepository where R: GetStream, @@ -204,7 +260,7 @@ where // and decode of already-snapshotted events). Without one, a plain full // stream load. Same hydrated aggregate either way. match &self.snapshot { - Some(policy) => (policy.load)(&self.repo, &identity).await, + Some(policy) => (policy.load)(&self.repo, &identity, StreamReads::ordinary()).await, None => { let Some(entity) = self.repo.get_stream(&identity).await? else { return Ok(None); diff --git a/src/application/capability.rs b/src/application/capability.rs index 2a8cad36a..7da3491ae 100644 --- a/src/application/capability.rs +++ b/src/application/capability.rs @@ -10,7 +10,7 @@ use super::error::{ApplicationError, ApplicationResult}; use super::identity::{canonical_json, sha256_fingerprint}; use super::manifest::ApplicationManifest; use super::mount::MountSelector; -use crate::graphql::command_contract::CommandConsistency; +use crate::command::CommandConsistency; /// A named logical capability required by one or more mounts. #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] @@ -186,9 +186,7 @@ pub fn derive_process_capabilities( &mut reasons, Capability::DirectProjectionTransaction, Some(mount), - format!( - "atomic command `{id}` requires collocated direct projection" - ), + format!("atomic command `{id}` requires collocated direct projection"), ); push( &mut reasons, @@ -289,7 +287,10 @@ pub fn derive_process_capabilities( Some(mount), format!("surface `{id}` dispatches commands remotely"), ); - } else if mounts.iter().any(|m| matches!(m, MountSelector::Command { .. })) { + } else if mounts + .iter() + .any(|m| matches!(m, MountSelector::Command { .. })) + { push( &mut reasons, Capability::LocalCommandDispatch, @@ -358,10 +359,7 @@ pub fn derive_process_capabilities( return Err(ApplicationError::Collision { kind: "schema lifecycle owner", identity: schema_owner.clone().unwrap_or_default(), - reason: format!( - "expected single logical owner `{}`", - manifest.name - ), + reason: format!("expected single logical owner `{}`", manifest.name), }); } } diff --git a/src/application/command.rs b/src/application/command.rs index 42b639a50..f3a604c11 100644 --- a/src/application/command.rs +++ b/src/application/command.rs @@ -7,12 +7,12 @@ use serde::{Deserialize, Serialize}; use super::error::{ApplicationError, ApplicationResult}; use super::identity::{canonical_json, sha256_fingerprint, LogicalId}; -use crate::graphql::command_contract::{ - CommandConsistency, CommandOutcome, TypedCommand, TypedCommandContract, +use crate::command::{ + CommandConsistency, CommandInputType, CommandOutcome, CommandTypeDef, TypedCommand, + TypedCommandContract, }; -use crate::graphql::{GraphqlInputType, GraphqlTypeDef}; -/// Serializable GraphQL type field used by a portable command contract. +/// Serializable command type field used by a portable command contract. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct CommandTypeField { @@ -25,7 +25,7 @@ pub struct CommandTypeField { pub nested: Option>, } -/// Serializable GraphQL input/output type used by a portable command contract. +/// Serializable command input/output type used by a portable command contract. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct CommandTypeSpec { @@ -35,8 +35,8 @@ pub struct CommandTypeSpec { pub type TypeSpec = CommandTypeSpec; -impl From<&GraphqlTypeDef> for CommandTypeSpec { - fn from(definition: &GraphqlTypeDef) -> Self { +impl From<&CommandTypeDef> for CommandTypeSpec { + fn from(definition: &CommandTypeDef) -> Self { Self { name: definition.name.clone(), fields: definition @@ -113,9 +113,7 @@ pub fn admit_command_session( user_id: Option<&str>, session_roles: &[&str], ) -> Result<(), &'static str> { - if command_roles_require_principal(roles) - && user_id.map(str::trim).unwrap_or("").is_empty() - { + if command_roles_require_principal(roles) && user_id.map(str::trim).unwrap_or("").is_empty() { return Err("unauthenticated"); } if roles.is_empty() { @@ -201,7 +199,7 @@ impl CommandDefinition { mount: Option, ) -> ApplicationResult where - I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + I: CommandInputType + serde::de::DeserializeOwned + Send + 'static, K: CommandOutcome, { let (_, typed_contract) = command.into_parts(); @@ -234,8 +232,7 @@ fn validate_mount_spec(spec: &CommandSpec, mount: &CommandMount) -> ApplicationR return Err(ApplicationError::Collision { kind: "command", identity: spec.id.clone(), - reason: "command definition and executable mount do not share one spec identity" - .into(), + reason: "command definition and executable mount do not share one spec identity".into(), }); } Ok(()) @@ -291,7 +288,8 @@ impl CommandSpec { proof: serde_json::Value, ) -> ApplicationResult { self.consistency = CommandConsistency::Atomic; - self.projected_model = Some(LogicalId::try_new("projected model", projected_model)?.into_string()); + self.projected_model = + Some(LogicalId::try_new("projected model", projected_model)?.into_string()); self.direct_projection = Some(proof); self.refresh_fingerprint()?; self.validate()?; @@ -360,7 +358,7 @@ impl CommandSpec { /// Build a portable spec from the framework's existing typed declaration. pub fn from_typed_command(command: &TypedCommand) -> ApplicationResult where - I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + I: CommandInputType + serde::de::DeserializeOwned + Send + 'static, K: CommandOutcome, { let (_, contract) = command.clone().into_parts(); @@ -368,19 +366,19 @@ impl CommandSpec { } pub(crate) fn from_contract( - contract: &crate::graphql::command_contract::TypedCommandContract, + contract: &crate::command::TypedCommandContract, ) -> ApplicationResult { let projection_contract = serde_json::to_value(&contract.projections)?; let applies = serde_json::to_value(&contract.projections.previews)?; let confirmations = contract .confirmations .iter() - .map(crate::graphql::command_contract::CommandProjectionConfirmation::canonical_value) + .map(crate::command::CommandProjectionConfirmation::canonical_value) .collect(); let direct_projection = contract .direct_projection .as_ref() - .map(crate::graphql::command_contract::CommandDirectProjectionTarget::canonical_value); + .map(crate::command::CommandDirectProjectionTarget::canonical_value); let mut emits: Vec = contract .projections .selectors @@ -397,11 +395,16 @@ impl CommandSpec { }) .collect(); emits.sort_by(|left, right| { - (left.name.as_str(), left.version, left.body_fingerprint.as_str()).cmp(&( - right.name.as_str(), - right.version, - right.body_fingerprint.as_str(), - )) + ( + left.name.as_str(), + left.version, + left.body_fingerprint.as_str(), + ) + .cmp(&( + right.name.as_str(), + right.version, + right.body_fingerprint.as_str(), + )) }); let mut roles = contract.roles.clone(); roles.sort(); @@ -597,11 +600,7 @@ fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> Appl super::manifest::MAX_MANIFEST_JSON_BYTES ))); } - fn walk( - kind: &'static str, - value: &serde_json::Value, - depth: usize, - ) -> ApplicationResult<()> { + fn walk(kind: &'static str, value: &serde_json::Value, depth: usize) -> ApplicationResult<()> { if depth > super::manifest::MAX_MANIFEST_JSON_DEPTH { return Err(ApplicationError::InvalidSpec(format!( "{kind} exceeds JSON depth {}", @@ -610,7 +609,8 @@ fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> Appl } match value { serde_json::Value::String(value) => { - if value.len() > super::manifest::MAX_MANIFEST_STRING_BYTES || value.contains('\0') { + if value.len() > super::manifest::MAX_MANIFEST_STRING_BYTES || value.contains('\0') + { return Err(ApplicationError::InvalidSpec(format!( "{kind} contains oversized or NUL string material" ))); @@ -633,8 +633,7 @@ fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> Appl ))); } for (key, value) in fields { - if key.len() > super::manifest::MAX_MANIFEST_STRING_BYTES - || key.contains('\0') + if key.len() > super::manifest::MAX_MANIFEST_STRING_BYTES || key.contains('\0') { return Err(ApplicationError::InvalidSpec(format!( "{kind} contains oversized or NUL object-key material" @@ -643,7 +642,8 @@ fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> Appl walk(kind, value, depth + 1)?; } } - serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + } } Ok(()) } @@ -656,9 +656,11 @@ fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> Appl /// `Any` value: a runtime can invoke it without knowing the concrete function /// item type or attempting a downcast. pub type CommandMountFuture<'a> = Pin< - Box> - + Send - + 'a>, + Box< + dyn Future> + + Send + + 'a, + >, >; pub trait CommandMountHandler: Send + Sync { @@ -669,7 +671,10 @@ pub trait CommandMountHandler: Send + Sync { /// not executable merely because it contains a value; an adapter must accept /// it into its registry before it can be dispatched. pub trait CommandMountRegistrar { - fn register_command_mount(&mut self, mount: CommandMount) -> Result<(), crate::microsvc::HandlerError>; + fn register_command_mount( + &mut self, + mount: CommandMount, + ) -> Result<(), crate::microsvc::HandlerError>; } /// Invocation context for the heterogeneous runtime boundary. The ordinary @@ -728,9 +733,8 @@ pub(crate) enum CommandMountExecutionError { #[allow(dead_code)] pub(crate) type CommandMountExecutionFuture<'a> = Pin< Box< - dyn Future< - Output = Result, - > + Send + dyn Future> + + Send + 'a, >, >; @@ -819,9 +823,8 @@ impl CommandMount { pub fn from_request_handler(spec: CommandSpec, handler: H) -> Self where H: Fn(crate::microsvc::CommandRequest) -> F + Send + Sync + 'static, - F: Future< - Output = Result, - > + Send + F: Future> + + Send + 'static, { Self::from_handler(spec, RequestCommandMountHandler(handler)) @@ -886,7 +889,7 @@ impl CommandMount { impl TypedCommand where - I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + I: CommandInputType + serde::de::DeserializeOwned + Send + 'static, K: CommandOutcome, { /// Compile the exact declaration into its portable, serializable spec. diff --git a/src/application/manifest.rs b/src/application/manifest.rs index ada263fab..51b931213 100644 --- a/src/application/manifest.rs +++ b/src/application/manifest.rs @@ -8,13 +8,18 @@ use super::identity::{canonical_json, sha256_fingerprint, LogicalId}; use super::module::{ModelSpec, Module, ModuleManifest, ProjectionSpec, SurfaceSpec}; /// Wire/schema version for the complete logical application manifest. -pub const APPLICATION_MANIFEST_SCHEMA_VERSION: u32 = 1; +pub const APPLICATION_MANIFEST_SCHEMA_VERSION: u32 = 2; /// Bounds applied before a portable application artifact is accepted. -pub const MAX_APPLICATION_MANIFEST_BYTES: usize = 1024 * 1024; +/// +/// A complete manifest carries authoritative module declarations and selected +/// Surface contracts. Flattened inventories are reconstructed, not serialized. +pub const MAX_APPLICATION_MANIFEST_BYTES: usize = 4 * 1024 * 1024; pub const MAX_MANIFEST_COLLECTION_ITEMS: usize = 4096; pub const MAX_MANIFEST_STRING_BYTES: usize = 4096; -pub const MAX_MANIFEST_JSON_BYTES: usize = 256 * 1024; +/// Per-value JSON bound. Several independently bounded contracts may comprise +/// one application manifest, but no opaque value receives the whole budget. +pub const MAX_MANIFEST_JSON_BYTES: usize = 1024 * 1024; pub const MAX_MANIFEST_JSON_DEPTH: usize = 32; /// Reserved manifest extension carrying the framework release that compiled it. pub const FRAMEWORK_COMPATIBILITY_EXTENSION_ID: &str = "distributed.framework"; @@ -101,8 +106,7 @@ impl ApplicationExtension { /// executable handlers are intentionally absent. Those belong to named /// schema/deployment/runtime layers and cannot become portable application /// identity by accident. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct ApplicationManifest { /// This field is required during decoding: canonical input may not omit /// the explicit schema version and receive a legacy default. @@ -110,13 +114,13 @@ pub struct ApplicationManifest { pub name: String, #[serde(default)] pub modules: Vec, - #[serde(default)] + #[serde(skip_serializing)] pub commands: Vec, - #[serde(default)] + #[serde(skip_serializing)] pub events: Vec, - #[serde(default)] + #[serde(skip_serializing)] pub projections: Vec, - #[serde(default)] + #[serde(skip_serializing)] pub models: Vec, #[serde(default)] pub surfaces: Vec, @@ -130,7 +134,123 @@ pub struct ApplicationManifest { pub provenance: ManifestProvenance, } +/// Versioned portable authority. Derived inventories are deliberately absent: +/// accepting them would introduce a second, potentially conflicting authority. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestWire { + schema_version: u32, + name: String, + #[serde(default)] + modules: Vec, + #[serde(default)] + surfaces: Vec, + #[serde(default)] + required_capabilities: Vec, + #[serde(default)] + extensions: Vec, + #[serde(default)] + fingerprints: ManifestFingerprint, + #[serde(default)] + provenance: ManifestProvenance, +} + +impl<'de> Deserialize<'de> for ApplicationManifest { + fn deserialize>(deserializer: D) -> Result { + let wire = ManifestWire::deserialize(deserializer)?; + let mut manifest = Self { + schema_version: wire.schema_version, + name: wire.name, + modules: wire.modules, + surfaces: wire.surfaces, + required_capabilities: wire.required_capabilities, + extensions: wire.extensions, + fingerprints: wire.fingerprints, + provenance: wire.provenance, + commands: Vec::new(), + events: Vec::new(), + projections: Vec::new(), + models: Vec::new(), + }; + manifest + .reconstruct_inventories() + .map_err(serde::de::Error::custom)?; + Ok(manifest) + } +} + impl ApplicationManifest { + fn validate_inventory_expansion(&self) -> ApplicationResult<()> { + // Check expansion before cloning any nested declarations. Duplicate + // declarations still consume the reconstruction budget. + validate_collection_len("modules", self.modules.len())?; + validate_collection_len("surfaces", self.surfaces.len())?; + for (kind, count) in [ + ( + "derived commands", + self.modules.iter().map(|m| m.commands.len()).sum(), + ), + ( + "derived events", + self.modules.iter().map(|m| m.events.len()).sum(), + ), + ( + "derived projections", + self.modules + .iter() + .map(|m| m.projections.len()) + .chain(self.surfaces.iter().map(|s| s.projections.len())) + .sum(), + ), + ( + "derived models", + self.modules + .iter() + .map(|m| m.models.len()) + .chain(self.surfaces.iter().map(|s| s.models.len())) + .sum(), + ), + ] { + validate_collection_len(kind, count)?; + } + Ok(()) + } + + fn reconstruct_inventories(&mut self) -> ApplicationResult<()> { + self.validate_inventory_expansion()?; + self.commands = dedup_commands( + self.modules + .iter() + .flat_map(|m| m.commands.iter().cloned()) + .collect(), + )?; + self.events = dedup_events( + self.modules + .iter() + .flat_map(|m| m.events.iter().cloned()) + .collect(), + )?; + self.projections = dedup_projections( + self.modules + .iter() + .flat_map(|m| m.projections.iter().cloned()) + .chain( + self.surfaces + .iter() + .flat_map(|s| s.projections.iter().cloned()), + ) + .collect(), + )?; + self.models = dedup_models( + self.modules + .iter() + .flat_map(|m| m.models.iter().cloned()) + .chain(self.surfaces.iter().flat_map(|s| s.models.iter().cloned())) + .collect(), + )?; + Ok(()) + } + pub fn new(name: impl Into) -> Self { Self { schema_version: APPLICATION_MANIFEST_SCHEMA_VERSION, @@ -256,7 +376,10 @@ impl ApplicationManifest { } pub fn module_ids(&self) -> Vec<&str> { - self.modules.iter().map(|module| module.id.as_str()).collect() + self.modules + .iter() + .map(|module| module.id.as_str()) + .collect() } /// Return exact deterministic manifest bytes, including the explicit @@ -356,6 +479,7 @@ impl ApplicationManifest { }); } LogicalId::try_new("application", self.name.clone())?; + self.validate_inventory_expansion()?; validate_collection_len("modules", self.modules.len())?; validate_collection_len("commands", self.commands.len())?; validate_collection_len("events", self.events.len())?; @@ -363,15 +487,26 @@ impl ApplicationManifest { validate_collection_len("models", self.models.len())?; validate_collection_len("surfaces", self.surfaces.len())?; validate_collection_len("extensions", self.extensions.len())?; - validate_unique_ids("module", self.modules.iter().map(|module| module.id.clone()))?; - validate_unique_ids("command", self.commands.iter().map(|command| command.id.clone()))?; + validate_unique_ids( + "module", + self.modules.iter().map(|module| module.id.clone()), + )?; + validate_unique_ids( + "command", + self.commands.iter().map(|command| command.id.clone()), + )?; validate_unique_ids("event", self.events.iter().map(|event| event.name.clone()))?; validate_unique_ids( "projection", - self.projections.iter().map(|projection| projection.id.clone()), + self.projections + .iter() + .map(|projection| projection.id.clone()), )?; validate_unique_ids("model", self.models.iter().map(|model| model.id.clone()))?; - validate_unique_ids("surface", self.surfaces.iter().map(|surface| surface.id.clone()))?; + validate_unique_ids( + "surface", + self.surfaces.iter().map(|surface| surface.id.clone()), + )?; let model_ids = self .models @@ -480,13 +615,19 @@ impl ApplicationManifest { self.modules.sort_by(|left, right| left.id.cmp(&right.id)); self.commands.sort_by(|left, right| left.id.cmp(&right.id)); self.events.sort_by(|left, right| { - (left.name.as_str(), left.version, left.body_fingerprint.as_str()).cmp(&( - right.name.as_str(), - right.version, - right.body_fingerprint.as_str(), - )) + ( + left.name.as_str(), + left.version, + left.body_fingerprint.as_str(), + ) + .cmp(&( + right.name.as_str(), + right.version, + right.body_fingerprint.as_str(), + )) }); - self.projections.sort_by(|left, right| left.id.cmp(&right.id)); + self.projections + .sort_by(|left, right| left.id.cmp(&right.id)); self.models.sort_by(|left, right| left.id.cmp(&right.id)); self.surfaces.sort_by(|left, right| left.id.cmp(&right.id)); self.required_capabilities.sort(); @@ -550,21 +691,41 @@ fn validate_module( module, require_nested_fingerprints, )?; - validate_unique_ids("module command", module.commands.iter().map(|item| item.id.clone()))?; + validate_unique_ids( + "module command", + module.commands.iter().map(|item| item.id.clone()), + )?; validate_unique_ids( "module projection", module.projections.iter().map(|item| item.id.clone()), )?; - validate_unique_ids("module event", module.events.iter().map(|item| item.name.clone()))?; - validate_unique_ids("module model", module.models.iter().map(|item| item.id.clone()))?; - validate_unique_ids("module surface", module.surfaces.iter().map(|item| item.id.clone()))?; + validate_unique_ids( + "module event", + module.events.iter().map(|item| item.name.clone()), + )?; + validate_unique_ids( + "module model", + module.models.iter().map(|item| item.id.clone()), + )?; + validate_unique_ids( + "module surface", + module.surfaces.iter().map(|item| item.id.clone()), + )?; validate_sorted_unique( "module commands", - &module.commands.iter().map(|item| item.id.clone()).collect::>(), + &module + .commands + .iter() + .map(|item| item.id.clone()) + .collect::>(), )?; validate_sorted_unique( "module events", - &module.events.iter().map(|item| item.name.clone()).collect::>(), + &module + .events + .iter() + .map(|item| item.name.clone()) + .collect::>(), )?; validate_sorted_unique( "module projections", @@ -576,26 +737,40 @@ fn validate_module( )?; validate_sorted_unique( "module models", - &module.models.iter().map(|item| item.id.clone()).collect::>(), + &module + .models + .iter() + .map(|item| item.id.clone()) + .collect::>(), )?; validate_sorted_unique( "module surfaces", - &module.surfaces.iter().map(|item| item.id.clone()).collect::>(), + &module + .surfaces + .iter() + .map(|item| item.id.clone()) + .collect::>(), )?; let module_model_ids = module .models .iter() .map(|model| model.id.as_str()) - .chain(module.surfaces.iter().flat_map(|surface| { - surface.models.iter().map(|model| model.id.as_str()) - })) + .chain( + module + .surfaces + .iter() + .flat_map(|surface| surface.models.iter().map(|model| model.id.as_str())), + ) .collect::>(); let module_projection_ids = module .projections .iter() .map(|projection| projection.id.as_str()) .chain(module.surfaces.iter().flat_map(|surface| { - surface.projections.iter().map(|projection| projection.id.as_str()) + surface + .projections + .iter() + .map(|projection| projection.id.as_str()) })) .collect::>(); for command in &module.commands { @@ -686,7 +861,10 @@ fn validate_projection( validate_sorted_unique("projection facts", &projection.facts)?; validate_sorted_unique("projection models", &projection.models)?; validate_sorted_unique("projection dependencies", &projection.dependencies)?; - validate_sorted_unique("modeled projection program IDs", &projection.modeled_programs)?; + validate_sorted_unique( + "modeled projection program IDs", + &projection.modeled_programs, + )?; for fact in &projection.facts { validate_portable_text("projection fact", fact)?; } @@ -793,7 +971,10 @@ fn validate_model( ))); } validate_sorted_unique("model primary key", &model.primary_key)?; - let field_names = field_names.iter().map(String::as_str).collect::>(); + let field_names = field_names + .iter() + .map(String::as_str) + .collect::>(); for field in &model.fields { validate_portable_text("model field", &field.name)?; validate_portable_text("model field scalar", &field.scalar)?; @@ -913,7 +1094,14 @@ fn validate_surface( .map(|projection| projection.id.clone()) .collect::>(), )?; - validate_json_contract("surface canonical contract", &surface.contract)?; + // This is the structured catalog of an entire Surface, not an opaque + // extension value. Its contents are checked below against the typed spec; + // the complete serialized application still owns the aggregate byte budget. + validate_json_contract_with_budget( + "surface canonical contract", + &surface.contract, + MAX_APPLICATION_MANIFEST_BYTES, + )?; validate_surface_selection(surface)?; validate_portable_text("surface dialect", &surface.dialect)?; if surface.max_limit == 0 || surface.default_limit > surface.max_limit { @@ -988,7 +1176,10 @@ fn validate_surface( validate_json_contract("surface command defaults", &command.defaults)?; validate_json_contract("surface command effects", &command.effects)?; validate_json_contract("surface command confirmations", &command.confirmations)?; - validate_json_contract("surface command projection contract", &command.projection_contract)?; + validate_json_contract( + "surface command projection contract", + &command.projection_contract, + )?; validate_json_contract("surface command applies", &command.applies)?; if let Some(model) = &command.projected_model { require_reference("model", model, model_scope)?; @@ -1030,7 +1221,11 @@ fn validate_surface_selection(surface: &SurfaceSpec) -> ApplicationResult<()> { ))); } } - value if value.strip_prefix("application:").is_some_and(|name| !name.is_empty()) => { + value + if value + .strip_prefix("application:") + .is_some_and(|name| !name.is_empty()) => + { let name = value.strip_prefix("application:").expect("matched above"); LogicalId::try_new("surface application", name.to_owned())?; if surface.eligible_roles.is_empty() { @@ -1045,11 +1240,12 @@ fn validate_surface_selection(surface: &SurfaceSpec) -> ApplicationResult<()> { surface.id ))); } - if surface - .schema_roles - .iter() - .any(|role| !surface.eligible_roles.iter().any(|eligible| eligible == role)) - { + if surface.schema_roles.iter().any(|role| { + !surface + .eligible_roles + .iter() + .any(|eligible| eligible == role) + }) { return Err(ApplicationError::InvalidSpec(format!( "application surface `{}` schema roles must be a subset of eligible roles", surface.id @@ -1079,7 +1275,9 @@ fn validate_roles(kind: &'static str, roles: &[String]) -> ApplicationResult<()> Ok(()) } -fn validate_surface_argument(argument: &super::module::SurfaceArgumentSpec) -> ApplicationResult<()> { +fn validate_surface_argument( + argument: &super::module::SurfaceArgumentSpec, +) -> ApplicationResult<()> { validate_portable_text("surface argument", &argument.name)?; validate_portable_text("surface argument kind", &argument.kind)?; validate_portable_text("surface argument type", &argument.type_name)?; @@ -1149,11 +1347,15 @@ fn validate_relationship_keys(value: &serde_json::Value) -> ApplicationResult<() let local = fields .get("local") .and_then(serde_json::Value::as_array) - .ok_or_else(|| ApplicationError::InvalidSpec("relationship keys need local columns".into()))?; + .ok_or_else(|| { + ApplicationError::InvalidSpec("relationship keys need local columns".into()) + })?; let remote = fields .get("remote") .and_then(serde_json::Value::as_array) - .ok_or_else(|| ApplicationError::InvalidSpec("relationship keys need remote columns".into()))?; + .ok_or_else(|| { + ApplicationError::InvalidSpec("relationship keys need remote columns".into()) + })?; if local.is_empty() || local.len() != remote.len() { return Err(ApplicationError::InvalidSpec( "relationship key columns must be non-empty and paired".into(), @@ -1278,9 +1480,7 @@ fn validate_command_type_spec_at_depth( fn validate_surface_contract(surface: &SurfaceSpec) -> ApplicationResult<()> { let expected = surface_contract_from_spec(surface)?; if canonical_json(&surface.contract) != expected { - return Err(ApplicationError::NonCanonical( - "surface contract material", - )); + return Err(ApplicationError::NonCanonical("surface contract material")); } Ok(()) } @@ -1297,8 +1497,7 @@ fn validate_manifest_ownership(manifest: &ApplicationManifest) -> ApplicationRes return Err(ApplicationError::Collision { kind: "command", identity: manifest.name.clone(), - reason: "application command inventory does not equal explicit module ownership" - .into(), + reason: "application command inventory does not equal explicit module ownership".into(), }); } @@ -1312,8 +1511,7 @@ fn validate_manifest_ownership(manifest: &ApplicationManifest) -> ApplicationRes return Err(ApplicationError::Collision { kind: "event", identity: manifest.name.clone(), - reason: "application event inventory does not equal explicit module ownership" - .into(), + reason: "application event inventory does not equal explicit module ownership".into(), }); } @@ -1360,7 +1558,10 @@ fn validate_manifest_ownership(manifest: &ApplicationManifest) -> ApplicationRes for module in &manifest.modules { for owned_surface in &module.surfaces { - let Some(surface) = manifest.surfaces.iter().find(|surface| surface.id == owned_surface.id) + let Some(surface) = manifest + .surfaces + .iter() + .find(|surface| surface.id == owned_surface.id) else { return Err(ApplicationError::Missing { kind: "surface", @@ -1419,8 +1620,9 @@ fn validate_manifest_ownership(manifest: &ApplicationManifest) -> ApplicationRes return Err(ApplicationError::Collision { kind: "projection", identity: exposed.id.clone(), - reason: "surface projection differs from the application projection declaration" - .into(), + reason: + "surface projection differs from the application projection declaration" + .into(), }); } } @@ -1503,17 +1705,15 @@ fn surface_command_closure( .iter() .filter(|command| match surface.selection.as_str() { "catalog" => true, - "role" => surface - .eligible_roles - .first() - .is_some_and(|role| { - command.roles.is_empty() || command.roles.iter().any(|allowed| allowed == role) - }), + "role" => surface.eligible_roles.first().is_some_and(|role| { + command.roles.is_empty() || command.roles.iter().any(|allowed| allowed == role) + }), value if value.starts_with("application:") => { command.roles.is_empty() - || surface.schema_roles.iter().all(|role| { - command.roles.iter().any(|allowed| allowed == role) - }) + || surface + .schema_roles + .iter() + .all(|role| command.roles.iter().any(|allowed| allowed == role)) } _ => false, }) @@ -1686,7 +1886,10 @@ fn validate_fingerprint( } let mut value = serde_json::to_value(value)?; if let serde_json::Value::Object(fields) = &mut value { - fields.insert("fingerprint".into(), serde_json::Value::String(String::new())); + fields.insert( + "fingerprint".into(), + serde_json::Value::String(String::new()), + ); } let expected = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); if expected != fingerprint { @@ -1774,17 +1977,21 @@ fn validate_sha256_text(kind: &'static str, value: &str) -> ApplicationResult<() } fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> ApplicationResult<()> { + validate_json_contract_with_budget(kind, value, MAX_MANIFEST_JSON_BYTES) +} + +fn validate_json_contract_with_budget( + kind: &'static str, + value: &serde_json::Value, + max_bytes: usize, +) -> ApplicationResult<()> { let bytes = serde_json::to_vec(value)?; - if bytes.len() > MAX_MANIFEST_JSON_BYTES { + if bytes.len() > max_bytes { return Err(ApplicationError::InvalidSpec(format!( - "{kind} exceeds {MAX_MANIFEST_JSON_BYTES} JSON bytes" + "{kind} exceeds {max_bytes} JSON bytes" ))); } - fn walk( - kind: &'static str, - value: &serde_json::Value, - depth: usize, - ) -> ApplicationResult<()> { + fn walk(kind: &'static str, value: &serde_json::Value, depth: usize) -> ApplicationResult<()> { if depth > MAX_MANIFEST_JSON_DEPTH { return Err(ApplicationError::InvalidSpec(format!( "{kind} exceeds JSON depth {MAX_MANIFEST_JSON_DEPTH}" @@ -1815,7 +2022,8 @@ fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> Appl walk(kind, value, depth + 1)?; } } - serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + } } Ok(()) } @@ -1832,17 +2040,23 @@ fn dedup_events( mut values: Vec, ) -> ApplicationResult> { values.sort_by(|left, right| { - (left.name.as_str(), left.version, left.body_fingerprint.as_str()).cmp(&( - right.name.as_str(), - right.version, - right.body_fingerprint.as_str(), - )) + ( + left.name.as_str(), + left.version, + left.body_fingerprint.as_str(), + ) + .cmp(&( + right.name.as_str(), + right.version, + right.body_fingerprint.as_str(), + )) }); let mut out = Vec::new(); for value in values { - if let Some(existing) = out.iter().find(|existing: &&super::command::EventSpec| { - existing.name == value.name - }) { + if let Some(existing) = out + .iter() + .find(|existing: &&super::command::EventSpec| existing.name == value.name) + { if *existing != value { return Err(ApplicationError::Collision { kind: "event", @@ -1867,7 +2081,10 @@ fn dedup_models(mut values: Vec) -> ApplicationResult> values.sort_by(|left, right| left.id.cmp(&right.id)); let mut out = Vec::new(); for value in values { - if let Some(existing) = out.iter().find(|existing: &&ModelSpec| existing.id == value.id) { + if let Some(existing) = out + .iter() + .find(|existing: &&ModelSpec| existing.id == value.id) + { if *existing != value { return Err(ApplicationError::Collision { kind: "model", @@ -1921,3 +2138,88 @@ fn validate_sorted_unique(kind: &'static str, identities: &[String]) -> Applicat } Ok(()) } + +#[cfg(test)] +mod size_limit_tests { + use super::*; + + #[test] + fn structured_surface_budget_preserves_nested_validation() { + let validate = |value: &serde_json::Value| { + validate_json_contract_with_budget( + "surface canonical contract", + value, + MAX_APPLICATION_MANIFEST_BYTES, + ) + }; + assert!(validate(&serde_json::json!({"bad": "nul\u{0}value"})).is_err()); + assert!( + validate(&serde_json::json!({"bad": "x".repeat(MAX_MANIFEST_STRING_BYTES + 1)})) + .is_err() + ); + assert!(validate(&serde_json::json!(vec![ + 0; + MAX_MANIFEST_COLLECTION_ITEMS + 1 + ])) + .is_err()); + let mut deep = serde_json::Value::Null; + for _ in 0..=MAX_MANIFEST_JSON_DEPTH { + deep = serde_json::json!([deep]); + } + assert!(validate(&deep).is_err()); + let oversized = serde_json::json!(vec!["x".repeat(MAX_MANIFEST_STRING_BYTES); 1024]); + assert!(validate(&oversized) + .unwrap_err() + .to_string() + .contains("exceeds 4194304 JSON bytes")); + } + + #[test] + fn json_contract_can_exceed_the_old_256_kib_limit() { + let chunk = "x".repeat(MAX_MANIFEST_STRING_BYTES); + let within_budget = serde_json::json!(vec![chunk.clone(); 96]); + assert!(serde_json::to_vec(&within_budget).unwrap().len() > 256 * 1024); + validate_json_contract("surface canonical contract", &within_budget).unwrap(); + + let over_budget = serde_json::json!(vec![chunk; 257]); + assert!(serde_json::to_vec(&over_budget).unwrap().len() > MAX_MANIFEST_JSON_BYTES); + assert!(validate_json_contract("surface canonical contract", &over_budget).is_err()); + } + + #[test] + fn manifest_accepts_multiple_bounded_contracts_beyond_one_mib() { + let value = serde_json::json!(vec!["x".repeat(MAX_MANIFEST_STRING_BYTES); 200]); + assert!(serde_json::to_vec(&value).unwrap().len() < MAX_MANIFEST_JSON_BYTES); + let mut manifest = ApplicationManifest::new("large-app"); + for index in 0..2 { + manifest.extensions.push( + ApplicationExtension::try_new(format!("large.contract.{index}"), 1, value.clone()) + .unwrap(), + ); + } + + let bytes = manifest.canonical_bytes().unwrap(); + assert!(bytes.len() > 1024 * 1024); + assert!(bytes.len() < MAX_APPLICATION_MANIFEST_BYTES); + ApplicationManifest::from_canonical_bytes(&bytes).unwrap(); + } + + #[test] + fn manifest_rejects_multiple_contracts_beyond_four_mib() { + let value = serde_json::json!(vec!["x".repeat(MAX_MANIFEST_STRING_BYTES); 230]); + assert!(serde_json::to_vec(&value).unwrap().len() < MAX_MANIFEST_JSON_BYTES); + let mut manifest = ApplicationManifest::new("oversized-app"); + for index in 0..5 { + manifest.extensions.push( + ApplicationExtension::try_new(format!("large.contract.{index}"), 1, value.clone()) + .unwrap(), + ); + } + + let error = manifest.canonical_bytes().unwrap_err().to_string(); + assert!(error.contains("application manifest exceeds 4194304 bytes")); + let oversized_bytes = serde_json::to_vec(&manifest).unwrap(); + assert!(oversized_bytes.len() > MAX_APPLICATION_MANIFEST_BYTES); + assert!(ApplicationManifest::from_canonical_bytes(&oversized_bytes).is_err()); + } +} diff --git a/src/application/module.rs b/src/application/module.rs index 18fb7f84f..e7122991f 100644 --- a/src/application/module.rs +++ b/src/application/module.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use super::command::{CommandDefinition, CommandMount, CommandSpec, CommandTypeSpec, EventSpec}; use super::error::{ApplicationError, ApplicationResult}; use super::identity::{canonical_json, sha256_fingerprint, LogicalId}; -use crate::graphql::command_contract::TypedCommandContract; +use crate::command::TypedCommandContract; use crate::graphql::surface::{ RootKind, Surface, SurfaceArgument, SurfaceCommand, SurfaceCommandShape, SurfaceProjectionOwner, SurfaceRelationshipKeys, SurfaceSelection, SurfaceTypeDef, @@ -84,10 +84,7 @@ impl ModelSpec { let table = LogicalId::try_new("model table", table)?.into_string(); let mut fields = fields.into_iter().collect::>(); fields.sort_by(|left, right| left.name.cmp(&right.name)); - let mut primary_key = primary_key - .into_iter() - .map(Into::into) - .collect::>(); + let mut primary_key = primary_key.into_iter().map(Into::into).collect::>(); primary_key.sort(); primary_key.dedup(); if fields.is_empty() { @@ -127,7 +124,6 @@ impl ModelSpec { self.fingerprint = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); Ok(()) } - } /// Portable projection-owner identity aggregated into a module. @@ -258,11 +254,13 @@ impl ProjectionSpec { pub fn canonical_bytes(&self) -> ApplicationResult> { let mut value = serde_json::to_value(self)?; if let serde_json::Value::Object(fields) = &mut value { - fields.insert("fingerprint".into(), serde_json::Value::String(String::new())); + fields.insert( + "fingerprint".into(), + serde_json::Value::String(String::new()), + ); } serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) } - } /// A root field identity retained by a surface contract. @@ -289,7 +287,7 @@ pub struct SurfaceCommandSpec { pub roles: Vec, pub input: Option, pub output: Option, - pub consistency: crate::graphql::CommandConsistency, + pub consistency: crate::command::CommandConsistency, pub defaults: serde_json::Value, pub effects: serde_json::Value, pub confirmations: serde_json::Value, @@ -423,7 +421,8 @@ impl SurfaceSpec { spec.fields .sort_by(|left, right| left.name.cmp(&right.name)); spec.primary_key.sort(); - spec.relationships.sort_by(|left, right| left.name.cmp(&right.name)); + spec.relationships + .sort_by(|left, right| left.name.cmp(&right.name)); spec.refresh_fingerprint()?; Ok(spec) }) @@ -434,7 +433,12 @@ impl SurfaceSpec { .query_fields .iter() .map(|root| ("query", root)) - .chain(surface.subscription_fields.iter().map(|root| ("subscription", root))) + .chain( + surface + .subscription_fields + .iter() + .map(|root| ("subscription", root)), + ) .map(|(operation, root)| SurfaceRootSpec { operation: operation.into(), name: root.name.clone(), @@ -512,7 +516,10 @@ impl SurfaceSpec { pub fn canonical_bytes(&self) -> ApplicationResult> { let mut value = serde_json::to_value(self)?; if let serde_json::Value::Object(fields) = &mut value { - fields.insert("fingerprint".into(), serde_json::Value::String(String::new())); + fields.insert( + "fingerprint".into(), + serde_json::Value::String(String::new()), + ); } serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) } @@ -534,7 +541,7 @@ fn surface_command_spec(command: &SurfaceCommand) -> ApplicationResult) -> ApplicationResult { + Self::extension(id) + } + pub fn kind_label(&self) -> &'static str { match self { Self::Command { .. } => "command", diff --git a/src/application/plan.rs b/src/application/plan.rs index 07f4af6cd..e11908357 100644 --- a/src/application/plan.rs +++ b/src/application/plan.rs @@ -14,11 +14,9 @@ use super::capability::{ use super::error::{ApplicationError, ApplicationResult}; use super::identity::{canonical_json, sha256_fingerprint, LogicalId}; use super::manifest::{ApplicationManifest, APPLICATION_MANIFEST_SCHEMA_VERSION}; -use super::mount::{ - validate_mounts_against_manifest, MountSelector, ProcessPreset, -}; +use super::mount::{validate_mounts_against_manifest, MountSelector, ProcessPreset}; use super::topology::{derive_topology, TopologyIntent}; -use crate::graphql::command_contract::CommandConsistency; +use crate::command::CommandConsistency; /// Wire schema version for deployment plans. pub const DEPLOYMENT_PLAN_SCHEMA_VERSION: u32 = 1; @@ -207,8 +205,10 @@ pub fn compile_deployment_plan( .insert(process.id.clone()); } MountSelector::Projector { id } => { - if let Some(projection) = - manifest.projections.iter().find(|projection| projection.id == *id) + if let Some(projection) = manifest + .projections + .iter() + .find(|projection| projection.id == *id) { if projection.direct { direct_projection_hosts @@ -425,7 +425,9 @@ impl DeploymentPlan { } let expected = expected_fingerprints(self)?; if self.fingerprints != expected { - return Err(ApplicationError::NonCanonical("deployment plan fingerprints")); + return Err(ApplicationError::NonCanonical( + "deployment plan fingerprints", + )); } // Silence unused import when APPLICATION_MANIFEST_SCHEMA_VERSION is only // for documentation linkage in validate paths. diff --git a/src/application/registration.rs b/src/application/registration.rs index afffdeebc..1fe95d300 100644 --- a/src/application/registration.rs +++ b/src/application/registration.rs @@ -4,7 +4,9 @@ use super::error::ApplicationResult; use super::manifest::{ApplicationExtension, ApplicationManifest, ManifestProvenance}; use super::module::{Module, SurfaceSpec}; use crate::graphql::surface::Surface; -use crate::graphql::{ClientManifestError, DistributedClientManifest, DistributedClientSurfaceExport}; +use crate::graphql::{ + ClientManifestError, DistributedClientManifest, DistributedClientSurfaceExport, +}; /// Explicit application registration. No linker inventory or source scan is /// consulted; only the values supplied to this constructor participate. @@ -62,6 +64,21 @@ impl Application { }) } + /// Declare a gateway's logical capabilities beside the typed Service surface. + /// Origins, routes, credentials and adapter resources remain host bindings. + #[cfg(feature = "gateway")] + pub fn with_gateway( + mut self, + id: impl Into, + gateway: &crate::gateway::Gateway, + ) -> ApplicationResult { + self.manifest + .extensions + .push(gateway.application_extension(id)?); + self.manifest.refresh_fingerprints()?; + Ok(self) + } + pub fn name(&self) -> &str { &self.name } @@ -217,7 +234,9 @@ impl ContractCompiler { "ContractCompiler already has a different authoritative Surface contract" )); } - return Err("ContractCompiler accepts exactly one authoritative Surface contract".into()); + return Err( + "ContractCompiler accepts exactly one authoritative Surface contract".into(), + ); } self.surface = Some(surface); self.surface_spec = Some(spec); diff --git a/src/application/runtime.rs b/src/application/runtime.rs index 899ebede2..14fe0b150 100644 --- a/src/application/runtime.rs +++ b/src/application/runtime.rs @@ -2,7 +2,7 @@ use super::error::{ApplicationError, ApplicationResult}; use super::module::Module; -use crate::graphql::CommandConsistency; +use crate::command::CommandConsistency; use std::collections::BTreeMap; /// Persistence dialect selected from a database URL. @@ -21,6 +21,8 @@ pub struct Runtime { mounts: Vec, graphql: bool, dispatch_routes: BTreeMap, + #[cfg(feature = "gateway")] + gateways: BTreeMap, } impl Runtime { @@ -52,6 +54,8 @@ impl Runtime { mounts: Vec::new(), graphql: false, dispatch_routes: BTreeMap::new(), + #[cfg(feature = "gateway")] + gateways: BTreeMap::new(), }) } @@ -69,6 +73,55 @@ impl Runtime { self } + /// Select one declared gateway. This stores portable host configuration only; + /// the caller instantiates its native/Worker resources after selecting it. + #[cfg(feature = "gateway")] + pub fn mount_gateway( + mut self, + application: &super::Application, + selector: super::MountSelector, + gateway: crate::gateway::Gateway, + ) -> ApplicationResult { + let super::MountSelector::Extension { id } = &selector else { + return Err(ApplicationError::InvalidSpec( + "gateway requires an extension mount".into(), + )); + }; + let expected = gateway.application_extension(id.clone())?; + if !application.manifest().extensions.contains(&expected) { + return Err(ApplicationError::InvalidSpec( + "gateway capabilities do not match the application declaration".into(), + )); + } + if self.gateways.insert(selector, gateway).is_some() { + return Err(ApplicationError::InvalidSpec( + "duplicate gateway mount".into(), + )); + } + Ok(self) + } + + /// Only explicitly selected gateways can be bound to a network adapter. + #[cfg(feature = "gateway")] + pub fn gateway(&self, selector: &super::MountSelector) -> Option<&crate::gateway::Gateway> { + self.gateways.get(selector) + } + + /// Invoke an adapter/resource factory only when this gateway was selected. + #[cfg(feature = "gateway")] + pub fn bind_gateway( + &self, + selector: &super::MountSelector, + factory: impl FnOnce(&crate::gateway::Gateway) -> Result, + ) -> Result, E> { + self.gateway(selector).map(factory).transpose() + } + + #[cfg(feature = "gateway")] + pub fn starts_gateway(&self) -> bool { + !self.gateways.is_empty() + } + pub fn graphql(mut self) -> Self { self.graphql = true; self diff --git a/src/application/runtime_host.rs b/src/application/runtime_host.rs index e0ad550b1..7a84a3b78 100644 --- a/src/application/runtime_host.rs +++ b/src/application/runtime_host.rs @@ -117,7 +117,7 @@ mod tests { compile_deployment_plan, Application, CommandDefinition, CommandSpec, CommandTypeSpec, ModelFieldSpec, ModelSpec, Module, ProcessIntent, ProcessPreset, ProjectionSpec, }; - use crate::graphql::CommandConsistency; + use crate::command::CommandConsistency; use std::sync::Arc; fn manifest() -> crate::application::ApplicationManifest { @@ -183,7 +183,7 @@ mod tests { use crate::command_dispatch::{CommandDispatchError, CommandDispatcher}; use crate::microsvc::{CommandRequest, CommandResponse}; use async_trait::async_trait; - + struct Stub; #[async_trait] impl CommandDispatcher for Stub { diff --git a/src/bus/in_memory_bus.rs b/src/bus/in_memory_bus.rs index 2a76f974e..db517d1aa 100644 --- a/src/bus/in_memory_bus.rs +++ b/src/bus/in_memory_bus.rs @@ -82,6 +82,17 @@ impl InMemoryBus { Ok(()) } + #[cfg(test)] + pub(crate) fn published_ids(&self) -> Vec { + self.topics + .lock() + .expect("test topic lock") + .values() + .flat_map(|messages| messages.iter()) + .filter_map(|message| message.id().map(str::to_owned)) + .collect() + } + #[cfg(test)] pub(crate) fn ordered_topic_evidence(&self, name: &str, position: u64) -> OrderedDelivery { OrderedDelivery::new( diff --git a/src/bus/message.rs b/src/bus/message.rs index 45bc33bfa..1edf4cdae 100644 --- a/src/bus/message.rs +++ b/src/bus/message.rs @@ -218,9 +218,9 @@ pub(crate) fn message_from_wire( let mut kind = MessageKind::Event; let mut metadata = Vec::new(); for (key, value) in headers { - if id_key == Some(key.as_str()) { + if id_key.is_some_and(|id_key| key.eq_ignore_ascii_case(id_key)) { id = Some(value); - } else if key == kind_key { + } else if key.eq_ignore_ascii_case(kind_key) { kind = MessageKind::from_str_lossy(&value); } else { metadata.push((key, value)); @@ -292,4 +292,23 @@ mod tests { 1 ); } + + #[cfg(any(feature = "nats", feature = "kafka", feature = "rabbitmq"))] + #[test] + fn wire_reserved_headers_are_case_insensitive() { + let message = message_from_wire( + "checkout.started".into(), + Vec::new(), + Some("Nats-Msg-Id"), + "X-Sourced-Kind", + [ + ("nats-msg-id".into(), "event-1".into()), + ("x-sourced-kind".into(), "command".into()), + ], + ); + + assert_eq!(message.id(), Some("event-1")); + assert_eq!(message.kind, MessageKind::Command); + assert!(message.metadata.is_empty()); + } } diff --git a/src/bus/nats.rs b/src/bus/nats.rs index 554c2d349..f18b72a30 100644 --- a/src/bus/nats.rs +++ b/src/bus/nats.rs @@ -28,6 +28,8 @@ use super::{retryable, MessagePublisher, TransportError}; const MESSAGE_ID_HEADER: &str = "Nats-Msg-Id"; /// Header carrying the canonical message kind. const MESSAGE_KIND_HEADER: &str = "X-Sourced-Kind"; +/// Header carrying the canonical payload media type. +const CONTENT_TYPE_HEADER: &str = "Content-Type"; /// Publishes canonical messages to a NATS JetStream subject. /// @@ -75,13 +77,20 @@ impl MessagePublisher for NatsPublisher { async fn publish(&self, mut message: Message) -> Result<(), TransportError> { let subject = self.subject(&message); let mut headers = async_nats::HeaderMap::new(); + for (key, value) in &message.metadata { + if [MESSAGE_ID_HEADER, MESSAGE_KIND_HEADER, CONTENT_TYPE_HEADER] + .iter() + .any(|reserved| key.eq_ignore_ascii_case(reserved)) + { + continue; + } + headers.insert(key.as_str(), value.as_str()); + } if let Some(id) = message.id() { headers.insert(MESSAGE_ID_HEADER, id); } headers.insert(MESSAGE_KIND_HEADER, message.kind.as_str()); - for (key, value) in &message.metadata { - headers.insert(key.as_str(), value.as_str()); - } + headers.insert(CONTENT_TYPE_HEADER, message.content_type.as_str()); // `message` is owned and dropped here, so move its payload out instead of // cloning. `Bytes::from(Vec)` takes ownership of the buffer (no copy). @@ -243,13 +252,14 @@ impl NatsReceived { .map(|value| (key.to_string(), value.to_string())) }) .collect(); - let message = message_from_wire( + let mut message = message_from_wire( name, payload, Some(MESSAGE_ID_HEADER), MESSAGE_KIND_HEADER, headers, ); + take_content_type(&mut message); let ordered = jetstream_ordered(&raw); Self { raw, @@ -274,6 +284,25 @@ impl NatsReceived { } } +/// Select the first inbound content type and remove every reserved spelling +/// from user-visible metadata. +fn take_content_type(message: &mut Message) { + let mut content_type = None; + message.metadata.retain(|(key, value)| { + if key.eq_ignore_ascii_case(CONTENT_TYPE_HEADER) { + if content_type.is_none() { + content_type = Some(value.clone()); + } + false + } else { + true + } + }); + if let Some(content_type) = content_type { + message.content_type = content_type; + } +} + fn jetstream_ordered(raw: &jetstream::Message) -> Option { let info = raw.info().ok()?; let source = ProjectionSource::new("nats.jetstream", info.stream.as_bytes()).ok()?; @@ -309,3 +338,25 @@ impl ReceivedMessage for NatsReceived { self.settle(AckKind::Term).await } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::bus::MessageKind; + + #[test] + fn content_type_selection_removes_every_case_variant() { + let mut message = Message::new("example.recorded", MessageKind::Event, Vec::new()) + .with_metadata("Content-Type", "application/vnd.example+binary") + .with_metadata("x-correlation-id", "corr-1") + .with_metadata("content-type", "application/json"); + + take_content_type(&mut message); + + assert_eq!(message.content_type, "application/vnd.example+binary"); + assert_eq!( + message.metadata, + vec![("x-correlation-id".into(), "corr-1".into())] + ); + } +} diff --git a/src/bus/nats_bus.rs b/src/bus/nats_bus.rs index 020ab9811..6f0a9feb3 100644 --- a/src/bus/nats_bus.rs +++ b/src/bus/nats_bus.rs @@ -15,6 +15,10 @@ //! //! The `group` is the logical consumer identity (the service/deployment name). //! Same group ⇒ competing; different groups ⇒ independent fan-out copies. +//! Startup reconciles that durable's subject filters without resetting its +//! acknowledgement cursor or broker tuning. Replicas sharing a group must use +//! the same subscription inventory. Adding a filter does not replay messages +//! previously skipped by that consumer; historical catch-up is explicit. //! //! Requires the `nats` feature. Integration-tested in `tests/nats_transport`. @@ -218,6 +222,98 @@ impl NatsBus { .map_err(|err| retryable("nats get_or_create_stream", err)) } + /// Read a bounded, stable archive of retained canonical domain events. + /// + /// Does not create streams/consumers, acknowledge deliveries, or publish. + /// Rejects truncated, gapped, changing, or oversized streams. Noncanonical + /// integration events and commands are excluded. A complete broker archive + /// is NOT proof that every historical aggregate publication reached it: + /// stop producers and drain outboxes before using this for maintenance. + pub async fn retained_domain_events( + &self, + ) -> Result, TransportError> { + let namespace = self.validated_namespace()?; + let mut stream = self + .jetstream + .get_stream(Self::stream_name(&namespace)) + .await + .map_err(|e| retryable("open domain-event archive", e))?; + let before = stream + .info() + .await + .map_err(|e| retryable("read archive boundary", e))? + .clone(); + let state = &before.state; + if state.last_sequence > 100_000 + || state.bytes > 64 * 1024 * 1024 + || state.messages != state.last_sequence + || (state.messages > 0 && state.first_sequence != 1) + { + return Err(TransportError::permanent( + "domain-event archive is truncated, gapped, or exceeds 100000 messages / 64 MiB", + )); + } + let prefix = format!("{namespace}.evt."); + let mut events = Vec::new(); + let mut bytes = 0usize; + for sequence in 1..=state.last_sequence { + let message = stream + .get_raw_message(sequence) + .await + .map_err(|e| retryable("read retained domain-event archive message", e))?; + bytes = bytes + .checked_add(message.payload.len()) + .ok_or_else(|| TransportError::permanent("domain-event archive size overflow"))?; + if bytes > 64 * 1024 * 1024 { + return Err(TransportError::permanent( + "domain-event archive exceeds 64 MiB", + )); + } + if message.sequence != sequence { + return Err(TransportError::permanent( + "domain-event archive returned a different sequence", + )); + } + let Some(name) = message.subject.as_str().strip_prefix(&prefix) else { + continue; + }; + if message + .headers + .get("x-sourced-payload-codec") + .map(|v| v.as_str()) + != Some("distributed.domain-event-occurrence+json") + { + continue; + } + let event = crate::DomainEventOccurrence::from_canonical_bytes(&message.payload) + .map_err(|e| { + TransportError::permanent(format!("invalid archived occurrence: {e}")) + })?; + if event.descriptor().name != name + || message.headers.get("Nats-Msg-Id").map(|v| v.as_str()) != Some(event.id()) + { + return Err(TransportError::permanent( + "archived occurrence differs from its transport identity", + )); + } + events.push(event); + } + let after = stream + .info() + .await + .map_err(|e| retryable("verify archive boundary", e))?; + if before.created != after.created + || before.state.last_sequence != after.state.last_sequence + || before.state.messages != after.state.messages + || before.state.first_sequence != after.state.first_sequence + { + return Err(TransportError::permanent( + "domain-event archive changed while reading; quiesce producers and retry", + )); + } + Ok(events) + } + /// Build a durable pull source over the bus stream, filtered to `subjects`, /// stripping `strip_prefix` so the dispatched message name is the bare name. async fn source( @@ -227,17 +323,41 @@ impl NatsBus { strip_prefix: String, ) -> Result { let stream = self.ensure_stream().await?; - let consumer = stream + let mut consumer = stream .get_or_create_consumer( durable, PullConfig { durable_name: Some(durable.to_string()), - filter_subjects: subjects, + filter_subjects: subjects.clone(), ..Default::default() }, ) .await .map_err(|err| retryable("nats get_or_create_consumer", err))?; + // Opening a durable does not reconcile its existing configuration. An + // added route otherwise never receives events after an application + // upgrade. Update filters in place, preserving progress and tuning. + let current = &consumer.cached_info().config; + let mut actual = current.filter_subjects.clone(); + if !current.filter_subject.is_empty() { + actual.push(current.filter_subject.clone()); + } + actual.sort(); + actual.dedup(); + let mut desired = subjects; + desired.sort(); + desired.dedup(); + if actual != desired { + use async_nats::jetstream::consumer::FromConsumer; + let mut config = PullConfig::try_from_consumer_config(current.clone()) + .map_err(|err| retryable("nats read durable config", err))?; + config.filter_subject.clear(); + config.filter_subjects = desired; + consumer = stream + .update_consumer(config) + .await + .map_err(|err| retryable("nats update consumer filters", err))?; + } Ok(NatsJetStreamSource::new(consumer) .with_fetch_timeout(self.fetch_timeout) .with_strip_prefix(strip_prefix) @@ -278,6 +398,208 @@ impl NatsBus { } } +#[cfg(test)] +mod archive_tests { + use super::*; + + #[tokio::test] + #[ignore = "requires DISTRIBUTED_ARCHIVE_TEST_NATS_URL with JetStream"] + async fn durable_filter_updates_preserve_progress_and_broker_tuning() { + use futures::StreamExt; + let url = std::env::var("DISTRIBUTED_ARCHIVE_TEST_NATS_URL").expect("test JetStream URL"); + let namespace = format!("filter-test-{}", uuid::Uuid::now_v7().simple()); + let bus = NatsBus::connect(&url).namespace(&namespace).await.unwrap(); + let stream = bus.ensure_stream().await.unwrap(); + let first = format!("{namespace}.evt.first"); + let added = format!("{namespace}.evt.added"); + let mut consumer = stream + .create_consumer(PullConfig { + durable_name: Some("filter-proof".into()), + filter_subjects: vec![first.clone()], + ack_wait: Duration::from_secs(17), + max_ack_pending: 29, + ..Default::default() + }) + .await + .unwrap(); + let created = consumer.cached_info().created; + bus.jetstream + .publish(first.clone(), "first".into()) + .await + .unwrap() + .await + .unwrap(); + let mut messages = consumer + .fetch() + .max_messages(1) + .expires(Duration::from_secs(2)) + .messages() + .await + .unwrap(); + let message = messages.next().await.unwrap().unwrap(); + message.double_ack().await.unwrap(); + drop(messages); + let acknowledged = consumer.info().await.unwrap().ack_floor.stream_sequence; + assert_eq!(acknowledged, 1); + bus.jetstream + .publish(added.clone(), "retained-new".into()) + .await + .unwrap() + .await + .unwrap(); + // Simulate the old application polling past an unmatched input. + let mut unmatched = consumer + .fetch() + .max_messages(1) + .expires(Duration::from_millis(100)) + .messages() + .await + .unwrap(); + assert!(unmatched.next().await.is_none()); + drop(unmatched); + drop( + bus.source( + "filter-proof", + vec![added.clone(), first.clone()], + format!("{namespace}.evt."), + ) + .await + .unwrap(), + ); + let info = consumer.info().await.unwrap(); + assert_eq!( + info.created, created, + "must update, not replace, the consumer" + ); + assert_eq!(info.ack_floor.stream_sequence, acknowledged); + assert_eq!(info.config.ack_wait, Duration::from_secs(17)); + assert_eq!(info.config.max_ack_pending, 29); + assert_eq!(info.config.filter_subjects.len(), 2); + bus.jetstream + .publish(added.clone(), "future-new".into()) + .await + .unwrap() + .await + .unwrap(); + let mut messages = consumer + .fetch() + .max_messages(2) + .expires(Duration::from_secs(2)) + .messages() + .await + .unwrap(); + let mut delivered = Vec::new(); + while let Some(message) = messages.next().await { + let message = message.unwrap(); + delivered.push(String::from_utf8(message.payload.to_vec()).unwrap()); + message.double_ack().await.unwrap(); + } + assert!(delivered.contains(&"future-new".into())); + assert!( + !delivered.contains(&"first".into()), + "acknowledged effects must not replay" + ); + // NATS does not promise historical catch-up for an added filter. Keep + // any observed retained delivery visible in the test output. + eprintln!("filter update delivered: {delivered:?}"); + drop( + bus.source( + "filter-proof", + vec![added.clone()], + format!("{namespace}.evt."), + ) + .await + .unwrap(), + ); + drop( + bus.source( + "filter-proof", + vec![added.clone()], + format!("{namespace}.evt."), + ) + .await + .unwrap(), + ); + assert_eq!( + consumer.info().await.unwrap().config.filter_subjects, + vec![added] + ); + assert_eq!(consumer.cached_info().created, created); + // The stream was created only by this test under a fresh UUID namespace. + bus.jetstream + .delete_stream(NatsBus::stream_name(&namespace)) + .await + .unwrap(); + } + + #[derive(serde::Serialize, serde::Deserialize, crate::DomainState)] + #[domain_state(version = 1)] + struct ArchiveState { + title: String, + } + + #[tokio::test] + async fn retained_archive_is_non_consuming_and_rejects_missing_history() { + let Ok(url) = std::env::var("DISTRIBUTED_ARCHIVE_TEST_NATS_URL") else { + return; + }; + let namespace = format!("archive-test-{}", uuid::Uuid::now_v7().simple()); + let bus = NatsBus::connect(&url).namespace(&namespace).await.unwrap(); + let mut stream = bus.ensure_stream().await.unwrap(); + for sequence in 1..=3 { + let event = crate::DomainEventOccurrence::capture( + crate::DomainEventDescriptor::state::("archive.changed", 1), + crate::DomainEventEnvelope { + aggregate_type: "archive-item".into(), + aggregate_id: "a".into(), + aggregate_sequence: sequence, + publication_ordinal: 0, + occurred_at: std::time::UNIX_EPOCH, + metadata: Default::default(), + }, + &ArchiveState { + title: sequence.to_string(), + }, + ) + .unwrap(); + let mut headers = async_nats::HeaderMap::new(); + headers.insert( + "x-sourced-payload-codec", + "distributed.domain-event-occurrence+json", + ); + headers.insert("Nats-Msg-Id", event.id()); + bus.jetstream + .publish_with_headers( + format!("{namespace}.evt.archive.changed"), + headers, + event.canonical_bytes().unwrap().into(), + ) + .await + .unwrap() + .await + .unwrap(); + } + let first = bus.retained_domain_events().await.unwrap(); + assert_eq!(first.len(), 3); + assert_eq!(bus.retained_domain_events().await.unwrap(), first); + let info = stream.info().await.unwrap(); + assert_eq!(info.state.consumer_count, 0); + assert_eq!(info.state.messages, 3); + // Delete only a message in this test's fresh UUID-scoped stream. + stream.delete_message(2).await.unwrap(); + assert!(bus + .retained_domain_events() + .await + .unwrap_err() + .to_string() + .contains("gapped")); + bus.jetstream + .delete_stream(NatsBus::stream_name(&namespace)) + .await + .unwrap(); + } +} + impl Bus for NatsBus { async fn send_message(&self, message: Message) -> Result<(), TransportError> { self.validated_namespace()?; diff --git a/src/bus/sql_bus_common.rs b/src/bus/sql_bus_common.rs index e2d7fe39e..0ad20698d 100644 --- a/src/bus/sql_bus_common.rs +++ b/src/bus/sql_bus_common.rs @@ -688,8 +688,9 @@ impl MessageSource for SqlLogSource { ), ) })?; - let source = ProjectionSource::new(format!("{}.bus_log", B::BACKEND), b"global".to_vec()) - .map_err(|error| corrupt_row(B::BACKEND, error.to_string()))?; + 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); diff --git a/src/graphql/command_contract/direct_projection.rs b/src/command/direct_projection.rs similarity index 100% rename from src/graphql/command_contract/direct_projection.rs rename to src/command/direct_projection.rs diff --git a/src/graphql/command_contract/effect_wire.rs b/src/command/effect_wire.rs similarity index 99% rename from src/graphql/command_contract/effect_wire.rs rename to src/command/effect_wire.rs index 361074824..94028b62e 100644 --- a/src/graphql/command_contract/effect_wire.rs +++ b/src/command/effect_wire.rs @@ -84,7 +84,7 @@ impl TypedEffectExpression { } } -/// Marker implemented only by `GraphqlInput` derive output in normal use. +/// Marker generated by command input derives (including the legacy GraphQL adapter). /// /// The trait must be public because derive output lives in downstream crates. /// All marker metadata is still revalidated against the final command Surface; diff --git a/src/graphql/command_contract/effects.rs b/src/command/effects.rs similarity index 100% rename from src/graphql/command_contract/effects.rs rename to src/command/effects.rs diff --git a/src/graphql/command_input.rs b/src/command/input.rs similarity index 95% rename from src/graphql/command_input.rs rename to src/command/input.rs index 2ae2beadb..f8fc5bc7e 100644 --- a/src/graphql/command_input.rs +++ b/src/command/input.rs @@ -1,8 +1,7 @@ -//! Canonical typed GraphQL command-input validation. +//! Canonical typed command-input validation. //! -//! Authenticated GraphQL causal dispatch passes through this one validator -//! before ledger reservation. Direct and bus transports currently fail closed; -//! any future verified framework envelope must enter through this same path. +//! Every authenticated command host passes through this validator before +//! ledger reservation. Transport adapters must retain these normalization rules. //! The retained wire value is the source of hashing and declaration-expression //! resolution, so decoding the Rust input never becomes a second, potentially //! differently named serialization path. @@ -14,7 +13,7 @@ use serde::de::DeserializeOwned; use serde_json::{Number, Value}; use sha2::{Digest, Sha256}; -use super::{GraphqlTypeDef, GraphqlTypeField}; +use super::{CommandTypeDef, CommandTypeField}; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CommandInputError { @@ -43,7 +42,7 @@ impl std::fmt::Display for CommandInputError { impl std::error::Error for CommandInputError {} -/// Validated, recursively key-sorted GraphQL wire input and its stable digest. +/// Validated, recursively key-sorted command wire input and its stable digest. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CanonicalCommandInput { wire: Value, @@ -110,7 +109,7 @@ impl CanonicalTypedCommandInput { } pub(crate) fn canonicalize_command_input( - definition: &GraphqlTypeDef, + definition: &CommandTypeDef, input: Value, ) -> Result { let wire = canonicalize_object(definition, input, "$")?; @@ -132,7 +131,7 @@ fn hex_digest(digest: &[u8; 32]) -> String { } fn canonicalize_object( - definition: &GraphqlTypeDef, + definition: &CommandTypeDef, value: Value, path: &str, ) -> Result { @@ -183,7 +182,7 @@ fn canonicalize_object( } fn canonicalize_field( - field: &GraphqlTypeField, + field: &CommandTypeField, value: Value, path: &str, ) -> Result { @@ -218,7 +217,7 @@ fn canonicalize_field( } fn canonicalize_leaf( - field: &GraphqlTypeField, + field: &CommandTypeField, value: Value, path: &str, ) -> Result { @@ -399,9 +398,9 @@ mod tests { nullable: bool, list: bool, item_nullable: bool, - nested: Option, - ) -> GraphqlTypeField { - GraphqlTypeField { + nested: Option, + ) -> CommandTypeField { + CommandTypeField { name: name.into(), type_name: type_name.into(), nullable, @@ -411,8 +410,8 @@ mod tests { } } - fn definition() -> GraphqlTypeDef { - GraphqlTypeDef::new( + fn definition() -> CommandTypeDef { + CommandTypeDef::new( "Input", vec![ field("id", "String", false, false, false, None), @@ -424,7 +423,7 @@ mod tests { false, false, false, - Some(GraphqlTypeDef::new( + Some(CommandTypeDef::new( "NestedInput", vec![field("count", "BigInt", false, false, false, None)], )), @@ -508,7 +507,7 @@ mod tests { #[test] fn graphql_int_is_limited_to_the_signed_32_bit_range() { - let definition = GraphqlTypeDef::new( + let definition = CommandTypeDef::new( "IntInput", vec![field("value", "Int", false, false, false, None)], ); @@ -527,7 +526,7 @@ mod tests { #[test] fn decoding_retains_the_original_wire_instead_of_reserializing_rust() { - let definition = GraphqlTypeDef::new( + let definition = CommandTypeDef::new( "RenamedInput", vec![field("wireId", "String", false, false, false, None)], ); diff --git a/src/graphql/command_contract/mod.rs b/src/command/mod.rs similarity index 95% rename from src/graphql/command_contract/mod.rs rename to src/command/mod.rs index 410c5dff1..991e6beff 100644 --- a/src/graphql/command_contract/mod.rs +++ b/src/command/mod.rs @@ -8,6 +8,12 @@ #![cfg_attr(not(feature = "graphql"), allow(dead_code))] +pub(crate) mod input; +mod types; + +pub(crate) use types::scalar_type_name; +pub use types::{CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField}; + mod direct_projection; mod effect_wire; mod effects; diff --git a/src/graphql/command_contract/outcomes.rs b/src/command/outcomes.rs similarity index 97% rename from src/graphql/command_contract/outcomes.rs rename to src/command/outcomes.rs index d279e59c7..e399378b1 100644 --- a/src/graphql/command_contract/outcomes.rs +++ b/src/command/outcomes.rs @@ -8,7 +8,7 @@ use super::projection_proof::{ validate_resolved_direct_plan, CommandCommitProofError, ProjectionCommitProof, }; use super::typed_command::TypedCommandContract; -use crate::graphql::types::{read_model_graphql_type, GraphqlOutputType, GraphqlTypeDef}; +use super::types::{read_model_command_type, CommandOutputType, CommandTypeDef}; use crate::outbox::OutboxMessage; use crate::projection::lower::LoweredProjectionPlan; use crate::projection_protocol::SameTransactionProjectionBatch; @@ -70,7 +70,7 @@ macro_rules! committed_outcome { impl CommandOutcome for $wrapper where - T: GraphqlOutputType + Serialize + Send + Sync + 'static, + T: CommandOutputType + Serialize + Send + Sync + 'static, { type Payload = T; const CONSISTENCY: CommandConsistency = $kind; @@ -83,8 +83,8 @@ macro_rules! committed_outcome { Self::from_committed_payload(payload) } - fn __graphql_output_type() -> GraphqlTypeDef { - T::graphql_type() + fn __command_output_type() -> CommandTypeDef { + T::command_type() } } }; @@ -110,8 +110,8 @@ where Self::from_committed_payload(payload) } - fn __graphql_output_type() -> GraphqlTypeDef { - read_model_graphql_type::() + fn __command_output_type() -> CommandTypeDef { + read_model_command_type::() } fn __projected_model() -> Option<(TypeId, &'static TableSchema)> { @@ -172,7 +172,7 @@ pub trait CommandOutcome: sealed::Outcome + Send + Sync + 'static { fn __finalize_committed(payload: Self::Payload) -> Self; #[doc(hidden)] - fn __graphql_output_type() -> GraphqlTypeDef; + fn __command_output_type() -> CommandTypeDef; /// Compiler-only model identity retained by an ordinary /// `typed_command::>` declaration. The sealed default keeps diff --git a/src/graphql/command_contract/projection_obligations.rs b/src/command/projection_obligations.rs similarity index 100% rename from src/graphql/command_contract/projection_obligations.rs rename to src/command/projection_obligations.rs diff --git a/src/graphql/command_contract/projection_proof.rs b/src/command/projection_proof.rs similarity index 100% rename from src/graphql/command_contract/projection_proof.rs rename to src/command/projection_proof.rs diff --git a/src/graphql/command_contract/projections.rs b/src/command/projections.rs similarity index 97% rename from src/graphql/command_contract/projections.rs rename to src/command/projections.rs index 59db4b96f..20c00e587 100644 --- a/src/graphql/command_contract/projections.rs +++ b/src/command/projections.rs @@ -100,7 +100,7 @@ impl CommandProjectionPreview { } /// Bind this preview to the exact outward event-set value also passed to - /// [`crate::graphql::TypedCommand::emits`]. + /// [`crate::command::TypedCommand::emits`]. #[must_use] pub fn events(mut self, events: CommandProjectionEventSet) -> Self { self.selectors = events.selectors; @@ -767,7 +767,7 @@ pub fn __command_projection_preview_constant( /// - `#[sourced]`-generated `domain_commands::*` transition witnesses (public /// aggregate methods that call domain-marked `#[event]` recorders) /// -/// Prefer [`crate::graphql::TypedCommand::emits_events`] with these types over +/// Prefer [`crate::command::TypedCommand::emits_events`] with these types over /// hand-maintaining a parallel event list when the domain already owns the /// transition. pub trait CommandEventSet { @@ -814,8 +814,8 @@ impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6, E7, E8); #[macro_export] macro_rules! events { ($($event:ty),+ $(,)?) => { - $crate::graphql::__command_projection_events([ - $($crate::graphql::__command_projection_event_descriptor::<$event>()),+ + $crate::command::__command_projection_events([ + $($crate::command::__command_projection_event_descriptor::<$event>()),+ ]) }; } @@ -829,7 +829,7 @@ macro_rules! state_preview { ( $event:ty => $state:ty { $($fields:tt)* } ) => {{ - $crate::graphql::__command_projection_state_preview::<$event, $state>( + $crate::command::__command_projection_state_preview::<$event, $state>( $crate::__distributed_state_preview_fields!(@collect [] ; $($fields)*) ) }}; @@ -841,7 +841,7 @@ macro_rules! event_preview { ( $event:ty => $body:ty { $($fields:tt)* } ) => {{ - $crate::graphql::__command_projection_event_preview::<$event, $body>( + $crate::command::__command_projection_event_preview::<$event, $body>( $crate::__distributed_state_preview_fields!(@collect [] ; $($fields)*) ) }}; @@ -865,7 +865,7 @@ macro_rules! __distributed_state_preview_fields { $($out,)* ( stringify!($field), - $crate::graphql::CommandProjectionPreviewSource::input([ + $crate::command::CommandProjectionPreviewSource::input([ stringify!($first) $(, stringify!($rest))* ]) ), @@ -882,7 +882,7 @@ macro_rules! __distributed_state_preview_fields { $($out,)* ( stringify!($field), - $crate::graphql::CommandProjectionPreviewSource::generated_default([ + $crate::command::CommandProjectionPreviewSource::generated_default([ stringify!($first) $(, stringify!($rest))* ]) ), @@ -899,7 +899,7 @@ macro_rules! __distributed_state_preview_fields { $($out,)* ( stringify!($field), - $crate::graphql::CommandProjectionPreviewSource::trusted($name, $codec) + $crate::command::CommandProjectionPreviewSource::trusted($name, $codec) ), ]; $($tail)* @@ -907,19 +907,19 @@ macro_rules! __distributed_state_preview_fields { }; (@collect [$($out:expr,)*] ; $field:ident : unknown, $($tail:tt)*) => { $crate::__distributed_state_preview_fields!( - @collect [$($out,)* (stringify!($field), $crate::graphql::CommandProjectionPreviewSource::Unknown),]; + @collect [$($out,)* (stringify!($field), $crate::command::CommandProjectionPreviewSource::Unknown),]; $($tail)* ) }; (@collect [$($out:expr,)*] ; $field:ident : absent, $($tail:tt)*) => { $crate::__distributed_state_preview_fields!( - @collect [$($out,)* (stringify!($field), $crate::graphql::CommandProjectionPreviewSource::Absent),]; + @collect [$($out,)* (stringify!($field), $crate::command::CommandProjectionPreviewSource::Absent),]; $($tail)* ) }; (@collect [$($out:expr,)*] ; $field:ident : null, $($tail:tt)*) => { $crate::__distributed_state_preview_fields!( - @collect [$($out,)* (stringify!($field), $crate::graphql::CommandProjectionPreviewSource::Null),]; + @collect [$($out,)* (stringify!($field), $crate::command::CommandProjectionPreviewSource::Null),]; $($tail)* ) }; @@ -929,7 +929,7 @@ macro_rules! __distributed_state_preview_fields { $($out,)* ( stringify!($field), - $crate::graphql::__command_projection_preview_constant($constant) + $crate::command::__command_projection_preview_constant($constant) ), ]; $($tail)* @@ -941,7 +941,7 @@ macro_rules! __distributed_state_preview_fields { $($out,)* ( stringify!($field), - $crate::graphql::__command_projection_preview_constant($constant) + $crate::command::__command_projection_preview_constant($constant) ), ]; $($tail)* diff --git a/src/graphql/command_contract/tests.rs b/src/command/tests.rs similarity index 95% rename from src/graphql/command_contract/tests.rs rename to src/command/tests.rs index 4894268ae..9ae7d2eb9 100644 --- a/src/graphql/command_contract/tests.rs +++ b/src/command/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::graphql::{GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField}; +use crate::command::{CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField}; use crate::microsvc::Session; use crate::outbox::OutboxMessage; use crate::projection_protocol::{ @@ -9,17 +9,30 @@ use crate::table::{ColumnType, PrimaryKey, TableColumn, TableKind, TableSchema}; use serde::{Deserialize, Serialize}; use std::any::TypeId; +#[test] +fn command_ledger_fingerprint_preserves_the_v1_canonical_contract() { + // The v1 digest includes historical field/scalar names. Module ownership + // must not change the identity used to recognize a command retry. + let (_, contract) = typed_command::>("core.fingerprint").into_parts(); + let digest = contract.fingerprint_bytes(); + let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect(); + assert_eq!( + hex, + "39cd9dbf7a1b3a17e0ee5c04841af2af95b0ba391c07eced4c4e1a80d9e55be5" + ); +} + #[allow(dead_code)] #[derive(Deserialize)] struct Input { id: String, } -impl GraphqlInputType for Input { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandInputType for Input { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "Input", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -123,11 +136,11 @@ impl crate::domain_event::DomainEventContract for DishonestStateContract { impl crate::domain_event::DomainEventBodyContract for DishonestStateContract {} -impl GraphqlOutputType for Payload { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandOutputType for Payload { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "Payload", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -236,11 +249,10 @@ fn command_transition_fills_emits_from_domain_event_set() { #[test] fn authenticated_user_field_reuses_generated_event_schema_metadata() { - let contract = super::command_transition::>( - "todo.complete", - ) - .authenticated_user_field::("status") - .into_contract(); + let contract = + super::command_transition::>("todo.complete") + .authenticated_user_field::("status") + .into_contract(); let field = contract.projections.inferred_values[0] .preview .fields @@ -848,11 +860,11 @@ fn command_fingerprints_canonicalize_nested_json_object_keys() { } #[test] -fn binding_rejects_missing_graphql_type_ids() { +fn binding_rejects_missing_command_type_ids() { let mut contract = typed_command::>("todo.create").into_contract(); contract.input.type_id = None; let error = TypedServiceCommandBinding::from_contracts("todos", &[contract]).unwrap_err(); - assert!(error.contains("input GraphQL metadata is missing")); + assert!(error.contains("input command metadata is missing")); } #[test] @@ -860,7 +872,7 @@ fn binding_canonicalizes_fields_and_roles_but_preserves_effect_order() { let mut first = typed_command::>("todo.create") .roles(["writer", "admin"]) .into_contract(); - first.input.fields.push(GraphqlTypeField { + first.input.fields.push(CommandTypeField { name: "z_extra".into(), type_name: "String".into(), nullable: true, @@ -887,7 +899,7 @@ fn binding_canonicalizes_fields_and_roles_but_preserves_effect_order() { let mut reordered = typed_command::>("todo.create") .roles(["writer", "admin"]) .into_contract(); - reordered.input.fields.push(GraphqlTypeField { + reordered.input.fields.push(CommandTypeField { name: "z_extra".into(), type_name: "String".into(), nullable: true, diff --git a/src/graphql/command_contract/typed_command.rs b/src/command/typed_command.rs similarity index 97% rename from src/graphql/command_contract/typed_command.rs rename to src/command/typed_command.rs index 3f2ef88f1..221b0ffe8 100644 --- a/src/graphql/command_contract/typed_command.rs +++ b/src/command/typed_command.rs @@ -24,8 +24,7 @@ use super::projections::{ CommandProjectionEvents, CommandProjectionPreview, CommandProjectionPreviewSource, CommandProjectionPureReduce, }; -use crate::graphql::naming; -use crate::graphql::types::{GraphqlInputType, GraphqlTypeDef}; +use super::types::{scalar_type_name, CommandInputType, CommandTypeDef}; use crate::microsvc::Session; use crate::outbox::OutboxMessage; use crate::projection_protocol::{ @@ -39,8 +38,8 @@ pub(crate) struct TypedCommandContract { pub name: String, pub field_name: String, pub roles: Vec, - pub input: GraphqlTypeDef, - pub output: GraphqlTypeDef, + pub input: CommandTypeDef, + pub output: CommandTypeDef, pub input_type_id: TypeId, pub output_type_id: TypeId, pub consistency: CommandConsistency, @@ -111,8 +110,8 @@ impl TypedCommandContract { "name": self.name, "field_name": self.field_name, "roles": roles, - "input": canonical_graphql_type(&self.input), - "output": canonical_graphql_type(&self.output), + "input": canonical_command_type(&self.input), + "output": canonical_command_type(&self.output), "consistency": self.consistency, "input_defaults": input_defaults, "effects": effects, @@ -166,7 +165,7 @@ impl TypedCommandContract { .iter() .find(|column| column.column_name == field.field) }) - .and_then(|column| naming::scalar_type_name(&column.column_type)), + .and_then(|column| scalar_type_name(&column.column_type)), ) .map(|value| ResolvedProjectionKeyField { field: field.field.clone(), @@ -343,7 +342,7 @@ fn resolve_projection_obligation_expression( } } -fn canonical_graphql_type(definition: &GraphqlTypeDef) -> serde_json::Value { +fn canonical_command_type(definition: &CommandTypeDef) -> serde_json::Value { let mut fields = definition.fields.iter().collect::>(); fields.sort_by(|left, right| left.name.cmp(&right.name)); serde_json::json!({ @@ -354,7 +353,7 @@ fn canonical_graphql_type(definition: &GraphqlTypeDef) -> serde_json::Value { "nullable": field.nullable, "list": field.list, "item_nullable": field.item_nullable, - "nested": field.nested.as_deref().map(canonical_graphql_type), + "nested": field.nested.as_deref().map(canonical_command_type), })).collect::>(), }) } @@ -399,13 +398,13 @@ impl TypedServiceCommandBinding { } if contract.input.type_id != Some(contract.input_type_id) { return Err(format!( - "typed command `{}` input GraphQL metadata is missing or has a different Rust TypeId", + "typed command `{}` input command metadata is missing or has a different Rust TypeId", contract.name )); } if contract.output.type_id != Some(contract.output_type_id) { return Err(format!( - "typed command `{}` output GraphQL metadata is missing or has a different Rust TypeId", + "typed command `{}` output command metadata is missing or has a different Rust TypeId", contract.name )); } @@ -565,7 +564,7 @@ impl Clone for TypedCommand { /// Begin a typed command declaration. pub fn typed_command(name: &'static str) -> TypedCommand where - I: GraphqlInputType + DeserializeOwned + Send + 'static, + I: CommandInputType + DeserializeOwned + Send + 'static, K: CommandOutcome, { let route_name = name; @@ -577,8 +576,8 @@ where other => other, }) .collect(); - let input = I::graphql_type(); - let output = K::__graphql_output_type(); + let input = I::command_type(); + let output = K::__command_output_type(); let projected_model = K::__projected_model() .map(|(output_type_id, schema)| CommandProjectedModel::new(output_type_id, schema)); TypedCommand { @@ -616,7 +615,7 @@ where pub fn command_transition(name: &'static str) -> TypedCommand where S: super::CommandEventSet, - I: GraphqlInputType + DeserializeOwned + Send + 'static, + I: CommandInputType + DeserializeOwned + Send + 'static, K: CommandOutcome, { typed_command::(name).emits_events::() @@ -750,7 +749,7 @@ impl TypedCommand { impl TypedCommand> where - I: GraphqlInputType + DeserializeOwned + Send + 'static, + I: CommandInputType + DeserializeOwned + Send + 'static, M: RelationalReadModel + Serialize + Send + Sync + 'static, { /// Attach compiler-generated direct projection ownership metadata. diff --git a/src/graphql/types.rs b/src/command/types.rs similarity index 52% rename from src/graphql/types.rs rename to src/command/types.rs index bb50ebd28..4e38cc946 100644 --- a/src/graphql/types.rs +++ b/src/command/types.rs @@ -1,13 +1,17 @@ -//! Command-mutation GraphQL type metadata (input/output derives). +//! Transport-neutral command data shapes. +//! +//! Scalar names are stable codec identifiers shared by command validation, +//! fingerprints, and client artifacts. Their historical spellings are retained +//! for wire compatibility; adapters own syntax and representability checks. use std::any::TypeId; -use crate::graphql::naming::scalar_type_name; use crate::read_model::RelationalReadModel; +use crate::table::ColumnType; -/// One field on a GraphQL input or output object. +/// One field on a command input or output object. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct GraphqlTypeField { +pub struct CommandTypeField { pub name: String, pub type_name: String, pub nullable: bool, @@ -15,19 +19,19 @@ pub struct GraphqlTypeField { /// Whether list elements are nullable. Always `false` for non-list fields. pub item_nullable: bool, /// Nested object type definition when `type_name` is not a scalar. - pub nested: Option>, + pub nested: Option>, } /// Full type definition for a derive-emitted input or output object. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct GraphqlTypeDef { +pub struct CommandTypeDef { pub name: String, - pub fields: Vec, + pub fields: Vec, pub type_id: Option, } -impl GraphqlTypeDef { - pub fn new(name: impl Into, fields: Vec) -> Self { +impl CommandTypeDef { + pub fn new(name: impl Into, fields: Vec) -> Self { Self { name: name.into(), fields, @@ -41,7 +45,7 @@ impl GraphqlTypeDef { } /// Transitive nested type defs (depth-first, deduped by name). - pub fn transitive_nested(&self) -> Vec { + pub fn transitive_nested(&self) -> Vec { let mut out = Vec::new(); let mut seen = std::collections::BTreeSet::new(); self.collect_nested(&mut out, &mut seen); @@ -50,7 +54,7 @@ impl GraphqlTypeDef { fn collect_nested( &self, - out: &mut Vec, + out: &mut Vec, seen: &mut std::collections::BTreeSet, ) { for field in &self.fields { @@ -64,20 +68,29 @@ impl GraphqlTypeDef { } } -pub trait GraphqlInputType { - fn graphql_type() -> GraphqlTypeDef; +/// Structural description of the serialized input accepted by a command. +/// +/// Prefer deriving [`CommandInput`](crate::CommandInput). Field names follow +/// Serde's deserialization direction. Transport-specific naming restrictions +/// are checked when an adapter exposes the declaration. +pub trait CommandInputType { + fn command_type() -> CommandTypeDef; } -pub trait GraphqlOutputType { - fn graphql_type() -> GraphqlTypeDef; +/// Structural description of a command result, following Serde serialization. +/// +/// Prefer deriving [`CommandOutput`](crate::CommandOutput). Atomic read-model +/// outcomes derive their shape directly from the relational schema instead. +pub trait CommandOutputType { + fn command_type() -> CommandTypeDef; } /// Build a command-output object from the same relational schema that owns -/// the generated query object. +/// the stored read model. /// /// Atomic command results contain stored columns only. Relationships stay /// query-time fields and are never invented by a same-transaction row result. -pub(crate) fn read_model_graphql_type() -> GraphqlTypeDef +pub(crate) fn read_model_command_type() -> CommandTypeDef where M: RelationalReadModel + 'static, { @@ -89,11 +102,11 @@ where .map(|column| { let type_name = scalar_type_name(&column.column_type).unwrap_or_else(|| { panic!( - "read model `{}` column `{}` has no GraphQL scalar mapping", + "read model `{}` column `{}` has no command scalar mapping", schema.model_name, column.column_name ) }); - GraphqlTypeField { + CommandTypeField { name: column.column_name.clone(), type_name: type_name.into(), nullable: column.nullable, @@ -103,20 +116,19 @@ where } }) .collect(); - GraphqlTypeDef::new(schema.model_name.clone(), fields).with_type_id(TypeId::of::()) + CommandTypeDef::new(schema.model_name.clone(), fields).with_type_id(TypeId::of::()) } -// Builtin scalar mappings for free-standing helpers used by derives. -#[allow(dead_code)] -pub fn scalar_for_rust_type(ty: &str) -> Option<&'static str> { - match ty { - "String" | "str" => Some("String"), - "bool" => Some("Boolean"), - "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize" => { - Some("BigInt") - } - "f32" | "f64" => Some("Float"), - "Value" | "serde_json::Value" => Some("JSON"), - _ => None, +/// Stable scalar codec name for a relational column in a command result. +pub(crate) fn scalar_type_name(column_type: &ColumnType) -> Option<&'static str> { + match column_type { + ColumnType::Text => Some("String"), + ColumnType::Boolean => Some("Boolean"), + ColumnType::Integer | ColumnType::UnsignedInteger => Some("BigInt"), + ColumnType::Float => Some("Float"), + ColumnType::Json => Some("JSON"), + ColumnType::Timestamp => Some("Timestamptz"), + ColumnType::Bytes => Some("Bytea"), + ColumnType::Unsupported(_) => None, } } diff --git a/src/command_ledger/tests.rs b/src/command_ledger/tests.rs index da9e426e5..a3ccd1bd8 100644 --- a/src/command_ledger/tests.rs +++ b/src/command_ledger/tests.rs @@ -123,6 +123,7 @@ fn direct_projection_evidence(marker: &str) -> SameTransactionProjectionEvidence let revision = RecordRevision::new(scope.clone(), 1, 1).unwrap(); let cursor = ProjectionChangeCursor::new(topology, partition, epoch, 1).unwrap(); let record = ProjectionRecordMetadata { + source_snapshot: None, revision: revision.clone(), tombstone: false, change: cursor.clone(), diff --git a/src/command_ledger/traits.rs b/src/command_ledger/traits.rs index bd678363a..76724d48a 100644 --- a/src/command_ledger/traits.rs +++ b/src/command_ledger/traits.rs @@ -16,6 +16,13 @@ pub(crate) trait CausalGetStream: Send + Sync { &'a self, identity: &'a StreamIdentity, ) -> impl Future, RepositoryError>> + Send + 'a; + + /// Fetch only the post-snapshot tail without retaining wrapper queue locks. + fn get_causal_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl Future, RepositoryError>> + Send + 'a; } /// Proves that command reservation, stream loading, and causal commit are all diff --git a/src/domain_event/derived_tests.rs b/src/domain_event/derived_tests.rs new file mode 100644 index 000000000..fd71e4bd7 --- /dev/null +++ b/src/domain_event/derived_tests.rs @@ -0,0 +1,185 @@ +use super::*; +use serde::Serialize; + +#[derive(Clone, serde::Serialize, serde::Deserialize, crate::ReadModel)] +#[readmodel(table = "derived_rows", primary_key = ["bytes"])] +struct DerivedRows { + bytes: u64, +} +#[allow(non_snake_case)] +fn SaveDerived() -> crate::Mutation<()> { + crate::mutation_file!("tests/fixtures/derived_event_save.graphql") +} +use crate::projection::lower::{EventualOnly, ProjectionDescriptor}; +crate::projection! { + const DERIVED_ROWS: ProjectionDescriptor = { + name: "derived-rows", version: 1, epoch: "derived-v1", model: DerivedRows, + on { events: [Indexed], mutation: SaveDerived, input: { row: body }, }, + }; +} +crate::projection! { + const SNAPSHOT_ROWS: ProjectionDescriptor = { + name: "derived-snapshot-rejection", version: 1, epoch: "snapshot-v1", model: DerivedRows, + source: aggregate_snapshot, + on { events: [Indexed], mutation: SaveDerived, input: { row: body }, }, + }; +} + +#[derive(Serialize)] +struct Indexed { + bytes: u64, +} +impl DomainEventContract for Indexed { + const EVENT_NAME: &'static str = "document.indexed"; + const EVENT_VERSION: u64 = 1; + fn descriptor() -> DomainEventDescriptor { + ::DESCRIPTOR.clone() + } +} +impl DomainEvent for Indexed { + const DESCRIPTOR: DomainEventDescriptor = DomainEventDescriptor { + name: std::borrow::Cow::Borrowed("document.indexed"), + version: 1, + body: DomainEventBodyDescriptor::distributed_json( + DomainEventBodyKind::Event, + "Indexed", + 1, + "indexed-v1", + "sha256:1111111111111111111111111111111111111111111111111111111111111111", + ), + }; +} +fn source() -> DomainEventOccurrence { + let mut entity = crate::Entity::with_id("document-1"); + entity.set_causation_id("command-1"); + entity.set_correlation_id("request-1"); + entity.digest("document.changed", &()).unwrap(); + entity + .capture_domain_event("document", &Indexed { bytes: 1 }) + .unwrap(); + entity.pending_domain_events()[0].clone() +} + +#[test] +fn derived_retry_round_trip_preserves_source_provenance() { + let source = source(); + let derived = source + .derive("indexer", "summary", &Indexed { bytes: 10 }) + .unwrap(); + let repeated = source + .derive("indexer", "summary", &Indexed { bytes: 10 }) + .unwrap(); + assert_eq!(derived, repeated); + assert_eq!( + derived.canonical_bytes().unwrap(), + repeated.canonical_bytes().unwrap() + ); + assert_eq!(derived.aggregate_id(), source.aggregate_id()); + assert_eq!(derived.aggregate_sequence(), source.aggregate_sequence()); + assert_eq!(derived.occurred_at_unix_ms(), source.occurred_at_unix_ms()); + assert_eq!(derived.causation_id(), source.causation_id()); + assert_eq!(derived.correlation_id(), source.correlation_id()); + assert_eq!( + derived.derivation().unwrap().source_occurrence_id, + source.id() + ); + assert_eq!( + DomainEventOccurrence::from_canonical_bytes(&derived.canonical_bytes().unwrap()).unwrap(), + derived + ); + assert!(source.derivation().is_none()); + assert_eq!( + DomainEventOccurrence::from_canonical_bytes(&source.canonical_bytes().unwrap()).unwrap(), + source + ); + let nested = derived + .derive("renderer", "html", &Indexed { bytes: 20 }) + .unwrap(); + assert_eq!( + nested.derivation().unwrap().source_occurrence_id, + derived.id() + ); + assert_eq!(nested.aggregate_id(), source.aggregate_id()); +} + +#[test] +fn derived_identity_binds_producer_key_body_and_parent() { + let source = source(); + let first = source + .derive("indexer", "one", &Indexed { bytes: 10 }) + .unwrap(); + for other in [ + source + .derive("other", "one", &Indexed { bytes: 10 }) + .unwrap(), + source + .derive("indexer", "two", &Indexed { bytes: 10 }) + .unwrap(), + source + .derive("indexer", "one", &Indexed { bytes: 11 }) + .unwrap(), + first + .derive("indexer", "one", &Indexed { bytes: 10 }) + .unwrap(), + ] { + assert_ne!(first.id(), other.id()); + } +} + +#[test] +fn derived_keys_and_canonical_tampering_fail_closed() { + let source = source(); + for key in ["".into(), " ".into(), "a\nb".into(), "x".repeat(1025)] { + assert!(source + .derive("indexer", &key, &Indexed { bytes: 1 }) + .is_err()); + } + assert!(source.derive("", "key", &Indexed { bytes: 1 }).is_err()); + let derived = source + .derive("indexer", "one", &Indexed { bytes: 10 }) + .unwrap(); + for (pointer, value) in [ + ("/derivation/output_key", serde_json::json!("changed")), + ("/aggregate_sequence", serde_json::json!(10)), + ("/occurred_at_unix_ms", serde_json::json!(0)), + ("/body", serde_json::json!("e30=")), + ] { + let mut wire = serde_json::to_value(&derived).unwrap(); + *wire.pointer_mut(pointer).unwrap() = value; + assert!( + DomainEventOccurrence::from_canonical_bytes(&canonical_json_bytes(&wire).unwrap()) + .is_err() + ); + } +} + +#[test] +fn derived_facts_are_not_aggregate_snapshots() { + let source = source(); + let derived = source + .derive("indexer", "one", &Indexed { bytes: 10 }) + .unwrap(); + assert!(crate::projection_protocol::SourceSnapshotVersion::from_occurrence(&source).is_ok()); + assert!(crate::projection_protocol::SourceSnapshotVersion::from_occurrence(&derived).is_err()); + assert_eq!( + DERIVED_ROWS + .server_executor() + .unwrap() + .plan(&derived) + .unwrap() + .write_plan + .mutations + .len(), + 1 + ); + assert!(SNAPSHOT_ROWS + .server_executor() + .unwrap() + .plan(&derived) + .is_err()); + assert!(SNAPSHOT_ROWS + .server_executor() + .unwrap() + .plan(&source) + .is_ok()); +} diff --git a/src/domain_event/mod.rs b/src/domain_event/mod.rs index 86fa4b0bb..0843f9e62 100644 --- a/src/domain_event/mod.rs +++ b/src/domain_event/mod.rs @@ -5,6 +5,8 @@ //! untouched, and only explicit successful persistence clears them. mod canonical; +#[cfg(test)] +mod derived_tests; mod descriptor; mod occurrence; @@ -19,7 +21,7 @@ pub use descriptor::{ }; pub use occurrence::{ DomainEventCaptureError, DomainEventCaptureOutcome, DomainEventCapturePoison, - DomainEventCommitGuardError, DomainEventEnvelope, DomainEventOccurrence, + DomainEventCommitGuardError, DomainEventDerivation, DomainEventEnvelope, DomainEventOccurrence, DOMAIN_EVENT_OCCURRENCE_VERSION, }; diff --git a/src/domain_event/occurrence.rs b/src/domain_event/occurrence.rs index f9b584f6b..592bb11dd 100644 --- a/src/domain_event/occurrence.rs +++ b/src/domain_event/occurrence.rs @@ -22,6 +22,20 @@ use super::{ /// Version of the canonical [`DomainEventOccurrence`] envelope. pub const DOMAIN_EVENT_OCCURRENCE_VERSION: u16 = 1; +/// Provenance of a typed fact produced while handling an earlier occurrence. +/// The envelope's aggregate fields still describe the originating transition, +/// not a new aggregate commit by this producer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DomainEventDerivation { + /// Immediate parent occurrence (which may itself be derived). + pub source_occurrence_id: String, + /// Stable effect-handler identity. + pub producer: String, + /// Stable output identity within this handler/source pair. + pub output_key: String, +} + /// Immutable framework metadata captured with one outward event transition. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DomainEventEnvelope { @@ -42,6 +56,8 @@ pub struct DomainEventEnvelope { /// One exact typed outward event captured at aggregate transition time. #[derive(Clone, PartialEq, Eq, Serialize)] pub struct DomainEventOccurrence { + #[serde(skip_serializing_if = "Option::is_none")] + derivation: Option, /// Canonical occurrence envelope version. occurrence_version: u16, /// Retry-stable occurrence identity. @@ -113,6 +129,7 @@ impl DomainEventOccurrence { validate_stable_message_id(Some(&id)).map_err(DomainEventCaptureError::OccurrenceId)?; let occurrence = Self { + derivation: None, occurrence_version: DOMAIN_EVENT_OCCURRENCE_VERSION, id, descriptor, @@ -133,6 +150,80 @@ impl DomainEventOccurrence { &self.body } + /// Derive a typed fact without inventing another aggregate transition. + /// + /// Repeat with the same source, producer, output key and body for identical + /// canonical bytes. Changed bodies have distinct identities. Publishing is + /// still at-least-once: acknowledge the source only after all outputs have + /// been accepted by the bus, and retry accepted prefixes with these IDs. + pub fn derive( + &self, + producer: &str, + output_key: &str, + body: &T, + ) -> Result { + self.validate()?; + let mut result = self.clone(); + result.descriptor = T::DESCRIPTOR.clone(); + if result.descriptor.body.kind != DomainEventBodyKind::Event { + return Err(DomainEventCaptureError::BodyKindMismatch { + expected: DomainEventBodyKind::Event, + actual: result.descriptor.body.kind, + }); + } + result.derivation = Some(DomainEventDerivation { + source_occurrence_id: self.id.clone(), + producer: producer.into(), + output_key: output_key.into(), + }); + result.body = canonical_json_bytes(body)?; + result.id = result.derived_identity()?; + result.canonical_bytes()?; + Ok(result) + } + + /// None for an aggregate's own captured transition facts. + pub fn derivation(&self) -> Option<&DomainEventDerivation> { + self.derivation.as_ref() + } + + fn derived_identity(&self) -> Result { + let derivation = self + .derivation + .as_ref() + .ok_or(DomainEventCaptureError::InvalidDerivation)?; + validate_message_name(&derivation.producer) + .map_err(|_| DomainEventCaptureError::InvalidDerivation)?; + validate_stable_message_id(Some(&derivation.source_occurrence_id)) + .map_err(|_| DomainEventCaptureError::InvalidDerivation)?; + if derivation.output_key.trim().is_empty() + || derivation.output_key.len() > 1024 + || derivation.output_key.chars().any(char::is_control) + { + return Err(DomainEventCaptureError::InvalidDerivation); + } + if self.descriptor.body.kind != DomainEventBodyKind::Event { + return Err(DomainEventCaptureError::InvalidDerivation); + } + let mut hash = Sha256::new(); + hash.update(b"distributed.domain-event.derived/v1\0"); + for value in [ + &derivation.source_occurrence_id, + &derivation.producer, + &derivation.output_key, + &self.aggregate_type, + &self.aggregate_id, + ] { + hash_component(&mut hash, value.as_bytes()); + } + hash.update(self.aggregate_sequence.to_be_bytes()); + hash.update(self.publication_ordinal.to_be_bytes()); + hash.update(self.occurred_at_unix_ms.to_be_bytes()); + hash_component(&mut hash, &canonical_json_bytes(&self.descriptor)?); + hash_component(&mut hash, &self.body); + Ok(format!("dd1:sha256:{:x}", hash.finalize())) + } + /// Return the canonical occurrence envelope version. pub fn occurrence_version(&self) -> u16 { self.occurrence_version @@ -241,6 +332,7 @@ impl DomainEventOccurrence { let wire: DomainEventOccurrenceWire = serde_json::from_slice(bytes) .map_err(|error| DomainEventCaptureError::OccurrenceDecoding(error.to_string()))?; let occurrence = Self { + derivation: wire.derivation, occurrence_version: wire.occurrence_version, id: wire.id, descriptor: wire.descriptor, @@ -294,7 +386,12 @@ impl DomainEventOccurrence { occurred_at: UNIX_EPOCH, metadata: BTreeMap::new(), }; - if occurrence_id(&self.descriptor, &envelope) != self.id { + let expected_id = if self.derivation.is_some() { + self.derived_identity()? + } else { + occurrence_id(&self.descriptor, &envelope) + }; + if expected_id != self.id { return Err(DomainEventCaptureError::OccurrenceIdentityMismatch); } Ok(()) @@ -310,6 +407,8 @@ impl DomainEventOccurrence { #[derive(Deserialize)] struct DomainEventOccurrenceWire { + #[serde(default)] + derivation: Option, occurrence_version: u16, id: String, descriptor: DomainEventDescriptor, @@ -390,6 +489,8 @@ fn validate_fingerprint(fingerprint: &str) -> Result<(), DomainEventCaptureError #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum DomainEventCaptureError { + /// Derived provenance has an invalid producer, output key or source identity. + InvalidDerivation, /// Semantic event name violated transport naming rules. EventName(MessageNameError), /// Aggregate type violated transport naming rules. @@ -468,6 +569,7 @@ pub enum DomainEventCaptureError { impl fmt::Display for DomainEventCaptureError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::InvalidDerivation => formatter.write_str("invalid domain-event derivation"), Self::EventName(error) => write!(formatter, "invalid domain-event name: {error}"), Self::AggregateType(error) => write!(formatter, "invalid aggregate type: {error}"), Self::AggregateId(error) => write!(formatter, "invalid aggregate id: {error}"), diff --git a/src/gateway/README.md b/src/gateway/README.md new file mode 100644 index 000000000..1563be0bc --- /dev/null +++ b/src/gateway/README.md @@ -0,0 +1,122 @@ +# Gateway identity boundary + +`AuthProvider` validates current `Credentials` and supplies `RequestContext` for +UI, API, assets and custom routes. Run authentication before each consumer's +route admission or delivery reuse; an expired assertion fails even on a public +route. `provider_revision` changes when provider policy/configuration changes. +An application can replace the provider without changing route declarations. +Contexts are in-process assertions and have no client deserializer. + +`BackendCredential` is explicit: either none or a provider-supplied bearer. +The gateway does not translate a cookie or `x-user-id` into a credential. +A configured session provider may resolve an Auth.js session and supply its +access token; the backend must still validate that token and authorize the +operation. With `graphql` enabled, `graphql::identity::OidcGatewayProvider` +reuses the existing JWT validator, JWKS cache and role mapping. This optional +adapter does not make the portable gateway depend on GraphQL. + +Delegate login/callback/refresh/logout to existing auth handlers. Configure +`AUTH_URL` as the public origin. Strip incoming identity and forwarded headers, +including deployment-specific identity/secret names, before adding trusted +public-origin metadata. Preserve Origin for CSRF and every Set-Cookie header. +Credentials are redacted from Debug output and are not cache keys. + +The reusable production Auth.js/OIDC/browser fixture is in +`tests/gateway-auth`. Network adapters and application mounts own transport +plumbing; merely declaring routes or providers starts no service or identity +store. Removing a gateway mount restores the application's prior entrypoints; +backend authentication stays enabled. + +## Native HTTP adapter + +Enable `gateway-native` and construct `NativeGateway` from a validated gateway, +`NativeOptions`, explicit named `NativeBinding` resources and `NativeAuth`. +Mount its `router()` on the application's existing listener. Local handlers +receive `RequestContext` through Axum request extensions. UI proxy targets come +from the portable declaration; callers cannot select upstream URLs. + +Use the real public origin in `NativeOptions::new`. Incoming identity and +forwarded headers are stripped; Host and forwarded host/protocol are rebuilt +from this value, while Origin is preserved for CSRF. Add deployment-specific +identity/secret header names to `strip_headers`. Upstream redirects are returned +to the browser, with private-origin locations mapped to public origin. The +proxy disables retries, automatic redirects, environment proxies and automatic +response decompression. Duplicate Set-Cookie values remain separate. + +`ProxyLimits` bounds request bytes, active proxy streams, connect/header wait, +read idle time and upgraded-connection lifetime. Known oversize requests return +413; over-limit streamed uploads terminate rather than being retried. Capacity +exhaustion returns 503. Response bytes stream without a whole-body buffer; +dropping the body releases capacity and cancels its upstream stream. WebSocket +upgrades require an explicit target opt-in and close on disconnect, identity +expiry or configured lifetime. Exact public-origin loops fail construction; +a bounded hop chain also detects loops through aliases at runtime. + +`StaticAssets` validates an immutable preloaded path/byte inventory and memory +budget. Protected assets run normal admission before lookup. It performs no +caller-selected filesystem access. Native construction starts no listener, +projector or event consumer. Disabling this mount restores previous entrypoints. + +### GraphQL executors + +`gateway-graphql` adds a portable parser-based selected-operation policy; it +compiles to Wasm without an executor. `gateway-graphql-native` binds the existing +GraphQL HTTP/WS executor or a whole remote endpoint to `NativeBinding::Graphql`. +Build query-only engines without command inventory and with `subscriptions(false)` +when live is absent. Commands include status recovery; status queries are never +ordinary reusable reads. Configure custom fields on the selected executor and +register their extension IDs. Custom embedded executors must install the supplied +operation filter and retain/run `GraphqlConnectionGuard` for custom WS handlers. + +Remote transport preserves operation names, variables, errors, extensions and +GraphQL WS IDs. It never retries mutations or creates receipts on transport +failure. Configured body, concurrency and connection lifetime limits apply; +backend authorization remains authoritative. Optional delivery features are +rejected until their adapters are bound. Removing the GraphQL binding restores +the existing direct executor routes; no migration is required. + +### Causal read routing + +`gateway-delivery` supplies bounded exact-scope identity, dependency overlap and +record/index minima contracts without a server or SQL dependency. With GraphQL, +`ReadRouting::new(replica).stale_tolerant(document, operation_name)` explicitly +registers stale-tolerant reads. Pass it to `GraphqlEngineBuilder::read_routing`; +the builder's original repository remains the authoritative primary. Schema and +SQL dialect must match. Unregistered queries, command recovery and live refreshes +use primary. SQLx pools do not certify replay progress. + +Generated query artifacts carry the protocol fingerprint. The client sends +`extensions.gatewayFreshness`, bound to its server-established schema, protocol, +policy and cache scope. Generated command dependencies select affected queries; +unknown effects broaden within the authorized surface. Pending effects force +primary without confirming projection. Confirmed query/live index clocks and +Atomic record fences survive optimistic layer retirement. An origin response +that cannot cover supplied minima returns `FRESHNESS_PENDING`, including +incomparable scopes. No stale fallback occurs on primary failure. Context limits +fail explicitly rather than discarding retained evidence. This initial router +has no replica-proof adapter; any retained floor uses primary. + +Applications must change the configured protocol namespace when activating a +new projection epoch/backend with incomparable evidence. Old contexts are +rejected and the existing replica scope/reset lifecycle handles rebootstrap. +Keep normal backend authentication on the executor: cacheScope and minima are +identifiers/hints, never bearer credentials. Snapshot-cache and shared-work +admission require a fresh origin identity for every consumer. + +No routing migration is required. Disable replica registration to route all reads +to primary; keep client revision fences active during a deployment rollback. +The isolated physical standby fixture is documented in tests/gateway-postgres. + +Snapshot caching is available through the explicit `NativeDelivery::snapshots` +resource (`gateway-graphql-native,gateway-delivery`). Origin-side +`GatewayVersionStore` supplies transactional data/proof dependency versions; +every hit authenticates and validates at the primary without result SQL. +See [activation, limits, public-age policy and rollback](../../docs/gateway/snapshot-cache.md). + +Concurrent queries can share a bounded execution independently of caching via +`NativeDelivery::coalescing(FlightLimits)`. Each consumer still authenticates; +last-consumer cancellation drops the upstream future. See +[query coalescing](../../docs/gateway/query-coalescing.md). + +Shared live delivery is independently selected with `NativeDelivery::live(LiveLimits)`. +See [live sharing and recovery](../../docs/gateway/live-sharing.md). diff --git a/src/gateway/application.rs b/src/gateway/application.rs new file mode 100644 index 000000000..bbab1882f --- /dev/null +++ b/src/gateway/application.rs @@ -0,0 +1,28 @@ +use super::{BindingKind, Gateway}; +use crate::application::{ApplicationExtension, ApplicationResult}; + +impl Gateway { + /// Logical capability declaration for Application/Service composition. + /// Changing physical origins, paths or embedded/remote placement does not + /// change the portable application identity. + pub fn application_extension( + &self, + id: impl Into, + ) -> ApplicationResult { + let bindings = self.bindings().map(|binding| { + let capability = match &binding.kind { + BindingKind::Handler => serde_json::json!({"kind":"handler"}), + BindingKind::Admission => serde_json::json!({"kind":"admission"}), + BindingKind::Assets => serde_json::json!({"kind":"assets"}), + BindingKind::UiProxy { .. } => serde_json::json!({"kind":"ui"}), + BindingKind::Graphql { capabilities, delivery, schema_extensions, .. } => serde_json::json!({"kind":"graphql","operations":capabilities,"delivery":delivery,"schemaExtensions":schema_extensions}), + }; + serde_json::json!({"id":binding.id,"capability":capability}) + }).collect::>(); + ApplicationExtension::try_new( + id, + 1, + serde_json::json!({"kind":"application_gateway","bindings":bindings}), + ) + } +} diff --git a/src/gateway/auth.rs b/src/gateway/auth.rs new file mode 100644 index 000000000..d24784459 --- /dev/null +++ b/src/gateway/auth.rs @@ -0,0 +1,92 @@ +use super::{Credentials, RequestContext}; +use std::future::Future; + +/// Authentication/admission failures carry no credentials or provider internals. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthError { + /// Missing, invalid, revoked or expired required credentials (401). + Unauthorized, + /// Authenticated but forbidden by the route policy (403). + Forbidden, + /// Provider cannot establish current identity (503); never serve stale data. + Unavailable, +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Unauthorized => "unauthorized", + Self::Forbidden => "forbidden", + Self::Unavailable => "identity provider unavailable", + }) + } +} +impl std::error::Error for AuthError {} + +/// Replaceable credential validator/session provider. Implementations may use +/// local Worker handles; native implementations may return Send futures. +/// Never trust caller identity or forwarded-host headers. Session providers +/// delegate refresh/callback/logout to their existing auth lifecycle handlers. +pub trait AuthProvider { + /// Validate current credentials, returning anonymous only when credentials + /// are absent (or the provider explicitly recognizes an anonymous session). + fn authenticate( + &self, + credentials: &Credentials, + ) -> impl Future>; +} + +/// Built-in route admission; applications can add policies in their adapter. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Admission { + /// Anonymous callers allowed; invalid credentials still fail authentication. + Public, + /// Any current authenticated subject. + Authenticated, + /// A provider-mapped role is required. + Role(String), +} + +impl Admission { + /// Apply after provider authentication with a host-supplied Unix clock. + /// Expiry is enforced on public routes too; it cannot downgrade to anonymous. + pub fn check(&self, context: &RequestContext, now: u64) -> Result<(), AuthError> { + let identity = context.identity(); + if identity.is_some_and(|identity| identity.expires_at() <= now) { + return Err(AuthError::Unauthorized); + } + match self { + Self::Public => Ok(()), + Self::Authenticated => identity.map(|_| ()).ok_or(AuthError::Unauthorized), + Self::Role(role) => { + let identity = identity.ok_or(AuthError::Unauthorized)?; + if identity.roles().contains(role) { + Ok(()) + } else { + Err(AuthError::Forbidden) + } + } + } + } +} + +/// Headers a public ingress must remove before delegating to a backend or auth +/// handler. Reconstruct public-origin headers from configured origin only. +/// Deployments must additionally strip their own custom identity/secret names. +pub fn is_untrusted_identity_header(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + matches!( + name.as_str(), + "forwarded" + | "x-forwarded-host" + | "x-forwarded-proto" + | "x-forwarded-port" + | "x-forwarded-for" + | "x-real-ip" + | "cf-connecting-ip" + | "cf-access-jwt-assertion" + | "x-user-id" + | "x-role" + | "x-roles" + ) || name.starts_with("x-hasura-") +} diff --git a/src/gateway/config.rs b/src/gateway/config.rs new file mode 100644 index 000000000..e1a016306 --- /dev/null +++ b/src/gateway/config.rs @@ -0,0 +1,331 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::route::{normalize_path, Route, RoutePath, SelectedRoute}; + +/// Maximum declared routes in one gateway configuration. +pub const MAX_ROUTES: usize = 256; +/// Maximum runtime bindings in one gateway configuration. +pub const MAX_BINDINGS: usize = 256; +/// Maximum bytes in route, binding, policy and schema-extension identifiers. +pub const MAX_ID_BYTES: usize = 256; + +/// Invalid gateway configuration or request metadata. Errors never echo URLs, +/// headers or credentials supplied by a caller. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GatewayError(pub(crate) &'static str); + +impl fmt::Display for GatewayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } +} + +impl std::error::Error for GatewayError {} + +pub(crate) fn validate_id(id: &str) -> Result<(), GatewayError> { + if id.len() > MAX_ID_BYTES { + return Err(GatewayError("gateway identifier exceeds size bound")); + } + crate::application::LogicalId::try_new("gateway identifier", id) + .map(|_| ()) + .map_err(|_| GatewayError("invalid gateway identifier")) +} + +/// Optional optimizations. These flags select adapter capabilities, never +/// authorize reuse. Origin-validated identity and freshness are still required. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DeliveryCapabilities { + /// Complete query snapshot reuse with origin validation. + pub snapshots: bool, + /// Equivalent concurrent query execution sharing. + pub coalescing: bool, + /// Equivalent upstream live subscription sharing. + pub live_sharing: bool, +} + +/// Explicit GraphQL surface selection at the bound executor. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GraphqlCapabilities { + /// Expose command mutations. + pub commands: bool, + /// Expose queries. + pub queries: bool, + /// Expose live operations. + pub live: bool, +} + +/// Location of the composed executor. Remote requests remain whole operations. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum GraphqlExecutor { + /// Adapter-owned executor in this process. + Embedded, + /// Complete remote executor at this configured HTTP(S) origin. The adapter + /// owns endpoint-path configuration and credential trust. + Remote { + /// Absolute origin without userinfo, path, query or fragment. + origin: String, + }, +} + +/// Kind of adapter resource explicitly selected by configuration. No handles, +/// secrets, native futures or database pools are part of this declaration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum BindingKind { + /// Application HTTP handler or delegated auth lifecycle handler. + Handler, + /// Authentication/admission policy implemented by the selected adapter. + Admission, + /// Native or platform assets; route admission precedes serving. + Assets, + /// UI server at a configured origin; never a request-selected proxy URL. + UiProxy { + /// Absolute HTTP(S) origin without userinfo, path, query or fragment. + origin: String, + }, + /// GraphQL executor and its explicitly selected capabilities. + Graphql { + /// Local or complete remote execution. + executor: GraphqlExecutor, + /// Surface components implemented by the executor. + capabilities: GraphqlCapabilities, + /// Independent optional delivery mounts; all disabled by default. + delivery: DeliveryCapabilities, + /// Adapter registration identifiers for local schema extensions. + /// Remote schemas must install their own fields at the remote executor; + /// declaring local extensions with a remote binding is rejected. + schema_extensions: Vec, + }, +} + +/// Named adapter binding. Identifiers are shared with route targets/admission. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Binding { + /// Unique configuration identifier. + pub id: String, + /// Required adapter capability. + pub kind: BindingKind, +} + +impl Binding { + /// Declare a binding. [`GatewayConfig::build`] validates all declarations. + pub fn new(id: impl Into, kind: BindingKind) -> Self { + Self { + id: id.into(), + kind, + } + } +} + +/// Portable configuration, validated atomically before a gateway is usable. +/// Declaration order does not change routing. Deserialization alone does not +/// validate references or grant authority; always call [`Self::build`]. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GatewayConfig { + /// Explicit resource declarations. Merely linking an adapter adds nothing. + pub bindings: Vec, + /// Explicit path ownership and admission chains. + pub routes: Vec, +} + +/// Validated immutable route and binding inventory. Creating this value performs +/// no I/O or runtime allocation other than its bounded configuration storage. +#[derive(Clone, Debug)] +pub struct Gateway { + routes: Vec, + bindings: BTreeMap, +} + +impl GatewayConfig { + /// Validate declarations, references, bounds and deterministic ownership. + /// + /// # Errors + /// Rejects duplicate/ambiguous paths or IDs, missing/wrong-kind bindings, + /// invalid origins, malformed paths and incompatible capability selections. + pub fn build(self) -> Result { + if self.routes.len() > MAX_ROUTES || self.bindings.len() > MAX_BINDINGS { + return Err(GatewayError( + "gateway configuration exceeds inventory bounds", + )); + } + let mut bindings = BTreeMap::new(); + for binding in self.bindings { + validate_id(&binding.id)?; + validate_binding(&binding.kind)?; + if bindings.insert(binding.id.clone(), binding).is_some() { + return Err(GatewayError("duplicate gateway binding identifier")); + } + } + let mut route_ids = BTreeSet::new(); + let mut paths = BTreeSet::new(); + let mut routes = self.routes; + for route in &mut routes { + route.validate()?; + if !route_ids.insert(route.id.clone()) { + return Err(GatewayError("duplicate gateway route identifier")); + } + if !paths.insert(route.path.clone()) { + return Err(GatewayError("duplicate gateway path owner")); + } + let target = bindings + .get(&route.target) + .ok_or(GatewayError("gateway route references a missing binding"))?; + if matches!(target.kind, BindingKind::Admission) { + return Err(GatewayError("admission binding cannot execute a route")); + } + for policy in &route.admission { + if !matches!( + bindings.get(policy).map(|b| &b.kind), + Some(BindingKind::Admission) + ) { + return Err(GatewayError( + "route admission requires an admission binding", + )); + } + } + } + // Stable declaration order aids adapters and diagnostics. Selection below + // ranks exact paths and prefix length rather than registration order. + routes.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(Gateway { routes, bindings }) + } +} + +impl Gateway { + /// The validated route inventory, ordered by identifier. + pub fn routes(&self) -> &[Route] { + &self.routes + } + + /// Deterministic logical binding inventory for explicit application mounts. + pub fn bindings(&self) -> impl ExactSizeIterator { + self.bindings.values() + } + + /// Resolve a configured resource without accepting a caller-selected URL. + pub fn binding(&self, id: &str) -> Option<&Binding> { + self.bindings.get(id) + } + + /// Resolve path ownership before checking the HTTP method. An unsupported + /// method stays owned by its selected route; it never falls through to UI. + /// Prefixes match at segment boundaries. Percent-encoded path aliases are + /// decoded once; encoded separators and ambiguous traversal are rejected. + /// + /// # Errors + /// Rejects malformed methods/targets and bounded or ambiguous path input. + pub fn select( + &self, + method: &str, + target: &str, + ) -> Result>, GatewayError> { + super::route::validate_method(method)?; + if target.len() > 16 * 1024 + || target.contains('#') + || target.bytes().any(|b| b.is_ascii_control()) + { + return Err(GatewayError("invalid gateway request target")); + } + let path = normalize_path(target.split('?').next().unwrap_or_default())?; + let route = self + .routes + .iter() + .filter(|r| r.path.matches(&path)) + .max_by_key(|r| match &r.path { + RoutePath::Exact(p) => (true, p.len()), + RoutePath::Prefix(p) => (false, p.len()), + }); + Ok(route.map(|route| SelectedRoute { + route, + binding: &self.bindings[&route.target], + method_allowed: route.methods.allows(method), + })) + } +} + +fn validate_binding(kind: &BindingKind) -> Result<(), GatewayError> { + match kind { + BindingKind::UiProxy { origin } => validate_origin(origin)?, + BindingKind::Graphql { + executor, + capabilities, + delivery, + schema_extensions, + } => { + if !capabilities.commands && !capabilities.queries && !capabilities.live { + return Err(GatewayError( + "GraphQL binding must select a surface capability", + )); + } + if ((delivery.snapshots || delivery.coalescing) && !capabilities.queries) + || (delivery.live_sharing && !capabilities.live) + { + return Err(GatewayError( + "delivery mount requires its query or live capability", + )); + } + if let GraphqlExecutor::Remote { origin } = executor { + validate_origin(origin)?; + if !schema_extensions.is_empty() { + return Err(GatewayError( + "remote schema extensions must be registered at the remote executor", + )); + } + } + if schema_extensions.len() > MAX_BINDINGS { + return Err(GatewayError("too many schema extensions")); + } + let mut seen = BTreeSet::new(); + for extension in schema_extensions { + validate_id(extension)?; + if !seen.insert(extension) { + return Err(GatewayError("duplicate schema extension identifier")); + } + } + } + BindingKind::Handler | BindingKind::Admission | BindingKind::Assets => {} + } + Ok(()) +} + +pub(crate) fn validate_origin(origin: &str) -> Result<(), GatewayError> { + let invalid = GatewayError( + "binding requires an absolute HTTP(S) origin without credentials, path, query or fragment", + ); + if origin.len() > 2048 + || origin + .bytes() + .any(|b| b.is_ascii_whitespace() || b.is_ascii_control() || b == b'\\') + { + return Err(invalid); + } + let parsed = url::Url::parse(origin).map_err(|_| invalid.clone())?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err(invalid); + } + // Check the original authority/path as well: URL parsing can erase dot + // segments or repair missing slashes, neither is an origin declaration. + let authority = origin.split_once("://").ok_or(invalid.clone())?.1; + if authority.trim_end_matches('/').contains('/') + || authority.contains('@') + || authority.ends_with("//") + { + return Err(invalid); + } + Ok(()) +} diff --git a/src/gateway/context.rs b/src/gateway/context.rs new file mode 100644 index 000000000..ab3505877 --- /dev/null +++ b/src/gateway/context.rs @@ -0,0 +1,146 @@ +use super::GatewayError; +use std::fmt; + +/// Credentials received from a client. Possession is not authentication. +/// Providers must reject invalid credentials even on otherwise public routes. +#[derive(Clone, Default)] +pub struct Credentials { + /// Raw Authorization value; never populated from a cookie by the gateway. + pub authorization: Option, + /// Raw Cookie value, for a configured session provider only. + pub cookie: Option, +} + +impl fmt::Debug for Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Credentials") + .field("authorization_present", &self.authorization.is_some()) + .field("cookie_present", &self.cookie.is_some()) + .finish() + } +} + +/// A credential explicitly supplied by the configured provider for backend +/// validation. Gateway admission never grants backend authorization. +#[derive(Clone, Default)] +pub enum BackendCredential { + /// No backend credential. The backend applies its anonymous policy. + #[default] + None, + /// An access token validated or obtained by the provider. Backends still + /// validate issuer, audience, expiry and their own authorization policy. + Bearer(String), +} + +impl fmt::Debug for BackendCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::None => "None", + Self::Bearer(_) => "Bearer([redacted])", + }) + } +} + +/// Provider-authenticated identity shared across UI, API and custom routes. +/// This is an in-process assertion, never a deserializable client proof. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Identity { + authority: String, + subject: String, + roles: Vec, + expires_at: u64, +} + +impl Identity { + /// Called by a trusted provider after validating credentials. `expires_at` + /// is Unix seconds, including the provider's session expiry ceiling. + pub fn verified( + authority: impl Into, + subject: impl Into, + roles: Vec, + expires_at: u64, + ) -> Result { + let authority = authority.into(); + let subject = subject.into(); + if [&authority, &subject] + .iter() + .any(|s| s.is_empty() || s.len() > 4096 || s.chars().any(char::is_control)) + || roles.len() > 128 + || roles + .iter() + .any(|s| s.is_empty() || s.len() > 256 || s.chars().any(char::is_control)) + { + return Err(GatewayError("invalid provider identity")); + } + let mut roles = roles; + roles.sort(); + roles.dedup(); + Ok(Self { + authority, + subject, + roles, + expires_at, + }) + } + /// Authority under which the subject is unique. + pub fn authority(&self) -> &str { + &self.authority + } + /// Authenticated subject, not a caller-supplied identity header. + pub fn subject(&self) -> &str { + &self.subject + } + /// Provider-mapped roles. The backend independently authorizes operations. + pub fn roles(&self) -> &[String] { + &self.roles + } + /// Unix second at which this assertion ceases to admit new work. + pub fn expires_at(&self) -> u64 { + self.expires_at + } +} + +/// Shared admission context. Re-authenticate every new consumer before any +/// cache/flight/live join; this value is not an authorization cache. +#[derive(Clone, Debug)] +pub struct RequestContext { + identity: Option, + provider_revision: String, + backend: BackendCredential, +} + +impl RequestContext { + /// Create an assertion from a trusted provider. Bump `provider_revision` + /// when replacing provider configuration/policy to prevent reuse across it. + pub fn from_provider( + identity: Option, + provider_revision: impl Into, + backend: BackendCredential, + ) -> Result { + let provider_revision = provider_revision.into(); + if provider_revision.is_empty() + || provider_revision.len() > 256 + || provider_revision.chars().any(char::is_control) + || (identity.is_none() && !matches!(backend, BackendCredential::None)) + { + return Err(GatewayError("invalid provider context")); + } + Ok(Self { + identity, + provider_revision, + backend, + }) + } + /// Authenticated identity, or anonymous. + pub fn identity(&self) -> Option<&Identity> { + self.identity.as_ref() + } + /// Provider/policy configuration generation; not a credential. + pub fn provider_revision(&self) -> &str { + &self.provider_revision + } + /// Explicit downstream credential, never derived from asserted headers. + pub fn backend_credential(&self) -> &BackendCredential { + &self.backend + } +} diff --git a/src/gateway/delivery/coordinator.rs b/src/gateway/delivery/coordinator.rs new file mode 100644 index 000000000..298034d71 --- /dev/null +++ b/src/gateway/delivery/coordinator.rs @@ -0,0 +1,127 @@ +use super::DeliveryError; +use std::collections::BTreeMap; + +/// Validated shared group bounds; construct through flight or live limits. +pub struct CoordinatorLimits { + pub(crate) groups: usize, + pub(crate) consumers: usize, + pub(crate) deadline_ms: u64, +} + +/// Adapter-owned consumer ticket. Release exactly once when that consumer +/// finishes/cancels. Stale tickets cannot release a newer same-key generation. +#[derive(Debug)] +pub struct CoordinatorTicket { + key: K, + generation: u64, +} +impl CoordinatorTicket { + /// Flight generation used to bind a runtime future to portable bookkeeping. + pub fn generation(&self) -> u64 { + self.generation + } +} +struct Group { + generation: u64, + consumers: usize, + deadline: u64, +} +/// Portable bounded refcount/deadline bookkeeping. Runtime futures, clocks, +/// sockets and cancellation guards belong to native/Worker adapters. +pub struct CoordinatorRegistry { + limits: CoordinatorLimits, + groups: BTreeMap, + next: u64, +} +impl CoordinatorRegistry { + /// Allocate empty bookkeeping only when query coalescing is selected. + pub fn new( + limits: impl TryInto, + ) -> Result { + let limits = limits.try_into()?; + Ok(Self { + limits, + groups: BTreeMap::new(), + next: 0, + }) + } + /// Join/create; the boolean identifies the one upstream execution owner. + /// `now_ms` is the adapter's monotonic clock, not a causal data clock. + pub fn join( + &mut self, + key: K, + now_ms: u64, + ) -> Result<(CoordinatorTicket, bool), DeliveryError> { + self.expire(now_ms); + if let Some(group) = self.groups.get_mut(&key) { + if group.consumers >= self.limits.consumers { + return Err(DeliveryError::Unavailable); + } + group.consumers += 1; + return Ok(( + CoordinatorTicket { + key, + generation: group.generation, + }, + false, + )); + } + if self.groups.len() >= self.limits.groups { + return Err(DeliveryError::Unavailable); + } + self.next = self.next.checked_add(1).ok_or(DeliveryError::Unavailable)?; + self.groups.insert( + key.clone(), + Group { + generation: self.next, + consumers: 1, + deadline: now_ms.saturating_add(self.limits.deadline_ms), + }, + ); + Ok(( + CoordinatorTicket { + key, + generation: self.next, + }, + true, + )) + } + /// Release one consumer; true means the last consumer left that generation. + pub fn leave(&mut self, ticket: CoordinatorTicket) -> bool { + let Some(group) = self.groups.get_mut(&ticket.key) else { + return false; + }; + if group.generation != ticket.generation { + return false; + } + group.consumers -= 1; + if group.consumers == 0 { + self.groups.remove(&ticket.key); + true + } else { + false + } + } + /// Forget expired groups; adapters enforce matching upstream deadlines. + pub fn expire(&mut self, now_ms: u64) { + self.groups.retain(|_, group| group.deadline > now_ms); + } + /// Active group count. + pub fn len(&self) -> usize { + self.groups.len() + } + /// Whether no group has an admitted consumer. + pub fn is_empty(&self) -> bool { + self.groups.is_empty() + } + /// Total current admitted consumers. + pub fn consumers(&self) -> usize { + self.groups.values().map(|group| group.consumers).sum() + } + /// Check whether a runtime future still belongs to an active generation. + pub fn contains_generation(&self, generation: u64) -> bool { + self.groups + .values() + .any(|group| group.generation == generation) + } +} diff --git a/src/gateway/delivery/flight.rs b/src/gateway/delivery/flight.rs new file mode 100644 index 000000000..68f8ef34f --- /dev/null +++ b/src/gateway/delivery/flight.rs @@ -0,0 +1,92 @@ +use super::{canonical_json, DeliveryError, FreshnessContext, OriginAdmission}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Exact admitted operation, dependency version and freshness requirement. +/// Conservative equality prevents stronger late consumers joining older work. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct FlightKey(String); +impl FlightKey { + /// Authenticate at the origin before constructing a flight identity. + pub fn admitted( + admission: &OriginAdmission, + request: &serde_json::Value, + freshness: Option<&FreshnessContext>, + now: u64, + ) -> Result { + admission.bind(request, now)?; + if crate::gateway::graphql::operation_kind( + request["query"].as_str().ok_or(DeliveryError::Ineligible)?, + request["operationName"].as_str(), + ) != Ok(crate::gateway::graphql::OperationKind::Query) + { + return Err(DeliveryError::Ineligible); + } + if let Some(context) = freshness { + context.bind(&admission.identity)?; + } + let bytes = canonical_json(&serde_json::json!([ + "query-flight-v1", + admission.key, + admission.validator, + freshness + ]))?; + Ok(Self(format!("{:x}", Sha256::digest(bytes)))) + } +} +/// Explicit coordinator bounds, independent of snapshot cache activation. +#[derive(Clone, Copy, Debug)] +pub struct FlightLimits { + /// Maximum simultaneously active exact-scope groups. + pub groups: usize, + /// Maximum admitted consumers in one group. + pub consumers: usize, + /// Maximum operation duration in milliseconds. + pub deadline_ms: u64, + /// Maximum complete response bytes retained by one group. + pub response_bytes: usize, +} +impl Default for FlightLimits { + fn default() -> Self { + Self { + groups: 256, + consumers: 1024, + deadline_ms: 30000, + response_bytes: 1024 * 1024, + } + } +} +impl FlightLimits { + /// Validate resource bounds before allocating runtime coordination. + pub fn validate(&self) -> Result<(), DeliveryError> { + if self.groups == 0 + || self.groups > 4096 + || self.consumers == 0 + || self.consumers > 65536 + || self.deadline_ms == 0 + || self.deadline_ms > 300000 + || self.response_bytes == 0 + || self.response_bytes > 16 * 1024 * 1024 + { + Err(DeliveryError::InvalidContext) + } else { + Ok(()) + } + } +} +/// Portable query registry using the common bounded coordinator. +pub type FlightRegistry = super::CoordinatorRegistry; +/// One admitted query consumer's cancellation/generation ticket. +pub type FlightTicket = super::CoordinatorTicket; +impl TryFrom for super::CoordinatorLimits { + type Error = DeliveryError; + fn try_from(limits: FlightLimits) -> Result { + limits.validate()?; + Ok(Self { + groups: limits.groups, + consumers: limits.consumers, + deadline_ms: limits.deadline_ms, + }) + } +} diff --git a/src/gateway/delivery/freshness.rs b/src/gateway/delivery/freshness.rs new file mode 100644 index 000000000..3d13a3659 --- /dev/null +++ b/src/gateway/delivery/freshness.rs @@ -0,0 +1,288 @@ +use super::{DeliveryError, OriginIdentity}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Maximum combined number of pending dependencies and retained floors. +pub const MAX_FRESHNESS_ITEMS: usize = 256; +/// Maximum serialized causal context size. +pub const MAX_FRESHNESS_BYTES: usize = 64 * 1024; + +/// Compiler-known dependency inventory. Unknown coverage overlaps everything +/// within the already authorized surface, including empty lists and counts. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Dependencies { + /// Whether the compiler accounts for every dependency. + pub complete: bool, + /// Model names including filter and join dependencies. + pub models: BTreeSet, + /// Relationship identities affecting membership. + pub relationships: BTreeSet, +} +impl Dependencies { + /// Conservatively test dependency overlap without inspecting result rows. + pub fn overlaps(&self, other: &Self) -> bool { + !self.complete + || !other.complete + || !self.models.is_disjoint(&other.models) + || !self.relationships.is_disjoint(&other.relationships) + } + /// Validate bounded contract values before accepting external data. + pub fn validate(&self) -> Result<(), DeliveryError> { + if self.models.len() + self.relationships.len() > MAX_FRESHNESS_ITEMS { + return Err(DeliveryError::InvalidContext); + } + for name in self.models.iter().chain(&self.relationships) { + bounded(name)?; + } + Ok(()) + } +} + +/// Opaque scope tokens keep incomparable record/index obligations separate. +/// This carries existing evidence, never a new public/global partition clock. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Minimum { + /// Comparable evidence for one record. + Record { + /// Model owning this record evidence. + model: String, + #[serde(rename = "scopeToken")] + /// Opaque origin-issued comparison scope. + scope_token: String, + /// Decimal record incarnation. + incarnation: String, + /// Decimal revision within the incarnation. + revision: String, + }, + /// Comparable evidence for an index. + Index { + /// Projection owning this index evidence. + projection: String, + #[serde(rename = "scopeToken")] + /// Opaque origin-issued comparison scope. + scope_token: String, + /// Decimal checkpoint within this opaque scope. + position: String, + }, +} +impl Minimum { + /// Validate bounded contract values before accepting external data. + pub fn validate(&self) -> Result<(), DeliveryError> { + match self { + Self::Record { + model, + scope_token, + incarnation, + revision, + } => { + bounded(model)?; + bounded(scope_token)?; + decimal(incarnation)?; + decimal(revision)?; + } + Self::Index { + projection, + scope_token, + position, + } => { + bounded(projection)?; + bounded(scope_token)?; + decimal(position)?; + } + } + Ok(()) + } + /// Whether this same-scope evidence is at least the required revision. + pub fn covers(&self, required: &Self) -> bool { + match (self, required) { + ( + Self::Record { + model: a, + scope_token: sa, + incarnation: ia, + revision: ra, + }, + Self::Record { + model: b, + scope_token: sb, + incarnation: ib, + revision: rb, + }, + ) => { + a == b + && sa == sb + && (compare(ia, ib).is_gt() || (ia == ib && !compare(ra, rb).is_lt())) + } + ( + Self::Index { + projection: a, + scope_token: sa, + position: pa, + }, + Self::Index { + projection: b, + scope_token: sb, + position: pb, + }, + ) => a == b && sa == sb && !compare(pa, pb).is_lt(), + _ => false, + } + } +} + +/// An authenticated request binds these client hints to the origin identity. +/// Hints may force primary reads; they are never proof that a command committed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FreshnessContext { + /// Wire contract version; must be one. + pub version: u32, + /// Exact schema generation. + pub schema_hash: String, + /// Exact protocol generation. + pub protocol_hash: String, + /// Origin policy generation. + pub authorization_generation: String, + /// Origin-issued subject and authorization scope. + pub cache_scope: String, + /// Unconfirmed effect dependencies, separate from minima. + pub pending: Vec, + /// Retained confirmed evidence, including incomparable scopes. + pub minimum: Vec, +} +impl FreshnessContext { + /// Decode a bounded context and reject malformed or unknown fields. + pub fn parse(value: &serde_json::Value) -> Result { + if serde_json::to_vec(value) + .map_err(|_| DeliveryError::InvalidContext)? + .len() + > MAX_FRESHNESS_BYTES + { + return Err(DeliveryError::InvalidContext); + } + let context: Self = + serde_json::from_value(value.clone()).map_err(|_| DeliveryError::InvalidContext)?; + if context.version != 1 + || context.pending.len() + context.minimum.len() > MAX_FRESHNESS_ITEMS + { + return Err(DeliveryError::InvalidContext); + } + for part in [ + &context.schema_hash, + &context.protocol_hash, + &context.authorization_generation, + &context.cache_scope, + ] { + bounded(part)?; + } + for dependencies in &context.pending { + dependencies.validate()?; + } + for minimum in &context.minimum { + minimum.validate()?; + } + Ok(context) + } + /// Compare client context with freshly authenticated origin authority. + pub fn bind(&self, identity: &OriginIdentity) -> Result<(), DeliveryError> { + identity.validate()?; + if self.schema_hash != identity.schema_hash + || self.protocol_hash != identity.protocol_hash + || self.authorization_generation != identity.authorization_generation + || self.cache_scope != identity.cache_scope + { + return Err(DeliveryError::ScopeChanged); + } + Ok(()) + } + /// Whether any unconfirmed effect can affect these dependencies. + pub fn pending_overlaps(&self, dependencies: &Dependencies) -> bool { + self.pending + .iter() + .any(|pending| pending.overlaps(dependencies)) + } + /// Require every retained floor to have comparable covering evidence. + pub fn satisfied_by(&self, evidence: &[Minimum]) -> bool { + self.minimum + .iter() + .all(|required| evidence.iter().any(|candidate| candidate.covers(required))) + } + /// Retain maximal comparable floors without discarding incomparable scopes. + pub fn observe( + &mut self, + evidence: impl IntoIterator, + ) -> Result<(), DeliveryError> { + let mut next = self.minimum.clone(); + for candidate in evidence { + candidate.validate()?; + if next.iter().any(|current| current.covers(&candidate)) { + continue; + } + next.retain(|current| !candidate.covers(current)); + next.push(candidate); + if next.len() + self.pending.len() > MAX_FRESHNESS_ITEMS { + return Err(DeliveryError::InvalidContext); + } + } + self.minimum = next; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// Origin-owned operation consistency policy. +pub enum ReadConsistency { + #[default] + /// Require an authoritative current read. + Current, + /// Explicit opt-in to stale reads subject to causal requirements. + StaleTolerant, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Backend chosen without assuming replay freshness from a pool. +pub enum ReadTarget { + /// Authoritative read-model backend. + Primary, + /// Explicitly configured stale-tolerant read backend. + Replica, +} +/// No replica-proof adapter is installed by the initial framework router. +/// Even an unrelated retained floor conservatively uses primary; pending work +/// with known disjoint dependencies does not change an explicit stale policy. +pub fn read_target( + policy: ReadConsistency, + dependencies: &Dependencies, + freshness: Option<&FreshnessContext>, +) -> ReadTarget { + if policy == ReadConsistency::Current + || freshness.is_some_and(|f| f.pending_overlaps(dependencies) || !f.minimum.is_empty()) + { + ReadTarget::Primary + } else { + ReadTarget::Replica + } +} +fn bounded(value: &str) -> Result<(), DeliveryError> { + if value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control) { + Err(DeliveryError::InvalidContext) + } else { + Ok(()) + } +} +fn decimal(value: &str) -> Result<(), DeliveryError> { + if value.is_empty() + || value.len() > 20 + || !value.bytes().all(|b| b.is_ascii_digit()) + || (value.len() > 1 && value.starts_with('0')) + || value.parse::().is_err() + { + Err(DeliveryError::InvalidContext) + } else { + Ok(()) + } +} +fn compare(a: &str, b: &str) -> std::cmp::Ordering { + a.len().cmp(&b.len()).then_with(|| a.cmp(b)) +} diff --git a/src/gateway/delivery/identity.rs b/src/gateway/delivery/identity.rs new file mode 100644 index 000000000..3668e1b63 --- /dev/null +++ b/src/gateway/delivery/identity.rs @@ -0,0 +1,136 @@ +use super::DeliveryError; +use crate::gateway::graphql::{operation_kind, OperationKind, MAX_DOCUMENT_BYTES}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Values established by the authenticated origin, never by a browser scope +/// claim. The cache scope includes subject and all result-affecting authority. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OriginIdentity { + /// Origin-selected application surface. + pub application: String, + /// Configured origin service identity. + pub endpoint: String, + /// Exact schema generation. + pub schema_hash: String, + /// Exact protocol generation. + pub protocol_hash: String, + /// Origin policy generation. + pub authorization_generation: String, + /// Origin-issued subject and authorization scope. + pub cache_scope: String, +} +impl OriginIdentity { + /// Validate bounded contract values before accepting external data. + pub fn validate(&self) -> Result<(), DeliveryError> { + for part in [ + &self.application, + &self.endpoint, + &self.schema_hash, + &self.protocol_hash, + &self.authorization_generation, + &self.cache_scope, + ] { + if part.is_empty() || part.len() > 1024 || part.chars().any(char::is_control) { + return Err(DeliveryError::InvalidContext); + } + } + Ok(()) + } +} + +/// Exact operation identity bound to an authenticated origin response. +/// Different HTTP/query and live documents intentionally have different keys. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OperationKey(String); +impl OperationKey { + /// Call only after origin admission and trusted operation eligibility. + pub fn from_origin( + identity: &OriginIdentity, + request: &serde_json::Value, + ) -> Result { + identity.validate()?; + let document = request + .get("query") + .and_then(|v| v.as_str()) + .ok_or(DeliveryError::Ineligible)?; + let name = match request.get("operationName") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(name)) => Some(name.as_str()), + _ => return Err(DeliveryError::Ineligible), + }; + if !matches!( + operation_kind(document, name), + Ok(OperationKind::Query | OperationKind::Subscription) + ) { + return Err(DeliveryError::Ineligible); + } + let variables = request + .get("variables") + .filter(|v| !v.is_null()) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + if !variables.is_object() { + return Err(DeliveryError::Ineligible); + } + let mut extensions = request + .get("extensions") + .filter(|v| !v.is_null()) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + // Freshness is checked separately for each consumer; it is not identity. + let object = extensions + .as_object_mut() + .ok_or(DeliveryError::Ineligible)?; + object.remove("gatewayFreshness"); + object.remove("gatewayDelivery"); + let bytes = canonical_json(&serde_json::json!([ + identity, document, name, variables, extensions + ]))?; + Ok(Self(format!("{:x}", Sha256::digest(bytes)))) + } + /// Stable digest used only after admission. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Bounded canonical JSON: objects sort by key, arrays preserve order. +/// Used for keys only, never a rewrite of the executed GraphQL request. +pub fn canonical_json(value: &serde_json::Value) -> Result, DeliveryError> { + fn ordered( + value: &serde_json::Value, + depth: usize, + budget: &mut usize, + ) -> Result { + if depth > 64 || *budget == 0 { + return Err(DeliveryError::InvalidContext); + } + *budget -= 1; + Ok(match value { + serde_json::Value::Object(map) => { + let sorted: std::collections::BTreeMap<_, _> = map.iter().collect(); + let mut result = serde_json::Map::new(); + for (key, value) in sorted { + result.insert(key.clone(), ordered(value, depth + 1, budget)?); + } + serde_json::Value::Object(result) + } + serde_json::Value::Array(values) => serde_json::Value::Array( + values + .iter() + .map(|v| ordered(v, depth + 1, budget)) + .collect::>()?, + ), + value => value.clone(), + }) + } + let bytes = serde_json::to_vec(&ordered(value, 0, &mut 16384)?) + .map_err(|_| DeliveryError::InvalidContext)?; + if bytes.len() > MAX_DOCUMENT_BYTES * 2 { + return Err(DeliveryError::InvalidContext); + } + Ok(bytes) +} diff --git a/src/gateway/delivery/live.rs b/src/gateway/delivery/live.rs new file mode 100644 index 000000000..9ada4bd5a --- /dev/null +++ b/src/gateway/delivery/live.rs @@ -0,0 +1,244 @@ +use super::{ + canonical_json, CoordinatorLimits, DeliveryError, FreshnessContext, OperationKey, + OriginAdmission, SnapshotResponse, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Authenticated live operation identity and initial replay requirements. +/// HTTP query documents can never construct this key. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct LiveKey { + base: String, + initial: String, + fork: u64, +} +impl LiveKey { + /// Construct only after fresh origin admission for this exact consumer. + pub fn admitted( + admission: &OriginAdmission, + request: &serde_json::Value, + freshness: Option<&FreshnessContext>, + now: u64, + ) -> Result { + admission.bind(request, now)?; + if crate::gateway::graphql::operation_kind( + request["query"].as_str().ok_or(DeliveryError::Ineligible)?, + request["operationName"].as_str(), + ) != Ok(crate::gateway::graphql::OperationKind::Subscription) + { + return Err(DeliveryError::Ineligible); + } + if let Some(freshness) = freshness { + freshness.bind(&admission.identity)?; + } + let mut base = request.clone(); + if let Some(extensions) = base + .get_mut("extensions") + .and_then(serde_json::Value::as_object_mut) + { + if let Some(distributed) = extensions + .get_mut("distributed") + .and_then(serde_json::Value::as_object_mut) + { + distributed.remove("resume"); + if distributed.is_empty() { + extensions.remove("distributed"); + } + } + } + let base = OperationKey::from_origin(&admission.identity, &base)? + .as_str() + .to_owned(); + let initial = canonical_json(&serde_json::json!([ + "live-join-v1", + admission.key, + admission.validator, + freshness + ]))?; + Ok(Self { + base, + initial: format!("{:x}", Sha256::digest(initial)), + fork: 0, + }) + } + /// Same exact operation/scope may hand off only after comparable cursor + /// equality. Different resume requests are not automatically initial joins. + pub fn same_operation(&self, other: &Self) -> bool { + self.base == other.base + } + /// Independent replay generation while waiting for a safe handoff. + pub fn fork(&self, nonce: u64) -> Self { + Self { + fork: nonce, + ..self.clone() + } + } + /// Exact initial request compatibility, ignoring runtime generation. + pub fn same_initial(&self, other: &Self) -> bool { + self.base == other.base && self.initial == other.initial + } +} +/// Bounded per-coordinator and per-consumer live resources. +#[derive(Clone, Copy, Debug)] +pub struct LiveLimits { + /// Maximum active upstream groups including independent replays. + pub groups: usize, + /// Maximum logical consumers sharing one group. + pub consumers: usize, + /// Maximum pending full frames for each consumer. + pub queue_frames: usize, + /// Maximum bytes in one full data/protocol frame. + pub frame_bytes: usize, + /// Maximum retained history frames for exact initial replay. + pub history_frames: usize, + /// Maximum upstream group lifetime; reconnect reauthenticates. + pub lifetime_ms: u64, +} +impl Default for LiveLimits { + fn default() -> Self { + Self { + groups: 256, + consumers: 1024, + queue_frames: 16, + frame_bytes: 1024 * 1024, + history_frames: 8, + lifetime_ms: 3600000, + } + } +} +impl LiveLimits { + /// Validate all bounds before mounting live coordination. + pub fn validate(&self) -> Result<(), DeliveryError> { + if self.groups == 0 + || self.groups > 4096 + || self.consumers == 0 + || self.consumers > 65536 + || self.queue_frames == 0 + || self.queue_frames > 1024 + || self.frame_bytes == 0 + || self.frame_bytes > 16 * 1024 * 1024 + || self.history_frames == 0 + || self.history_frames > self.queue_frames + || self.lifetime_ms == 0 + || self.lifetime_ms > 3600000 + { + Err(DeliveryError::InvalidContext) + } else { + Ok(()) + } + } +} +impl TryFrom for CoordinatorLimits { + type Error = DeliveryError; + fn try_from(limits: LiveLimits) -> Result { + limits.validate()?; + Ok(Self { + groups: limits.groups, + consumers: limits.consumers, + deadline_ms: limits.lifetime_ms, + }) + } +} +/// Live refcounts/deadlines use the same coordinator as query flights. +pub type LiveRegistry = super::CoordinatorRegistry; +/// One admitted live consumer's generation ticket. +pub type LiveTicket = super::CoordinatorTicket; + +/// Full origin frame, including every causal observation and checkpoint. +#[derive(Clone, Debug)] +pub struct LiveFrame { + payload: serde_json::Value, + hash: [u8; 32], + cursor: Option>, + identity: super::OriginIdentity, + operation: String, + evidence: Vec, +} +impl LiveFrame { + /// Validate exact origin authority and any supplied floors before fan-out. + pub fn from_origin( + admission: &OriginAdmission, + payload: serde_json::Value, + freshness: Option<&FreshnessContext>, + max_bytes: usize, + ) -> Result { + let bytes = serde_json::to_vec(&payload).map_err(|_| DeliveryError::Ineligible)?; + if bytes.len() > max_bytes { + return Err(DeliveryError::Unavailable); + } + let response = SnapshotResponse { + status: 200, + headers: Vec::new(), + body: bytes.clone(), + }; + if !response.live_shareable(admission, freshness) { + return Err(DeliveryError::Ineligible); + } + let evidence = response + .evidence(admission, false, true) + .ok_or(DeliveryError::Ineligible)?; + let canonical = canonical_json(&payload).unwrap_or(bytes); + let protocol = &payload["extensions"]["distributed"]; + let cursors = &protocol["live"]["cursors"]; + let cursor = if protocol["live"]["supported"] == true + && protocol["snapshot"]["indexesComparable"] == true + && cursors.as_array().is_some_and(|cursors| { + !cursors.is_empty() + && cursors.len() <= 256 + && cursors.iter().all(|cursor| { + cursor["projection"] + .as_str() + .is_some_and(|value| !value.is_empty() && value.len() <= 1024) + && cursor["position"].as_str().is_some_and(|value| { + !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && value.parse::().is_ok() + }) + && cursor["token"] + .as_str() + .is_some_and(|value| !value.is_empty() && value.len() <= 1024) + }) + }) { + Some(canonical_json(cursors)?) + } else { + None + }; + Ok(Self { + payload, + hash: Sha256::digest(canonical).into(), + cursor, + identity: admission.identity.clone(), + operation: admission.operation.clone(), + evidence, + }) + } + /// Check each consumer's independent authority and retained minima. + pub fn satisfies( + &self, + admission: &OriginAdmission, + freshness: Option<&FreshnessContext>, + ) -> bool { + self.identity == admission.identity + && self.operation == admission.operation + && freshness.is_none_or(|context| { + context.bind(&admission.identity).is_ok() && context.satisfied_by(&self.evidence) + }) + } + /// Full payload to serialize under each consumer's own transport ID. + pub fn payload(&self) -> &serde_json::Value { + &self.payload + } + /// Suppress only fully identical data plus protocol, never data alone. + pub fn same_frame(&self, other: &Self) -> bool { + self.hash == other.hash + } + /// Independent replay can hand off only at an exact proven cursor vector. + /// Adapters must also compare LiveKey::same_operation and preserve queued + /// frames before moving the consumer to the target's future stream. + pub fn same_cursor(&self, other: &Self) -> bool { + self.cursor.is_some() + && self.cursor == other.cursor + && self.payload["data"] == other.payload["data"] + } +} diff --git a/src/gateway/delivery/metrics.rs b/src/gateway/delivery/metrics.rs new file mode 100644 index 000000000..6faf15a53 --- /dev/null +++ b/src/gateway/delivery/metrics.rs @@ -0,0 +1,20 @@ +//! Bounded, identifier-free coordinator observations. Exporters choose their own +//! transport; these counters never retain subjects, documents or variables. +use serde::Serialize; + +/// Cumulative cache decisions within one coordinator lifetime. Saturating +/// counters are monotonic; restarting a coordinator starts a new series. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotMetrics { + /// A freshly origin-admitted consumer reused a complete envelope. + pub hits: u64, + /// Admitted lookups with no reusable complete envelope. + pub misses: u64, + /// Resident/filling envelopes rejected by current version, proof or generation. + pub stale_rejections: u64, + /// Fills bypassed storage, including oversized or unshareable responses. + pub fill_bypasses: u64, + /// Explicit lost-feed/rebuild invalidations, not individual SQL writes. + pub invalidations: u64, +} diff --git a/src/gateway/delivery/mod.rs b/src/gateway/delivery/mod.rs new file mode 100644 index 000000000..13f57b240 --- /dev/null +++ b/src/gateway/delivery/mod.rs @@ -0,0 +1,42 @@ +//! Portable delivery contracts. Origin authentication establishes identity; +//! client freshness hints can strengthen reads but never authorize reuse. +mod freshness; +mod identity; +pub use freshness::*; +pub use identity::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Fail-closed outcomes of admission, routing or proof checks. +pub enum DeliveryError { + /// Malformed or oversized client context. + InvalidContext, + /// Origin authority differs from the supplied context. + ScopeChanged, + /// Operation has no trusted reusable contract. + Ineligible, + /// Available snapshot cannot prove the required minimum. + Pending, + /// Authoritative origin is unavailable; stale fallback is forbidden. + Unavailable, +} +impl std::fmt::Display for DeliveryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} +impl std::error::Error for DeliveryError {} + +mod snapshot; +pub use snapshot::*; + +mod flight; +pub use flight::*; + +mod coordinator; +pub use coordinator::*; + +mod live; +pub use live::*; + +mod metrics; +pub use metrics::*; diff --git a/src/gateway/delivery/snapshot.rs b/src/gateway/delivery/snapshot.rs new file mode 100644 index 000000000..3faac1360 --- /dev/null +++ b/src/gateway/delivery/snapshot.rs @@ -0,0 +1,414 @@ +use super::{DeliveryError, FreshnessContext, Minimum, OperationKey, OriginIdentity}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Fresh authenticated origin control response. Deserialize only from the +/// configured origin, never from public request headers/body or a cached grant. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OriginAdmission { + /// Effective origin identity including subject and policy generation. + pub identity: OriginIdentity, + /// Exact admitted operation and variable identity. + pub key: OperationKey, + /// Exact transport document fingerprint in the response envelope. + pub operation: String, + /// Opaque version vector computed on the primary at this validation. + pub validator: String, + /// Origin validation time, seconds since Unix epoch. + pub validated_at: u64, + /// Credential/admission expiry; never extended by copying an entry. + pub expires_at: u64, + /// Origin-approved policy; private/current is the default. + pub policy: SnapshotPolicy, +} +impl OriginAdmission { + /// Ensure a configured origin answered this exact consumer operation. + pub fn bind(&self, request: &serde_json::Value, now: u64) -> Result<(), DeliveryError> { + self.validate(now)?; + if OperationKey::from_origin(&self.identity, request)? != self.key { + return Err(DeliveryError::ScopeChanged); + } + Ok(()) + } + /// Validate a fresh control response before lookup/join. + pub fn validate(&self, now: u64) -> Result<(), DeliveryError> { + self.identity.validate()?; + if self.key.as_str().len() != 64 + || !self + .key + .as_str() + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(DeliveryError::InvalidContext); + } + if self.validator.is_empty() + || self.validator.len() > 1024 + || self.operation.is_empty() + || self.operation.len() > 256 + || self.validated_at > now.saturating_add(5) + || self.expires_at <= now + || self.expires_at <= self.validated_at + { + return Err(DeliveryError::Unavailable); + } + if let SnapshotPolicy::Public { max_age_seconds } = self.policy { + if max_age_seconds == 0 || max_age_seconds > 86400 { + return Err(DeliveryError::InvalidContext); + } + } + Ok(()) + } +} + +/// Data staleness policy never relaxes per-consumer origin authentication. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum SnapshotPolicy { + /// Require the primary's current version vector on every hit. + #[default] + Current, + /// Explicit origin-approved public staleness, measured from validation. + Public { + /// Maximum age in seconds; copying cannot renew it. + max_age_seconds: u64, + }, +} + +/// Bounded storage limits. No cache exists unless explicitly constructed. +#[derive(Clone, Copy, Debug)] +pub struct SnapshotLimits { + /// Maximum resident entries. + pub entries: usize, + /// Maximum aggregate response bytes. + pub bytes: usize, + /// Maximum individual response size. + pub entry_bytes: usize, +} +impl Default for SnapshotLimits { + fn default() -> Self { + Self { + entries: 1024, + bytes: 16 * 1024 * 1024, + entry_bytes: 1024 * 1024, + } + } +} + +/// Complete HTTP envelope retained without reconstructing GraphQL data/proofs. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SnapshotResponse { + /// HTTP status; reusable responses must be successful. + pub status: u16, + /// End-to-end response headers, preserving duplicate values. + pub headers: Vec<(String, String)>, + /// Exact JSON response bytes from the executor. + pub body: Vec, +} +impl SnapshotResponse { + pub(super) fn evidence( + &self, + admission: &OriginAdmission, + complete: bool, + live: bool, + ) -> Option> { + if self.status != 200 + || self.headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("set-cookie") + || (name.eq_ignore_ascii_case("cache-control") + && value + .split(',') + .any(|directive| directive.trim().eq_ignore_ascii_case("no-store"))) + || (name.eq_ignore_ascii_case("vary") + && value.split(',').any(|field| field.trim() == "*")) + }) + { + return None; + } + let value: serde_json::Value = serde_json::from_slice(&self.body).ok()?; + if value.get("data").is_none() + || value["data"].is_null() + || value + .get("errors") + .is_some_and(|e| e.as_array().is_none_or(|e| !e.is_empty())) + { + return None; + } + let protocol = &value["extensions"]["distributed"]; + if protocol["protocolVersion"] != 1 + || protocol["schemaHash"] != admission.identity.schema_hash + || protocol["authorizationGeneration"] != admission.identity.authorization_generation + || protocol["cacheScope"] != admission.identity.cache_scope + || protocol["operation"] != admission.operation + || protocol.get("command").is_some() + || protocol.get("receipt").is_some() + || (!live && protocol.get("live").is_some()) + { + return None; + } + let snapshot = &protocol["snapshot"]; + if complete + && (snapshot["recordsComplete"] != true || snapshot["indexesComparable"] != true) + { + return None; + } + if complete + && (snapshot["records"].as_array().is_none() + || snapshot["indexes"].as_array().is_none()) + { + return None; + } + let mut evidence = Vec::new(); + for record in snapshot["records"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]) + { + let minimum = Minimum::Record { + model: record["model"].as_str()?.into(), + scope_token: record["scopeToken"].as_str()?.into(), + incarnation: record["incarnation"].as_str()?.into(), + revision: record["revision"].as_str()?.into(), + }; + minimum.validate().ok()?; + evidence.push(minimum); + } + for index in snapshot["indexes"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]) + { + let minimum = Minimum::Index { + projection: index["projection"].as_str()?.into(), + scope_token: index["scopeToken"].as_str()?.into(), + position: index["position"].as_str()?.into(), + }; + minimum.validate().ok()?; + evidence.push(minimum); + } + Some(evidence) + } + /// Complete admitted HTTP result may be shared in flight without requiring + /// future cache eligibility. Any supplied minima still require actual proof. + pub fn shareable( + &self, + admission: &OriginAdmission, + freshness: Option<&FreshnessContext>, + ) -> bool { + self.evidence(admission, false, false) + .is_some_and(|evidence| { + freshness.is_none_or(|context| { + context.bind(&admission.identity).is_ok() && context.satisfied_by(&evidence) + }) + }) + } + /// Validate live fan-out while retaining the origin's live envelope. + pub fn live_shareable( + &self, + admission: &OriginAdmission, + freshness: Option<&FreshnessContext>, + ) -> bool { + self.evidence(admission, false, true) + .is_some_and(|evidence| { + freshness.is_none_or(|context| { + context.bind(&admission.identity).is_ok() && context.satisfied_by(&evidence) + }) + }) + } + /// Candidate proof covers every required floor in the admitted scope. + pub fn satisfies( + &self, + admission: &OriginAdmission, + freshness: Option<&FreshnessContext>, + ) -> bool { + self.evidence(admission, true, false) + .is_some_and(|evidence| { + freshness.is_none_or(|context| { + context.bind(&admission.identity).is_ok() && context.satisfied_by(&evidence) + }) + }) + } +} + +#[derive(Clone)] +struct Entry { + admission: OriginAdmission, + response: SnapshotResponse, + bytes: usize, + sequence: u64, +} +/// Opaque reservation fencing cache installation after invalidation/restart. +#[derive(Clone, Debug)] +pub struct FillTicket { + key: OperationKey, + generation: u64, +} + +/// Portable bounded snapshot store. Runtime adapters provide current origin +/// admission for every consumer and coordinate calls; this owns no task/socket. +pub struct SnapshotCache { + metrics: super::SnapshotMetrics, + limits: SnapshotLimits, + entries: BTreeMap, + bytes: usize, + generation: u64, + sequence: u64, +} +impl SnapshotCache { + /// Allocate an empty cache with explicit resource bounds. + pub fn new(limits: SnapshotLimits) -> Result { + if limits.entries == 0 + || limits.entries > 65536 + || limits.bytes == 0 + || limits.bytes > 1024 * 1024 * 1024 + || limits.entry_bytes == 0 + || limits.entry_bytes > limits.bytes + { + return Err(DeliveryError::InvalidContext); + } + Ok(Self { + metrics: super::SnapshotMetrics::default(), + limits, + entries: BTreeMap::new(), + bytes: 0, + generation: 0, + sequence: 0, + }) + } + /// Reserve a fill after authentication. Invalidations fence old tickets. + pub fn begin_fill( + &self, + admission: &OriginAdmission, + now: u64, + ) -> Result { + admission.validate(now)?; + Ok(FillTicket { + key: admission.key.clone(), + generation: self.generation, + }) + } + /// Lookup against a fresh authenticated primary validation, never a stored + /// lease alone. Public age is anchored to the original validation time. + pub fn lookup( + &mut self, + admission: &OriginAdmission, + freshness: Option<&FreshnessContext>, + now: u64, + ) -> Result, DeliveryError> { + admission.validate(now)?; + if let Some(freshness) = freshness { + freshness.bind(&admission.identity)?; + } + let Some(entry) = self.entries.get_mut(&admission.key) else { + self.metrics.misses = self.metrics.misses.saturating_add(1); + return Ok(None); + }; + let current = entry.admission.validator == admission.validator; + let public_age = match (entry.admission.policy, admission.policy) { + ( + SnapshotPolicy::Public { + max_age_seconds: first, + }, + SnapshotPolicy::Public { + max_age_seconds: next, + }, + ) => now.saturating_sub(entry.admission.validated_at) <= first.min(next), + _ => false, + }; + if (!current && !public_age) + || entry.admission.identity != admission.identity + || !entry.response.satisfies(admission, freshness) + { + self.metrics.misses = self.metrics.misses.saturating_add(1); + self.metrics.stale_rejections = self.metrics.stale_rejections.saturating_add(1); + return Ok(None); + } + self.metrics.hits = self.metrics.hits.saturating_add(1); + self.sequence = self.sequence.saturating_add(1); + entry.sequence = self.sequence; + Ok(Some(entry.response.clone())) + } + /// Install only after revalidating the response's own snapshot validator at + /// the origin. A late old fill cannot acquire a newer result's validator. + pub fn install( + &mut self, + ticket: FillTicket, + admission: OriginAdmission, + response: SnapshotResponse, + now: u64, + ) -> Result { + admission.validate(now)?; + let bytes = response.body.len() + + response + .headers + .iter() + .map(|(a, b)| a.len() + b.len()) + .sum::(); + if bytes > self.limits.entry_bytes { + self.metrics.fill_bypasses = self.metrics.fill_bypasses.saturating_add(1); + return Ok(false); + } + if self.generation == u64::MAX + || ticket.generation != self.generation + || ticket.key != admission.key + || !response.satisfies(&admission, None) + { + self.metrics.fill_bypasses = self.metrics.fill_bypasses.saturating_add(1); + return Ok(false); + } + let value: serde_json::Value = + serde_json::from_slice(&response.body).map_err(|_| DeliveryError::Ineligible)?; + if value["extensions"]["gatewayDelivery"]["validator"] != admission.validator { + self.metrics.fill_bypasses = self.metrics.fill_bypasses.saturating_add(1); + return Ok(false); + } + if let Some(previous) = self.entries.remove(&ticket.key) { + self.bytes -= previous.bytes; + } + while self.entries.len() >= self.limits.entries || self.bytes + bytes > self.limits.bytes { + let Some(key) = self + .entries + .iter() + .min_by_key(|(_, e)| e.sequence) + .map(|(k, _)| k.clone()) + else { + break; + }; + self.bytes -= self.entries.remove(&key).expect("resident key").bytes; + } + self.sequence = self.sequence.saturating_add(1); + self.bytes += bytes; + self.entries.insert( + ticket.key, + Entry { + admission, + response, + bytes, + sequence: self.sequence, + }, + ); + Ok(true) + } + /// Lost feed, rebuild or coordinator reset discards data and fences fills. + pub fn invalidate_all(&mut self) { + self.metrics.invalidations = self.metrics.invalidations.saturating_add(1); + self.entries.clear(); + self.bytes = 0; + // Saturation cannot permit an old ticket to become current: at the + // terminal counter value installation is permanently disabled. + self.generation = self.generation.saturating_add(1); + } + /// Snapshot decisions without any identity-bearing labels. + pub fn metrics(&self) -> super::SnapshotMetrics { + self.metrics + } + /// Current resident entry count for diagnostics and boundedness checks. + pub fn len(&self) -> usize { + self.entries.len() + } + /// Whether the store is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} diff --git a/src/gateway/extension.rs b/src/gateway/extension.rs new file mode 100644 index 000000000..a09158f36 --- /dev/null +++ b/src/gateway/extension.rs @@ -0,0 +1,78 @@ +use super::{Gateway, SelectedRoute}; +use std::future::Future; + +/// Protocol-neutral rejection for the host to render as an HTTP response. +#[derive(Clone, Copy, Debug)] +pub enum Rejection<'a> { + /// Invalid or oversized request metadata (400). + BadRequest, + /// No declared route owns the path (404). + NotFound, + /// The selected owner does not support this method (405). Its methods can + /// populate Allow; the adapter must not retry against a UI fallback. + MethodNotAllowed(SelectedRoute<'a>), +} + +/// Runtime execution seam. Implementations own bodies, headers, credentials, +/// streaming, cancellation and the selected binding/provider registry. +/// +/// Futures deliberately have no Send bound: a Worker may hold local handles. +/// Native adapters can implement these methods with Send futures. Gateway +/// admission does not replace authorization at a remote or embedded executor. +pub trait GatewayAdapter { + /// Complete runtime request, including the body and untrusted credentials. + type Request; + /// Authenticated/admitted context, defined by the adapter/provider contract. + type Context; + /// Complete runtime response, including streaming body and independent headers. + type Response; + + /// The actual method of this request; it must agree with the executed request. + fn method<'a>(&self, request: &'a Self::Request) -> &'a str; + /// Raw origin-form path/query from this request (before decoding). Worker + /// adapters extract this from the platform URL, never forwarded-host headers. + fn target<'a>(&self, request: &'a Self::Request) -> &'a str; + /// Authenticate and run every declared admission policy in order. Return a + /// terminal denial response on failure. Public routes may return anonymous + /// context, but must not treat an invalid credential as a valid principal. + fn admit( + &self, + selected: SelectedRoute<'_>, + request: &Self::Request, + ) -> impl Future>; + /// Execute exactly the selected binding with admitted context. Preserve all + /// data/errors/protocol evidence. Upstream failures are terminal responses; + /// do not retry mutations or re-route 404/405/5xx responses to UI. + fn execute( + &self, + selected: SelectedRoute<'_>, + context: Self::Context, + request: Self::Request, + ) -> impl Future; + /// Render a gateway rejection without resolving another route. + fn reject(&self, rejection: Rejection<'_>) -> Self::Response; +} + +impl Gateway { + /// Select once, admit before serving (including protected assets), then + /// execute once. The returned response/body is untouched and adapter-owned. + pub async fn dispatch( + &self, + adapter: &A, + request: A::Request, + ) -> A::Response { + let selected = match self.select(adapter.method(&request), adapter.target(&request)) { + Ok(Some(selected)) => selected, + Ok(None) => return adapter.reject(Rejection::NotFound), + Err(_) => return adapter.reject(Rejection::BadRequest), + }; + let context = match adapter.admit(selected, &request).await { + Ok(context) => context, + Err(response) => return response, + }; + if !selected.method_allowed() { + return adapter.reject(Rejection::MethodNotAllowed(selected)); + } + adapter.execute(selected, context, request).await + } +} diff --git a/src/gateway/graphql/mod.rs b/src/gateway/graphql/mod.rs new file mode 100644 index 000000000..59204bfbd --- /dev/null +++ b/src/gateway/graphql/mod.rs @@ -0,0 +1,181 @@ +//! Whole GraphQL operation admission, independent of an executor or SQL pool. +//! The caller retains the original request/envelope; admission never dispatches +//! commands itself, rewrites command IDs or manufactures commit evidence. +use super::GraphqlCapabilities; +use async_graphql_parser::{ + parse_query, + types::{ExecutableDocument, OperationDefinition, OperationType, Selection}, +}; +use serde_json::Value; + +/// Maximum portable operation document size accepted before parsing. +pub const MAX_DOCUMENT_BYTES: usize = 256 * 1024; + +/// Kind of the exact selected operation, obtained from the GraphQL parser. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperationKind { + /// Read query. + Query, + /// Built-in command status recovery query, owned by the command mount and + /// excluded from query snapshot/coalescing eligibility. + CommandStatus, + /// Query combining ordinary reads and command status; requires both mounts + /// and remains ineligible for query delivery reuse. + MixedQuery, + /// Command mutation; never eligible for an automatic retry. + Mutation, + /// Live subscription. + Subscription, +} + +/// Admission failure before invoking the selected executor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperationError { + /// Malformed transport request/variables/extensions. + InvalidRequest, + /// Invalid executable GraphQL document. + InvalidDocument, + /// Multiple operations require an explicit operationName. + AmbiguousOperation, + /// The requested operationName does not exist in the document. + UnknownOperation, + /// The selected operation's surface is not mounted. + NotMounted, +} +impl std::fmt::Display for OperationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::InvalidRequest => "invalid GraphQL request", + Self::InvalidDocument => "invalid GraphQL document", + Self::AmbiguousOperation => { + "GraphQL operation name is required for multi-operation documents" + } + Self::UnknownOperation => "GraphQL operation name was not found", + Self::NotMounted => "GraphQL operation surface is not mounted", + }) + } +} +impl std::error::Error for OperationError {} +impl OperationError { + /// GraphQL error envelope, without command receipts or response evidence. + pub fn envelope(self) -> Value { + serde_json::json!({ "errors": [{ "message": self.to_string(), "extensions": { "code": if self == Self::NotMounted { "OPERATION_NOT_MOUNTED" } else { "BAD_REQUEST" } } }] }) + } +} + +/// Resolve the exact operation. Reject ambiguous/unknown selection even when +/// another operation in the same document would be allowed. This is not full +/// schema validation; that remains the executor's responsibility. +pub fn operation_kind( + document: &str, + operation_name: Option<&str>, +) -> Result { + if document.len() > MAX_DOCUMENT_BYTES || operation_name.is_some_and(|name| name.len() > 256) { + return Err(OperationError::InvalidRequest); + } + let document = parse_query(document).map_err(|_| OperationError::InvalidDocument)?; + let mut operations = document.operations.iter(); + let operation = if let Some(name) = operation_name { + operations + .find(|(candidate, _)| candidate.is_some_and(|candidate| candidate.as_str() == name)) + .map(|(_, op)| op) + .ok_or(OperationError::UnknownOperation)? + } else { + let (_, operation) = operations.next().ok_or(OperationError::InvalidDocument)?; + if operations.next().is_some() { + return Err(OperationError::AmbiguousOperation); + } + operation + }; + Ok(match operation.node.ty { + OperationType::Query => query_kind(&document, &operation.node), + OperationType::Mutation => OperationKind::Mutation, + OperationType::Subscription => OperationKind::Subscription, + }) +} + +/// Check a selected operation against explicit command/query/live mounts. +pub fn admit_operation( + document: &str, + operation_name: Option<&str>, + capabilities: GraphqlCapabilities, +) -> Result { + let kind = operation_kind(document, operation_name)?; + let allowed = match kind { + OperationKind::Query => capabilities.queries, + OperationKind::Mutation | OperationKind::CommandStatus => capabilities.commands, + OperationKind::MixedQuery => capabilities.queries && capabilities.commands, + OperationKind::Subscription => capabilities.live, + }; + if allowed { + Ok(kind) + } else { + Err(OperationError::NotMounted) + } +} + +/// Validate and admit one JSON GraphQL request, preserving all original values +/// and extensions for forwarding. Request byte/depth limits belong to the host. +pub fn admit_request( + request: &Value, + capabilities: GraphqlCapabilities, +) -> Result { + let document = request + .get("query") + .and_then(Value::as_str) + .ok_or(OperationError::InvalidRequest)?; + let name = match request.get("operationName") { + None | Some(Value::Null) => None, + Some(Value::String(name)) => Some(name.as_str()), + _ => return Err(OperationError::InvalidRequest), + }; + for key in ["variables", "extensions"] { + if request + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_object()) + { + return Err(OperationError::InvalidRequest); + } + } + admit_operation(document, name, capabilities) +} + +// Follow root fragments, not text/aliases. Every selected root must belong to +// status recovery; mixed read/status documents need the full query surface. +fn query_kind(document: &ExecutableDocument, operation: &OperationDefinition) -> OperationKind { + let mut pending: Vec<_> = operation.selection_set.node.items.iter().collect(); + let mut fragments = std::collections::BTreeSet::new(); + let mut status = false; + let mut query = false; + let mut count = 0; + while let Some(selection) = pending.pop() { + count += 1; + if count > 4096 { + return OperationKind::MixedQuery; + } + match &selection.node { + Selection::Field(field) => match field.node.name.node.as_str() { + "commandStatus" => status = true, + "__typename" => {} + _ => query = true, + }, + Selection::InlineFragment(fragment) => { + pending.extend(fragment.node.selection_set.node.items.iter()) + } + Selection::FragmentSpread(spread) => { + let name = spread.node.fragment_name.node.as_str(); + if fragments.insert(name) { + let Some(fragment) = document.fragments.get(name) else { + return OperationKind::MixedQuery; + }; + pending.extend(fragment.node.selection_set.node.items.iter()); + } + } + } + } + match (status, query) { + (true, false) => OperationKind::CommandStatus, + (true, true) => OperationKind::MixedQuery, + _ => OperationKind::Query, + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs new file mode 100644 index 000000000..23cb2ed9f --- /dev/null +++ b/src/gateway/mod.rs @@ -0,0 +1,52 @@ +//! Portable application gateway contracts (`gateway` feature). +//! +//! Configuration selects capabilities; linking this module starts no listeners, +//! executors, projectors or delivery coordinators. [`Gateway::dispatch`] fixes +//! route ownership before admission and execution. Native/Worker adapters own +//! HTTP bodies, credentials, streams, cancellation and connection lifetimes. +//! +//! ``` +//! use distributed::gateway::*; +//! let gateway = GatewayConfig { +//! bindings: vec![Binding::new("assets", BindingKind::Assets)], +//! routes: vec![Route::new("ui", RoutePath::prefix("/"), "assets")], +//! }.build()?; +//! assert_eq!(gateway.select("GET", "/about")?.unwrap().route().id, "ui"); +//! # Ok::<(), GatewayError>(()) +//! ``` + +#![deny(missing_docs)] + +mod auth; +mod config; +mod context; +mod extension; +mod route; + +pub use config::{ + Binding, BindingKind, DeliveryCapabilities, Gateway, GatewayConfig, GatewayError, + GraphqlCapabilities, GraphqlExecutor, MAX_BINDINGS, MAX_ID_BYTES, MAX_ROUTES, +}; +pub use extension::{GatewayAdapter, Rejection}; +pub use route::{Methods, Route, RoutePath, SelectedRoute, MAX_ADMISSIONS, MAX_PATH_BYTES}; + +pub use auth::{is_untrusted_identity_header, Admission, AuthError, AuthProvider}; +pub use context::{BackendCredential, Credentials, Identity, RequestContext}; + +/// Native HTTP routing, streaming proxy and static asset adapter. +#[cfg(feature = "gateway-native")] +pub mod native; + +/// Portable GraphQL operation selection and capability admission. +#[cfg(feature = "gateway-graphql")] +pub mod graphql; + +/// Portable authenticated delivery identity and freshness contracts. +#[cfg(feature = "gateway-delivery")] +pub mod delivery; + +/// workers-rs ingress and sharded Durable Object delivery adapter. +#[cfg(feature = "gateway-worker")] +pub mod worker; + +mod application; diff --git a/src/gateway/native/assets.rs b/src/gateway/native/assets.rs new file mode 100644 index 000000000..026f5dd72 --- /dev/null +++ b/src/gateway/native/assets.rs @@ -0,0 +1,70 @@ +use super::{response, Body, GatewayError, HeaderValue, Request, Response, StatusCode}; +use axum::http::header; +use std::{collections::BTreeMap, sync::Arc}; + +/// Preloaded asset with explicit response content type. +#[derive(Clone)] +pub struct Asset { + /// Bytes stored once and shared between responses. + pub bytes: axum::body::Bytes, + /// Trusted application-selected content type. + pub content_type: HeaderValue, +} +/// Immutable bounded static inventory. There is no fallback filesystem path, +/// symlink traversal or directory listing. Route policy runs before lookup. +#[derive(Clone)] +pub struct StaticAssets(Arc>); +impl StaticAssets { + /// Validate canonical paths, duplicate entries and a total memory budget. + pub fn new( + assets: impl IntoIterator, + max_bytes: usize, + ) -> Result { + let mut inventory = BTreeMap::new(); + let mut bytes = 0usize; + for (path, asset) in assets { + let normalized = super::super::route::normalize_path(&path)?; + if path != normalized || inventory.len() >= 16384 { + return Err(GatewayError("invalid static asset inventory")); + } + bytes = bytes + .checked_add(asset.bytes.len()) + .ok_or(GatewayError("asset inventory too large"))?; + if bytes > max_bytes || inventory.insert(path, asset).is_some() { + return Err(GatewayError("asset inventory budget or duplicate")); + } + } + Ok(Self(Arc::new(inventory))) + } + pub(super) fn serve(&self, request: Request) -> Response { + if request.method() != "GET" && request.method() != "HEAD" { + let mut result = response(StatusCode::METHOD_NOT_ALLOWED); + result + .headers_mut() + .insert(header::ALLOW, HeaderValue::from_static("GET, HEAD")); + return result; + } + let Ok(path) = super::super::route::normalize_path(request.uri().path()) else { + return response(StatusCode::BAD_REQUEST); + }; + let Some(asset) = self.0.get(&path) else { + return response(StatusCode::NOT_FOUND); + }; + let mut result = response(StatusCode::OK); + result + .headers_mut() + .insert(header::CONTENT_TYPE, asset.content_type.clone()); + result.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&asset.bytes.len().to_string()).expect("usize header"), + ); + result.headers_mut().insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + if request.method() != "HEAD" { + *result.body_mut() = Body::from(asset.bytes.clone()); + } + result + } +} diff --git a/src/gateway/native/delivery.rs b/src/gateway/native/delivery.rs new file mode 100644 index 000000000..ef7c9f5e1 --- /dev/null +++ b/src/gateway/native/delivery.rs @@ -0,0 +1,449 @@ +use super::{ + graphql::GraphqlBinding, response, Body, GatewayError, NativeInner, Request, RequestContext, + Response, StatusCode, +}; +use crate::gateway::{delivery::*, DeliveryCapabilities, GraphqlExecutor}; +use axum::{ + body::Bytes, + http::{header, request::Parts}, + response::IntoResponse, +}; +use futures_util::StreamExt; +use std::sync::{Arc, Mutex}; +use tokio::sync::OwnedSemaphorePermit; + +/// Independently selected native delivery capabilities. None allocates nothing. +#[derive(Default)] +pub struct NativeDeliveryOptions { + /// Optional complete snapshot storage. + pub snapshots: Option, + /// Optional concurrent query execution coordination. + pub coalescing: Option, + /// Optional shared live operation coordination. + pub live: Option, +} +/// Bounded native delivery. Each consumer authenticates at the origin before +/// lookup/join; snapshot storage and shared query execution are independent. +pub struct NativeDelivery { + bypasses: std::sync::atomic::AtomicU64, + snapshots: Option>, + flights: Option>, + pub(super) live: Option>, + entry_bytes: usize, +} +struct Fill { + binding: GraphqlBinding, + inner: Arc, + executor: GraphqlExecutor, + context: RequestContext, + parts: Parts, + value: serde_json::Value, + admission: OriginAdmission, + freshness: Option, + ticket: Option, +} +impl NativeDelivery { + /// Allocate only the explicitly selected capabilities and their bounds. + pub fn new(options: NativeDeliveryOptions) -> Result { + if options.snapshots.is_none() && options.coalescing.is_none() && options.live.is_none() { + return Err(GatewayError("no delivery capability selected")); + } + let entry_bytes = options + .coalescing + .map(|limits| limits.response_bytes) + .or(options.snapshots.map(|limits| limits.entry_bytes)) + .unwrap_or(1024 * 1024); + Ok(Self { + bypasses: std::sync::atomic::AtomicU64::new(0), + snapshots: options + .snapshots + .map(SnapshotCache::new) + .transpose() + .map_err(|_| GatewayError("invalid snapshot limits"))? + .map(Mutex::new), + flights: options + .coalescing + .map(super::flight::NativeFlights::new) + .transpose()?, + live: options.live.map(super::live::NativeLive::new).transpose()?, + entry_bytes, + }) + } + /// Cache decisions and origin-ineligible query bypasses. Labels contain no + /// request identity. None indicates snapshot storage was not selected. + pub fn metrics(&self) -> (Option, u64) { + ( + self.snapshots + .as_ref() + .and_then(|cache| cache.lock().ok().map(|cache| cache.metrics())), + self.bypasses.load(std::sync::atomic::Ordering::Relaxed), + ) + } + /// Allocate a bounded origin-validated snapshot cache. + pub fn snapshots(limits: SnapshotLimits) -> Result { + Self::new(NativeDeliveryOptions { + snapshots: Some(limits), + coalescing: None, + live: None, + }) + } + /// Allocate bounded query coalescing without snapshot storage. + pub fn coalescing(limits: FlightLimits) -> Result { + Self::new(NativeDeliveryOptions { + snapshots: None, + coalescing: Some(limits), + live: None, + }) + } + /// Allocate shared live coordination without query caching/coalescing. + pub fn live(limits: LiveLimits) -> Result { + Self::new(NativeDeliveryOptions { + live: Some(limits), + ..Default::default() + }) + } + /// Active live groups/consumers and cumulative source attempts, resets, + /// upstream frames, duplicate frames and safe consumer handoffs. + pub fn live_counts(&self) -> (usize, usize, u64, u64, u64, u64, u64) { + self.live + .as_ref() + .map_or((0, 0, 0, 0, 0, 0, 0), |live| live.counts()) + } + pub(super) fn capabilities(&self) -> DeliveryCapabilities { + DeliveryCapabilities { + snapshots: self.snapshots.is_some(), + coalescing: self.flights.is_some(), + live_sharing: self.live.is_some(), + } + } + /// Current active query groups and admitted consumers, without identifiers. + pub fn flight_counts(&self) -> (usize, usize) { + self.flights + .as_ref() + .map_or((0, 0), |flights| flights.counts()) + } + /// Lost-feed/rebuild reset fences fills; primary hit validation still applies. + pub fn invalidate_all(&self) { + if let Some(cache) = &self.snapshots { + if let Ok(mut cache) = cache.lock() { + cache.invalidate_all(); + } + } + } + #[allow(clippy::too_many_arguments)] + pub(super) async fn execute( + self: &Arc, + binding: &GraphqlBinding, + inner: &Arc, + executor: &GraphqlExecutor, + context: RequestContext, + parts: Parts, + value: serde_json::Value, + permit: OwnedSemaphorePermit, + ) -> Response { + let freshness = match value["extensions"].get("gatewayFreshness") { + Some(value) => match FreshnessContext::parse(value) { + Ok(value) => Some(value), + Err(_) => return response(StatusCode::BAD_REQUEST), + }, + None => None, + }; + let admission = match validate(binding, inner, executor, &context, &parts, &value).await { + AdmissionResult::Eligible(admission) => admission, + AdmissionResult::Bypass => { + self.bypasses + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return binding + .execute_http( + inner, + executor, + context, + request(&parts, value), + Some(permit), + ) + .await; + } + AdmissionResult::Error(error) => return error, + }; + let ticket = if let Some(cache) = &self.snapshots { + let Ok(mut cache) = cache.lock() else { + return response(StatusCode::SERVICE_UNAVAILABLE); + }; + match cache.lookup(&admission, freshness.as_ref(), super::now()) { + Ok(Some(hit)) => return cached_response(hit), + Err(_) => return response(StatusCode::SERVICE_UNAVAILABLE), + Ok(None) => {} + } + match cache.begin_fill(&admission, super::now()) { + Ok(ticket) => Some(ticket), + Err(_) => return response(StatusCode::SERVICE_UNAVAILABLE), + } + } else { + None + }; + let fill = Fill { + binding: binding.clone(), + inner: inner.clone(), + executor: executor.clone(), + context: context.clone(), + parts: request(&parts, value.clone()).into_parts().0, + value: value.clone(), + admission: admission.clone(), + freshness: freshness.clone(), + ticket, + }; + let result = if let Some(flights) = &self.flights { + let key = + match FlightKey::admitted(&admission, &value, freshness.as_ref(), super::now()) { + Ok(key) => key, + Err(_) => return response(StatusCode::BAD_REQUEST), + }; + let owner = self.clone(); + match flights + .execute(key, admission, freshness, move || async move { + owner.fill(fill).await + }) + .await + { + Some(result) => result, + None => { + return binding + .execute_http( + inner, + executor, + context, + request(&parts, value), + Some(permit), + ) + .await + } + } + } else { + self.fill(fill).await + }; + super::flight::with_permit(result, permit) + } + async fn fill(&self, fill: Fill) -> Response { + let Fill { + binding, + inner, + executor, + context, + parts, + value, + admission, + freshness, + ticket, + } = fill; + let mut execution = value.clone(); + mark(&mut execution, "snapshot"); + let result = binding + .execute_http( + &inner, + &executor, + context.clone(), + request(&parts, execution), + None, + ) + .await; + let (response_parts, body) = result.into_parts(); + let captured = match tokio::time::timeout( + inner.options.limits.read_timeout, + capture(body, self.entry_bytes), + ) + .await + { + Ok(captured) => captured, + Err(_) => return response(StatusCode::GATEWAY_TIMEOUT), + }; + let body = match captured { + Captured::Bytes(body) => body, + Captured::Streaming(body) => return Response::from_parts(response_parts, body), + }; + let headers = response_parts + .headers + .iter() + .map(|(name, value)| { + value + .to_str() + .map(|value| (name.to_string(), value.to_owned())) + }) + .collect::, _>>(); + let Ok(headers) = headers else { + return Response::from_parts(response_parts, Body::from(body)); + }; + let snapshot = SnapshotResponse { + status: response_parts.status.as_u16(), + headers, + body: body.to_vec(), + }; + if !snapshot.shareable(&admission, freshness.as_ref()) { + return Response::from_parts(response_parts, Body::from(body)); + } + // Scope/policy can change while result work is running. Authenticate + // after the result too, including when only coalescing is selected. + match validate(&binding, &inner, &executor, &context, &parts, &value).await { + AdmissionResult::Eligible(current) => { + if current.identity != admission.identity || current.key != admission.key { + return response(StatusCode::CONFLICT); + } + if let (Some(ticket), Some(cache)) = (ticket, &self.snapshots) { + let Ok(mut cache) = cache.lock() else { + return response(StatusCode::SERVICE_UNAVAILABLE); + }; + if cache + .install(ticket, current, snapshot, super::now()) + .is_err() + { + return response(StatusCode::SERVICE_UNAVAILABLE); + } + } + } + AdmissionResult::Error(error) => return error, + AdmissionResult::Bypass => {} + } + Response::from_parts(response_parts, Body::from(body)) + } +} + +pub(super) enum AdmissionResult { + Eligible(OriginAdmission), + Bypass, + Error(Response), +} +pub(super) async fn validate( + binding: &GraphqlBinding, + inner: &std::sync::Arc, + executor: &GraphqlExecutor, + context: &RequestContext, + parts: &Parts, + value: &serde_json::Value, +) -> AdmissionResult { + let mut validation = value.clone(); + mark(&mut validation, "validate"); + let pending = binding.execute_http( + inner, + executor, + context.clone(), + request(parts, validation), + None, + ); + let result = + match tokio::time::timeout(inner.options.limits.response_header_timeout, pending).await { + Ok(result) => result, + Err(_) => return AdmissionResult::Error(response(StatusCode::GATEWAY_TIMEOUT)), + }; + let (parts, body) = result.into_parts(); + let body = match tokio::time::timeout( + inner.options.limits.read_timeout, + axum::body::to_bytes(body, 65536), + ) + .await + { + Ok(Ok(body)) => body, + Ok(Err(_)) => return AdmissionResult::Error(response(StatusCode::BAD_GATEWAY)), + Err(_) => return AdmissionResult::Error(response(StatusCode::GATEWAY_TIMEOUT)), + }; + if parts.status != StatusCode::OK { + return AdmissionResult::Error(Response::from_parts(parts, Body::from(body))); + } + let parsed: serde_json::Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => return AdmissionResult::Error(response(StatusCode::BAD_GATEWAY)), + }; + if parsed + .get("errors") + .is_some_and(|e| e.as_array().is_none_or(|e| !e.is_empty())) + { + return AdmissionResult::Error(Response::from_parts(parts, Body::from(body))); + } + let delivery = &parsed["extensions"]["gatewayDelivery"]; + if delivery["eligible"] != true { + return AdmissionResult::Bypass; + } + // Cookie-bearing validation is never a reusable authorization grant. + if parts.headers.contains_key(header::SET_COOKIE) { + return AdmissionResult::Bypass; + } + let admission: OriginAdmission = match serde_json::from_value(delivery["admission"].clone()) { + Ok(value) => value, + Err(_) => return AdmissionResult::Error(response(StatusCode::BAD_GATEWAY)), + }; + if admission.bind(value, super::now()).is_err() { + return AdmissionResult::Error(response(StatusCode::SERVICE_UNAVAILABLE)); + } + AdmissionResult::Eligible(admission) +} +fn mark(value: &mut serde_json::Value, action: &str) { + if !value["extensions"].is_object() { + value["extensions"] = serde_json::json!({}); + } + let init = value["extensions"]["gatewayDelivery"] + .get("connectionInit") + .cloned(); + value["extensions"]["gatewayDelivery"] = serde_json::json!({"action":action}); + if let Some(init) = init { + value["extensions"]["gatewayDelivery"]["connectionInit"] = init; + } +} +pub(super) fn request(parts: &Parts, value: serde_json::Value) -> Request { + let mut request = Request::new(Body::from(value.to_string())); + *request.method_mut() = parts.method.clone(); + *request.uri_mut() = parts.uri.clone(); + *request.version_mut() = parts.version; + *request.headers_mut() = parts.headers.clone(); + *request.extensions_mut() = parts.extensions.clone(); + request.headers_mut().remove(header::CONTENT_LENGTH); + request.headers_mut().insert( + header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + request +} +pub(super) fn cached_response(snapshot: SnapshotResponse) -> Response { + let mut result = snapshot.body.into_response(); + *result.status_mut() = StatusCode::from_u16(snapshot.status).expect("validated status"); + result.headers_mut().clear(); + for (name, value) in snapshot.headers { + if let (Ok(name), Ok(value)) = ( + name.parse::(), + value.parse::(), + ) { + result.headers_mut().append(name, value); + } + } + result +} +pub(super) enum Captured { + Bytes(Bytes), + Streaming(Body), +} +pub(super) async fn capture(body: Body, limit: usize) -> Captured { + let mut stream = body.into_data_stream(); + let mut chunks = Vec::new(); + let mut bytes = 0; + while let Some(chunk) = stream.next().await { + match chunk { + Ok(chunk) => { + bytes += chunk.len(); + chunks.push(Ok(chunk)); + } + Err(error) => { + chunks.push(Err(error)); + return Captured::Streaming(Body::from_stream( + futures_util::stream::iter(chunks).chain(stream), + )); + } + } + if bytes > limit { + return Captured::Streaming(Body::from_stream( + futures_util::stream::iter(chunks).chain(stream), + )); + } + } + let mut result = Vec::with_capacity(bytes); + for chunk in chunks { + result.extend_from_slice(&chunk.expect("successful captured chunks")); + } + Captured::Bytes(result.into()) +} diff --git a/src/gateway/native/flight.rs b/src/gateway/native/flight.rs new file mode 100644 index 000000000..1e3cb8448 --- /dev/null +++ b/src/gateway/native/flight.rs @@ -0,0 +1,312 @@ +use super::{response, Body, Response, StatusCode}; +use crate::gateway::delivery::{ + FlightKey, FlightLimits, FlightRegistry, FlightTicket, FreshnessContext, OriginAdmission, + SnapshotResponse, +}; +use futures_util::{ + future::{BoxFuture, Shared, WeakShared}, + FutureExt, StreamExt, +}; +use std::{ + collections::BTreeMap, + future::Future, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +#[derive(Clone)] +enum Outcome { + Shared(Arc), + Exclusive(Arc>>), +} +type Work = BoxFuture<'static, Outcome>; +struct State { + registry: FlightRegistry, + work: BTreeMap>, +} +pub(super) struct NativeFlights { + state: Mutex, + limits: FlightLimits, + started: Instant, +} +struct Lease { + owner: Arc, + ticket: Option, + work: Shared, +} +impl Drop for Lease { + fn drop(&mut self) { + if let Some(ticket) = self.ticket.take() { + let generation = ticket.generation(); + if let Ok(mut state) = self.owner.state.lock() { + if state.registry.leave(ticket) { + state.work.remove(&generation); + } + } + } + // The registry owns only WeakShared. Dropping the last lease cancels + // the upstream future immediately, without a detached background task. + } +} +impl NativeFlights { + pub(super) fn new(limits: FlightLimits) -> Result, super::GatewayError> { + Ok(Arc::new(Self { + state: Mutex::new(State { + registry: FlightRegistry::new(limits) + .map_err(|_| super::GatewayError("invalid flight limits"))?, + work: BTreeMap::new(), + }), + limits, + started: Instant::now(), + })) + } + pub(super) fn counts(&self) -> (usize, usize) { + self.state.lock().map_or((0, 0), |state| { + (state.registry.len(), state.registry.consumers()) + }) + } + fn join(self: &Arc, key: FlightKey, start: impl FnOnce() -> Work) -> Result { + let mut state = self.state.lock().map_err(|_| ())?; + let now = u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX); + state.registry.expire(now); + let expired = state + .work + .keys() + .filter(|generation| !state.registry.contains_generation(**generation)) + .copied() + .collect::>(); + for generation in expired { + state.work.remove(&generation); + } + let (ticket, owner) = state.registry.join(key, now).map_err(|_| ())?; + let generation = ticket.generation(); + let work = if owner { + let work = start().shared(); + state.work.insert(generation, work.downgrade().ok_or(())?); + work + } else { + match state.work.get(&generation).and_then(WeakShared::upgrade) { + Some(work) => work, + None => { + state.registry.leave(ticket); + return Err(()); + } + } + }; + Ok(Lease { + owner: self.clone(), + ticket: Some(ticket), + work, + }) + } + // None means a nonshareable (cookie/oversized/partial/error) result already + // went to another consumer. This consumer executes normally without joining. + pub(super) async fn execute( + self: &Arc, + key: FlightKey, + admission: OriginAdmission, + freshness: Option, + start: F, + ) -> Option + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let limits = self.limits; + let expires = admission.expires_at; + let lease = match self.join(key, move || { + async move { + let result = + tokio::time::timeout(Duration::from_millis(limits.deadline_ms), async move { + let response = start().await; + let (parts, body) = response.into_parts(); + let body = match super::delivery::capture(body, limits.response_bytes).await + { + super::delivery::Captured::Bytes(bytes) => bytes, + super::delivery::Captured::Streaming(body) => { + return exclusive(Response::from_parts(parts, body)) + } + }; + let headers = parts + .headers + .iter() + .map(|(name, value)| { + value + .to_str() + .map(|value| (name.to_string(), value.to_owned())) + }) + .collect::, _>>(); + if let Ok(headers) = headers { + let snapshot = SnapshotResponse { + status: parts.status.as_u16(), + headers, + body: body.to_vec(), + }; + if snapshot.shareable(&admission, freshness.as_ref()) { + return Outcome::Shared(Arc::new(snapshot)); + } + } + exclusive(Response::from_parts(parts, Body::from(body))) + }) + .await; + result.unwrap_or_else(|_| exclusive(response(StatusCode::GATEWAY_TIMEOUT))) + } + .boxed() + }) { + Ok(lease) => lease, + Err(_) => return Some(response(StatusCode::SERVICE_UNAVAILABLE)), + }; + let remaining = Duration::from_secs(expires.saturating_sub(super::now())); + if remaining.is_zero() { + return Some(response(StatusCode::UNAUTHORIZED)); + } + let outcome = match tokio::time::timeout(remaining, lease.work.clone()).await { + Ok(outcome) => outcome, + Err(_) => return Some(response(StatusCode::UNAUTHORIZED)), + }; + if super::now() >= expires { + return Some(response(StatusCode::UNAUTHORIZED)); + } + match outcome { + Outcome::Shared(snapshot) => { + Some(super::delivery::cached_response((*snapshot).clone())) + } + Outcome::Exclusive(response) => response + .lock() + .ok() + .and_then(|mut response| response.take()), + } + } +} +fn exclusive(response: Response) -> Outcome { + Outcome::Exclusive(Arc::new(Mutex::new(Some(response)))) +} +pub(super) fn with_permit( + response: Response, + permit: tokio::sync::OwnedSemaphorePermit, +) -> Response { + let (parts, body) = response.into_parts(); + let stream = body.into_data_stream().map(move |chunk| { + let _ = &permit; + chunk + }); + Response::from_parts(parts, Body::from_stream(stream)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gateway::delivery::{OperationKey, OriginIdentity, SnapshotPolicy}; + use std::sync::atomic::{AtomicUsize, Ordering}; + fn admitted() -> (FlightKey, OriginAdmission) { + let request = serde_json::json!({"query":"{ rows { title } }"}); + let identity = OriginIdentity { + application: "test".into(), + endpoint: "origin".into(), + schema_hash: "schema".into(), + protocol_hash: "protocol".into(), + authorization_generation: "policy".into(), + cache_scope: "alice".into(), + }; + let admission = OriginAdmission { + key: OperationKey::from_origin(&identity, &request).unwrap(), + identity, + operation: "operation".into(), + validator: "v1".into(), + validated_at: super::super::now(), + expires_at: super::super::now() + 30, + policy: SnapshotPolicy::Current, + }; + ( + FlightKey::admitted(&admission, &request, None, super::super::now()).unwrap(), + admission, + ) + } + struct Dropped(Arc); + impl Drop for Dropped { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + #[tokio::test] + async fn last_consumer_cancels_upstream_without_detached_owner() { + let flights = NativeFlights::new(FlightLimits::default()).unwrap(); + let (key, admission) = admitted(); + let dropped = Arc::new(AtomicUsize::new(0)); + let counter = dropped.clone(); + let (started, ready) = tokio::sync::oneshot::channel(); + let owner = flights.clone(); + let first_key = key.clone(); + let first_admission = admission.clone(); + let first = tokio::spawn(async move { + owner + .execute(first_key, first_admission, None, move || async move { + let _guard = Dropped(counter); + let _ = started.send(()); + std::future::pending::().await + }) + .await + }); + ready.await.unwrap(); + let owner = flights.clone(); + let second = tokio::spawn(async move { + owner + .execute(key, admission, None, || async { + panic!("joined consumer must not start work") + }) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + while flights.counts() != (1, 2) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + first.abort(); + let _ = first.await; + assert_eq!(flights.counts(), (1, 1)); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + second.abort(); + let _ = second.await; + assert_eq!(flights.counts(), (0, 0)); + assert_eq!(dropped.load(Ordering::SeqCst), 1); + } + #[tokio::test] + async fn deadline_expiry_and_failure_release_every_group() { + let flights = NativeFlights::new(FlightLimits { + deadline_ms: 20, + ..Default::default() + }) + .unwrap(); + let (key, admission) = admitted(); + let response = flights + .execute(key, admission, None, || std::future::pending::()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!(flights.counts(), (0, 0)); + let flights = NativeFlights::new(FlightLimits { + deadline_ms: 3000, + ..Default::default() + }) + .unwrap(); + let (key, mut admission) = admitted(); + admission.expires_at = super::super::now() + 1; + let response = flights + .execute(key, admission, None, || std::future::pending::()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(flights.counts(), (0, 0)); + let (key, admission) = admitted(); + let result = flights + .execute(key, admission, None, || async { + super::response(StatusCode::BAD_GATEWAY) + }) + .await + .unwrap(); + assert_eq!(result.status(), StatusCode::BAD_GATEWAY); + assert_eq!(flights.counts(), (0, 0)); + } +} diff --git a/src/gateway/native/graphql.rs b/src/gateway/native/graphql.rs new file mode 100644 index 000000000..8eea1aba2 --- /dev/null +++ b/src/gateway/native/graphql.rs @@ -0,0 +1,659 @@ +use super::{ + proxy, response, Body, GatewayError, HeaderValue, NativeInner, Request, RequestContext, + Response, Router, StatusCode, Url, +}; +use crate::command_dispatch::SharedCommandHost; +use crate::gateway::{ + graphql::{admit_operation, admit_request, OperationError}, + BindingKind, DeliveryCapabilities, GraphqlCapabilities, GraphqlExecutor, +}; +use crate::graphql::{graphql_router_composed, GraphqlEngine, GraphqlOperationFilter}; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + FromRequestParts, + }, + http::header, + response::IntoResponse, +}; +use futures_util::{SinkExt, StreamExt}; +use std::{collections::BTreeSet, sync::Arc, time::Duration}; +use tokio_tungstenite::{ + tungstenite::{protocol::Role, Message as UpstreamMessage}, + WebSocketStream, +}; +use tower::ServiceExt; + +/// Complete embedded executor, with explicit capabilities and extension +/// registration. The executor owns the composed schema and authorization. +#[derive(Clone)] +pub struct EmbeddedGraphql { + pub(super) router: Router, + #[cfg(feature = "gateway-delivery")] + pub(super) engine: Option>, + capabilities: GraphqlCapabilities, + extensions: BTreeSet, +} +impl EmbeddedGraphql { + /// Mount the framework's existing HTTP/WS handlers. Build query-only schemas + /// without command inventory and with subscriptions(false). This validates + /// absent capabilities against the actual role surfaces before serving. + pub fn new( + engine: Arc, + host: Option, + capabilities: GraphqlCapabilities, + ) -> Result { + for surface in engine.inner.role_surfaces.values() { + if (!capabilities.commands && !surface.commands.is_empty()) + || (!capabilities.live && !surface.subscription_fields.is_empty()) + { + return Err(GatewayError( + "embedded schema exposes an unmounted capability", + )); + } + } + if capabilities.commands && host.is_none() { + return Err(GatewayError("command surface requires a command host")); + } + Ok(Self { + #[cfg(feature = "gateway-delivery")] + engine: Some(engine.clone()), + router: graphql_router_composed(engine, host, Some(operation_filter(capabilities))), + capabilities, + extensions: BTreeSet::new(), + }) + } + + /// Bind an application-composed executor (including custom GraphQL fields). + /// The factory must install the supplied filter in its HTTP and WS execution + /// paths, just as graphql_router_composed does. Custom WS handlers must also + /// retain the request extension `GraphqlConnectionGuard` through the socket + /// lifetime and run the connection with its `run` method. It is a trusted executor + /// extension seam, not arbitrary field federation. Register fields here at + /// this executor; declare their registration IDs in the gateway config. + pub fn custom( + factory: impl FnOnce(GraphqlOperationFilter) -> Router, + capabilities: GraphqlCapabilities, + extensions: impl IntoIterator, + ) -> Result { + let mut registered = BTreeSet::new(); + for name in extensions { + super::super::config::validate_id(&name)?; + if !registered.insert(name) { + return Err(GatewayError("duplicate schema extension registration")); + } + } + Ok(Self { + router: factory(operation_filter(capabilities)), + #[cfg(feature = "gateway-delivery")] + engine: None, + capabilities, + extensions: registered, + }) + } +} + +/// Paths at a complete remote GraphQL executor. Its origin comes from the +/// validated portable binding, never from a caller request or query variable. +#[derive(Clone, Debug)] +pub struct RemoteGraphql { + /// Remote HTTP operation path. + pub http_path: String, + /// Remote WebSocket operation path, required when live is mounted. + pub live_path: Option, +} +impl Default for RemoteGraphql { + fn default() -> Self { + Self { + http_path: "/graphql".into(), + live_path: Some("/graphql/ws".into()), + } + } +} + +/// One executor resource for a portable GraphQL binding. +#[derive(Clone)] +pub enum GraphqlBinding { + /// Executor in the same process, sharing its existing schema and command host. + Embedded(EmbeddedGraphql), + /// Whole-operation remote HTTP and WS transport. + Remote(RemoteGraphql), +} + +fn operation_filter(capabilities: GraphqlCapabilities) -> GraphqlOperationFilter { + Arc::new(move |request| { + admit_operation( + &request.query, + request.operation_name.as_deref(), + capabilities, + ) + .map(|_| ()) + .map_err(|error| { + let mut result = async_graphql::ServerError::new(error.to_string(), None); + let mut extensions = async_graphql::ErrorExtensionValues::default(); + extensions.set( + "code", + if error == OperationError::NotMounted { + "OPERATION_NOT_MOUNTED" + } else { + "BAD_REQUEST" + }, + ); + result.extensions = Some(extensions); + result + }) + }) +} +fn error_response(error: OperationError) -> Response { + axum::Json(error.envelope()).into_response() +} + +impl GraphqlBinding { + pub(super) fn validate( + &self, + executor: &GraphqlExecutor, + capabilities: GraphqlCapabilities, + delivery: DeliveryCapabilities, + extensions: &[String], + public: &Url, + ) -> Result<(), GatewayError> { + // Later delivery mounts provide these implementations. Reject enabling + // a mount until it is bound; a configuration flag cannot pretend to cache. + if delivery != DeliveryCapabilities::default() { + return Err(GatewayError("delivery adapter is not bound")); + } + match (self, executor) { + (Self::Embedded(embedded), GraphqlExecutor::Embedded) => { + if embedded.capabilities != capabilities + || embedded.extensions != extensions.iter().cloned().collect() + { + return Err(GatewayError( + "embedded schema registration does not match declaration", + )); + } + } + (Self::Remote(remote), GraphqlExecutor::Remote { origin }) => { + if Url::parse(origin) + .map_err(|_| GatewayError("invalid GraphQL origin"))? + .origin() + == public.origin() + { + return Err(GatewayError("GraphQL upstream points to gateway")); + } + for path in std::iter::once(&remote.http_path).chain(remote.live_path.as_ref()) { + if super::super::route::normalize_path(path)? != *path { + return Err(GatewayError("invalid remote GraphQL path")); + } + } + if capabilities.live && remote.live_path.is_none() { + return Err(GatewayError("live gateway needs a remote live endpoint")); + } + } + _ => return Err(GatewayError("GraphQL executor location mismatch")), + } + Ok(()) + } + + pub(super) async fn execute( + &self, + inner: &Arc, + declaration: &BindingKind, + context: RequestContext, + mut request: Request, + ) -> Response { + let BindingKind::Graphql { + capabilities, + executor, + .. + } = declaration + else { + return response(StatusCode::SERVICE_UNAVAILABLE); + }; + let upgrade = request.headers().contains_key(header::UPGRADE); + if upgrade { + #[cfg(feature = "gateway-delivery")] + let shared = request + .extensions() + .get::>() + .cloned() + .filter(|delivery| delivery.live.is_some()); + if !capabilities.live { + return response(StatusCode::NOT_FOUND); + } + return match (self, executor) { + (Self::Embedded(embedded), _) => { + #[cfg(feature = "gateway-delivery")] + if let Some(coordinator) = shared.filter(|_| embedded.engine.is_some()) { + let execution = super::live_transport::Execution { + binding: self.clone(), + inner: inner.clone(), + declaration: declaration.clone(), + context, + }; + return super::live_transport::upgrade_embedded( + execution, + request, + embedded.clone(), + coordinator, + ) + .await; + } + let permit = match inner.permits.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => return response(StatusCode::SERVICE_UNAVAILABLE), + }; + let lifetime = + context + .identity() + .map_or(inner.options.limits.upgrade_lifetime, |id| { + inner + .options + .limits + .upgrade_lifetime + .min(Duration::from_secs( + id.expires_at().saturating_sub(super::now()), + )) + }); + request.extensions_mut().insert(Arc::new( + crate::graphql::http::GraphqlConnectionGuard { + lifetime, + _permit: permit, + }, + )); + if proxy::prepare_headers(request.headers_mut(), inner, &context, true).is_err() + { + return response(StatusCode::BAD_REQUEST); + } + rewrite_path(&mut request, "/graphql/ws"); + request.extensions_mut().insert(context); + match embedded.router.clone().oneshot(request).await { + Ok(response) => response, + Err(never) => match never {}, + } + } + (Self::Remote(remote), GraphqlExecutor::Remote { origin }) => { + remote_websocket( + inner, + origin, + remote.live_path.as_deref().expect("validated live path"), + *capabilities, + context.clone(), + request, + #[cfg(feature = "gateway-delivery")] + shared.map(|coordinator| { + ( + super::live_transport::Execution { + binding: self.clone(), + inner: inner.clone(), + declaration: declaration.clone(), + context, + }, + coordinator, + ) + }), + ) + .await + } + _ => response(StatusCode::SERVICE_UNAVAILABLE), + }; + } + self.execute_operation(inner, declaration, context, request) + .await + } + + /// Execute an already-selected HTTP operation without an upgrade branch. + pub(super) async fn execute_operation( + &self, + inner: &Arc, + declaration: &BindingKind, + context: RequestContext, + request: Request, + ) -> Response { + let BindingKind::Graphql { + capabilities, + executor, + .. + } = declaration + else { + return response(StatusCode::SERVICE_UNAVAILABLE); + }; + if request.method() != "POST" { + let mut result = response(StatusCode::METHOD_NOT_ALLOWED); + result + .headers_mut() + .insert(header::ALLOW, HeaderValue::from_static("POST")); + return result; + } + let permit = match inner.permits.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => return response(StatusCode::SERVICE_UNAVAILABLE), + }; + let (parts, body) = request.into_parts(); + let body = match tokio::time::timeout( + inner.options.limits.response_header_timeout, + axum::body::to_bytes(body, inner.options.limits.request_body_bytes), + ) + .await + { + Ok(Ok(body)) => body, + Ok(Err(_)) => return response(StatusCode::PAYLOAD_TOO_LARGE), + Err(_) => return response(StatusCode::REQUEST_TIMEOUT), + }; + let value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => return error_response(OperationError::InvalidRequest), + }; + if let Err(error) = admit_request(&value, *capabilities) { + return error_response(error); + } + #[cfg(feature = "gateway-delivery")] + if let Some(coordinator) = parts + .extensions + .get::>() + .filter(|delivery| { + let caps = delivery.capabilities(); + caps.snapshots || caps.coalescing + }) + .cloned() + { + if super::super::graphql::operation_kind( + value["query"].as_str().unwrap_or(""), + value["operationName"].as_str(), + ) == Ok(super::super::graphql::OperationKind::Query) + && (value["extensions"].get("gatewayDelivery").is_none() + || value["extensions"]["gatewayDelivery"]["action"] == "execute") + { + return coordinator + .execute(self, inner, executor, context, parts, value, permit) + .await; + } + } + self.execute_http( + inner, + executor, + context, + Request::from_parts(parts, Body::from(body)), + Some(permit), + ) + .await + } + + pub(super) async fn execute_http( + &self, + inner: &Arc, + executor: &GraphqlExecutor, + context: RequestContext, + mut request: Request, + permit: Option, + ) -> Response { + // Internal control/result calls share the outer admitted capacity slot. + // Plain remote streaming responses retain their own permit in the body. + match (self, executor) { + (Self::Embedded(embedded), _) => { + if proxy::prepare_headers(request.headers_mut(), inner, &context, false).is_err() { + return response(StatusCode::BAD_REQUEST); + } + rewrite_path(&mut request, "/graphql"); + request.extensions_mut().insert(context); + match embedded.router.clone().oneshot(request).await { + Ok(response) => response, + Err(never) => match never {}, + } + } + (Self::Remote(remote), GraphqlExecutor::Remote { origin }) => { + rewrite_path(&mut request, &remote.http_path); + proxy::forward_with_permit(inner, origin, false, context, request, permit).await + } + _ => response(StatusCode::SERVICE_UNAVAILABLE), + } + } +} + +fn rewrite_path(request: &mut Request, path: &str) { + let target = request + .uri() + .query() + .map_or_else(|| path.to_owned(), |query| format!("{path}?{query}")); + *request.uri_mut() = target.parse().expect("validated path and request query"); +} + +async fn remote_websocket( + inner: &Arc, + origin: &str, + path: &str, + capabilities: GraphqlCapabilities, + context: RequestContext, + request: Request, + #[cfg(feature = "gateway-delivery")] sharing: Option<( + super::live_transport::Execution, + Arc, + )>, +) -> Response { + let permit = match inner.permits.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => return response(StatusCode::SERVICE_UNAVAILABLE), + }; + let (mut parts, _) = request.into_parts(); + let offered = parts + .headers + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let protocol = if offered + .split(',') + .any(|p| p.trim() == "graphql-transport-ws") + { + "graphql-transport-ws" + } else if offered.split(',').any(|p| p.trim() == "graphql-ws") { + "graphql-ws" + } else { + return response(StatusCode::BAD_REQUEST); + }; + let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &()).await { + Ok(upgrade) => upgrade, + Err(rejection) => return rejection.into_response(), + }; + if proxy::prepare_headers(&mut parts.headers, inner, &context, true).is_err() { + return response(StatusCode::BAD_REQUEST); + } + #[cfg(feature = "gateway-delivery")] + let sharing = sharing.map(|(execution, coordinator)| { + ( + execution, + coordinator, + super::delivery::request(&parts, serde_json::json!({})) + .into_parts() + .0, + ) + }); + parts.headers.insert( + header::SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static(protocol), + ); + let Some(key) = parts.headers.get(header::SEC_WEBSOCKET_KEY) else { + return response(StatusCode::BAD_REQUEST); + }; + let accept = tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes()); + if proxy::add_hop(&mut parts.headers, inner).is_err() { + return response(StatusCode::BAD_REQUEST); + } + let pending = inner + .client + .get(format!( + "{}{path}{}", + origin.trim_end_matches('/'), + parts + .uri + .query() + .map_or(String::new(), |query| format!("?{query}")) + )) + .headers(parts.headers) + .send(); + let upstream = + match tokio::time::timeout(inner.options.limits.response_header_timeout, pending).await { + Ok(Ok(upstream)) => upstream, + Err(_) => return response(StatusCode::GATEWAY_TIMEOUT), + _ => return response(StatusCode::BAD_GATEWAY), + }; + if upstream.status() != StatusCode::SWITCHING_PROTOCOLS { + let status = upstream.status(); + let mut headers = upstream.headers().clone(); + proxy::strip_hop_headers(&mut headers); + // The client already enforces idle read timeout. Retain capacity until + // the terminal upstream body finishes or the consumer disconnects. + let stream = upstream.bytes_stream().map(move |chunk| { + let _ = &permit; + chunk + }); + let mut result = Response::new(Body::from_stream(stream)); + *result.status_mut() = status; + *result.headers_mut() = headers; + return result; + } + if upstream + .headers() + .get(header::SEC_WEBSOCKET_ACCEPT) + .and_then(|v| v.to_str().ok()) + != Some(accept.as_str()) + || upstream + .headers() + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|v| v.to_str().ok()) + != Some(protocol) + { + return response(StatusCode::BAD_GATEWAY); + } + let mut headers = upstream.headers().clone(); + for name in [ + header::SEC_WEBSOCKET_ACCEPT, + header::SEC_WEBSOCKET_PROTOCOL, + header::CONNECTION, + header::UPGRADE, + header::CONTENT_LENGTH, + ] { + headers.remove(name); + } + let lifetime = context + .identity() + .map_or(inner.options.limits.upgrade_lifetime, |id| { + inner + .options + .limits + .upgrade_lifetime + .min(Duration::from_secs( + id.expires_at().saturating_sub(super::now()), + )) + }); + let max_message = inner.options.limits.request_body_bytes; + let mut response = upgrade + .protocols([protocol]) + .max_message_size(max_message) + .on_upgrade(move |socket| async move { + let _permit = permit; + let _ = tokio::time::timeout(lifetime, async move { + let Ok(upstream) = upstream.upgrade().await else { + return; + }; + let upstream = WebSocketStream::from_raw_socket( + upstream, + Role::Client, + Some( + tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(max_message)), + ), + ) + .await; + #[cfg(feature = "gateway-delivery")] + if let Some((execution, coordinator, parts)) = sharing { + super::live_transport::remote( + socket, + upstream, + execution, + parts, + coordinator, + protocol, + ) + .await; + return; + } + bridge(socket, upstream, capabilities, protocol).await; + }) + .await; + }) + .into_response(); + for (name, value) in &headers { + response.headers_mut().append(name, value.clone()); + } + response +} + +async fn bridge( + mut client: WebSocket, + mut origin: WebSocketStream, + capabilities: GraphqlCapabilities, + protocol: &str, +) { + let mut admitted = false; + let mut active = BTreeSet::new(); + loop { + tokio::select! { + message = client.recv() => { + let Some(Ok(message)) = message else { break }; + match message { + Message::Text(text) => { + if let Ok(value) = serde_json::from_str::(&text) { + match value["type"].as_str() { + Some("subscribe" | "start") => { + let Some(id) = value["id"].as_str() else { break }; + if !admitted || id.len() > 256 || active.len() >= 128 || active.contains(id) { break; } + if let Err(error) = admit_request(&value["payload"], capabilities) { + let payload = serde_json::json!({ "id": id, "type": if protocol == "graphql-ws" { "data" } else { "next" }, "payload": error.envelope() }); + if client.send(Message::Text(payload.to_string().into())).await.is_err() { break; } + if client.send(Message::Text(serde_json::json!({ "id": id, "type": "complete" }).to_string().into())).await.is_err() { break; } + continue; + } + active.insert(id.to_owned()); + } + Some("complete" | "stop") => { if let Some(id) = value["id"].as_str() { active.remove(id); } } + _ => {} + } + } + if origin.send(UpstreamMessage::Text(text.to_string().into())).await.is_err() { break; } + } + Message::Binary(_) => break, // GraphQL WS uses JSON text; never bypass operation admission. + Message::Ping(bytes) => { if origin.send(UpstreamMessage::Ping(bytes)).await.is_err() { break; } } + Message::Pong(bytes) => { if origin.send(UpstreamMessage::Pong(bytes)).await.is_err() { break; } } + Message::Close(frame) => { + let frame = frame.map(|frame| tokio_tungstenite::tungstenite::protocol::CloseFrame { code: frame.code.into(), reason: frame.reason.to_string().into() }); + let _ = origin.send(UpstreamMessage::Close(frame)).await; + break; + }, + } + } + message = origin.next() => { + let Some(Ok(message)) = message else { break }; + let downstream = match message { + UpstreamMessage::Text(text) => { + if let Ok(value) = serde_json::from_str::(&text) { + if value["type"] == "connection_ack" { admitted = true; } + if matches!(value["type"].as_str(), Some("complete" | "error")) { if let Some(id) = value["id"].as_str() { active.remove(id); } } + } + Message::Text(text.to_string().into()) + } + UpstreamMessage::Binary(bytes) => Message::Binary(bytes), + UpstreamMessage::Ping(bytes) => Message::Ping(bytes), + UpstreamMessage::Pong(bytes) => Message::Pong(bytes), + UpstreamMessage::Close(frame) => { + let frame = frame.map(|frame| axum::extract::ws::CloseFrame { code: frame.code.into(), reason: frame.reason.to_string().into() }); + let _ = client.send(Message::Close(frame)).await; + break; + }, + UpstreamMessage::Frame(_) => continue, + }; + if client.send(downstream).await.is_err() { break; } + } + } + } + let _ = client.send(Message::Close(None)).await; + let _ = origin.close(None).await; +} diff --git a/src/gateway/native/live.rs b/src/gateway/native/live.rs new file mode 100644 index 000000000..b4e29f6e7 --- /dev/null +++ b/src/gateway/native/live.rs @@ -0,0 +1,692 @@ +use crate::gateway::delivery::*; +use futures_util::{future::BoxFuture, stream::BoxStream, StreamExt}; +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + sync::{Arc, Mutex, Weak}, + time::{Duration, Instant}, +}; +use tokio::sync::{mpsc, oneshot}; + +pub(super) type LiveSource = BoxStream<'static, Result>; +pub(super) type LiveSourceFactory = + Arc BoxFuture<'static, Result> + Send + Sync>; +#[derive(Clone)] +struct Input { + admission: OriginAdmission, + request: serde_json::Value, + freshness: Option, + source: LiveSourceFactory, +} +struct Consumer { + ticket: LiveTicket, + input: Input, + frames: mpsc::Sender>, + reset: oneshot::Sender<&'static str>, +} +struct Group { + key: LiveKey, + initial: LiveKey, + consumers: BTreeSet, + history: VecDeque>, + complete_history: bool, + driver: Option, +} +impl Drop for Group { + fn drop(&mut self) { + if let Some(driver) = &self.driver { + driver.abort(); + } + } +} +struct State { + registry: LiveRegistry, + groups: BTreeMap, + consumers: BTreeMap, + next: u64, + upstreams: u64, + resets: u64, + frames: u64, + deduplicated: u64, + handoffs: u64, +} +/// Native stream drivers are owned by these groups; removing the last consumer +/// aborts the upstream and releases its socket/stream, including on disconnect. +pub(super) struct NativeLive { + state: Mutex, + limits: LiveLimits, + started: Instant, +} +pub(super) struct LiveLease { + id: u64, + owner: Arc, + frames: mpsc::Receiver>, + reset: oneshot::Receiver<&'static str>, + expiry: u64, + terminal: Option<&'static str>, +} +impl Drop for LiveLease { + fn drop(&mut self) { + self.owner.remove(self.id, "consumer_left"); + } +} +impl LiveLease { + pub(super) async fn next(&mut self) -> Result>, &'static str> { + loop { + let remaining = Duration::from_secs(self.expiry.saturating_sub(super::now())); + if remaining.is_zero() { + return Err("AUTH_EXPIRED"); + } + if let Some(reason) = self.terminal { + if reason != "complete" { + return Err(reason); + } + let frame = self.frames.recv().await; + return if super::now() >= self.expiry { + Err("AUTH_EXPIRED") + } else { + Ok(frame) + }; + } + tokio::select! { biased; + reset = &mut self.reset => { self.terminal = Some(reset.unwrap_or("LIVE_RESET_REQUIRED")); }, + _ = tokio::time::sleep(remaining) => return Err("AUTH_EXPIRED"), + frame = self.frames.recv() => return if super::now() >= self.expiry { Err("AUTH_EXPIRED") } else { Ok(frame) }, + } + } + } + // A reset/expiry interrupts a blocked network queue. Normal completion + // preserves all already-queued frames before the terminal complete packet. + pub(super) async fn interrupted(&mut self) -> &'static str { + let remaining = Duration::from_secs(self.expiry.saturating_sub(super::now())); + if remaining.is_zero() { + return "AUTH_EXPIRED"; + } + if let Some(reason) = self.terminal { + if reason != "complete" { + return reason; + } + tokio::time::sleep(remaining).await; + return "AUTH_EXPIRED"; + } + tokio::select! { + reset = &mut self.reset => { + let reason = reset.unwrap_or("LIVE_RESET_REQUIRED"); self.terminal = Some(reason); + if reason == "complete" { tokio::time::sleep(remaining).await; "AUTH_EXPIRED" } else { reason } + }, + _ = tokio::time::sleep(remaining) => "AUTH_EXPIRED", + } + } +} + +impl NativeLive { + pub(super) fn new(limits: LiveLimits) -> Result, super::GatewayError> { + let registry = + LiveRegistry::new(limits).map_err(|_| super::GatewayError("invalid live limits"))?; + Ok(Arc::new(Self { + state: Mutex::new(State { + registry, + groups: BTreeMap::new(), + consumers: BTreeMap::new(), + next: 0, + upstreams: 0, + resets: 0, + frames: 0, + deduplicated: 0, + handoffs: 0, + }), + limits, + started: Instant::now(), + })) + } + pub(super) fn counts(&self) -> (usize, usize, u64, u64, u64, u64, u64) { + self.state.lock().map_or((0, 0, 0, 0, 0, 0, 0), |s| { + ( + s.groups.len(), + s.consumers.len(), + s.upstreams, + s.resets, + s.frames, + s.deduplicated, + s.handoffs, + ) + }) + } + pub(super) fn join( + self: &Arc, + admission: OriginAdmission, + request: serde_json::Value, + freshness: Option, + source: LiveSourceFactory, + ) -> Result { + let initial = LiveKey::admitted(&admission, &request, freshness.as_ref(), super::now())?; + let input = Input { + admission, + request, + freshness, + source, + }; + let (frames, receiver) = mpsc::channel(self.limits.queue_frames); + let (reset, reset_receiver) = oneshot::channel(); + let mut state = self.state.lock().map_err(|_| DeliveryError::Unavailable)?; + let now = u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX); + state.registry.expire(now); + let expired = state + .groups + .keys() + .filter(|generation| !state.registry.contains_generation(**generation)) + .copied() + .collect::>(); + for generation in expired { + end(&mut state, generation, "LIVE_RESET_REQUIRED"); + } + state.next = state + .next + .checked_add(1) + .ok_or(DeliveryError::Unavailable)?; + let id = state.next; + let existing = state + .groups + .iter() + .find(|(_, group)| { + group.initial.same_initial(&initial) + && group.complete_history + && group.consumers.len() < self.limits.consumers + }) + .map(|(generation, group)| (*generation, group.key.clone())); + let key = existing + .map(|(_, key)| key) + .unwrap_or_else(|| initial.fork(id)); + let (ticket, owner) = state.registry.join(key.clone(), now)?; + let generation = ticket.generation(); + if owner { + state.groups.insert( + generation, + Group { + key, + initial, + consumers: BTreeSet::new(), + history: VecDeque::new(), + complete_history: true, + driver: None, + }, + ); + } + let group = state + .groups + .get_mut(&generation) + .ok_or(DeliveryError::Unavailable)?; + for frame in &group.history { + if !frame.satisfies(&input.admission, input.freshness.as_ref()) { + state.registry.leave(ticket); + return Err(DeliveryError::Pending); + } + frames + .try_send(frame.clone()) + .map_err(|_| DeliveryError::Unavailable)?; + } + group.consumers.insert(id); + let expiry = input.admission.expires_at; + state.consumers.insert( + id, + Consumer { + ticket, + input, + frames, + reset, + }, + ); + drop(state); + if owner { + let weak = Arc::downgrade(self); + let lifetime = self.limits.lifetime_ms; + let driver = tokio::spawn(async move { + let reason = tokio::time::timeout( + Duration::from_millis(lifetime), + drive(weak.clone(), generation), + ) + .await + .unwrap_or("LIVE_RESET_REQUIRED"); + if let Some(owner) = weak.upgrade() { + if let Ok(mut state) = owner.state.lock() { + end(&mut state, generation, reason); + } + } + }); + let handle = driver.abort_handle(); + // No detached owner: Group owns the abort handle, and the driver + // retains only Weak so the coordinator can be dropped cleanly. + if let Ok(mut state) = self.state.lock() { + if let Some(group) = state.groups.get_mut(&generation) { + group.driver = Some(handle); + } else { + handle.abort(); + } + } else { + handle.abort(); + } + } + Ok(LiveLease { + id, + owner: self.clone(), + frames: receiver, + reset: reset_receiver, + expiry, + terminal: None, + }) + } + fn remove(&self, id: u64, reason: &'static str) { + if let Ok(mut state) = self.state.lock() { + remove(&mut state, id, reason); + } + } + fn input(&self, generation: u64) -> Option { + let mut state = self.state.lock().ok()?; + let group = state.groups.get(&generation)?; + let mut input = group + .consumers + .iter() + .filter_map(|id| state.consumers.get(id)) + .filter(|consumer| consumer.input.admission.expires_at > super::now()) + .max_by_key(|consumer| consumer.input.admission.expires_at)? + .input + .clone(); + // Reconnect under a remaining consumer's current credentials. The + // origin handles replay/reset at the last observed proven cursor. + if let Some(frame) = group.history.back() { + if !input.request["extensions"].is_object() { + input.request["extensions"] = serde_json::json!({}); + } + if !input.request["extensions"]["distributed"].is_object() { + input.request["extensions"]["distributed"] = serde_json::json!({}); + } + input.request["extensions"]["distributed"]["resume"] = serde_json::json!({"cursors":frame.payload()["extensions"]["distributed"]["live"]["cursors"]}); + } + state.upstreams = state.upstreams.saturating_add(1); + Some(input) + } + fn emit(&self, generation: u64, input: &Input, payload: serde_json::Value) -> bool { + let frame = match LiveFrame::from_origin( + &input.admission, + payload, + None, + self.limits.frame_bytes, + ) { + Ok(frame) => Arc::new(frame), + Err(_) => { + if let Ok(mut state) = self.state.lock() { + end(&mut state, generation, "LIVE_RESET_REQUIRED"); + } + return false; + } + }; + let Ok(mut state) = self.state.lock() else { + return false; + }; + state.frames = state.frames.saturating_add(1); + let Some(group) = state.groups.get_mut(&generation) else { + return false; + }; + if group + .history + .back() + .is_some_and(|last| last.same_frame(&frame)) + { + state.deduplicated = state.deduplicated.saturating_add(1); + return true; + } + group.history.push_back(frame.clone()); + if group.history.len() > self.limits.history_frames { + group.history.pop_front(); + group.complete_history = false; + } + let ids = group.consumers.iter().copied().collect::>(); + for id in ids { + let Some(consumer) = state.consumers.get(&id) else { + continue; + }; + let reason = if consumer.input.admission.expires_at <= super::now() { + Some("AUTH_EXPIRED") + } else if !frame.satisfies(&consumer.input.admission, consumer.input.freshness.as_ref()) + { + Some("FRESHNESS_PENDING") + } else if consumer.frames.try_send(frame.clone()).is_err() { + Some("LIVE_RESET_REQUIRED") + } else { + None + }; + if let Some(reason) = reason { + remove(&mut state, id, reason); + } + } + // Each replay's own frame is queued first. At equal proven cursor and + // data, future frames can move to an existing operation without a gap. + let Some(group) = state.groups.get(&generation) else { + return false; + }; + let target = state + .groups + .iter() + .find(|(other, target)| { + **other < generation + && target.key.same_operation(&group.key) + && target + .history + .back() + .is_some_and(|head| head.same_cursor(&frame)) + }) + .map(|(id, group)| (*id, group.key.clone())); + if let Some((target, key)) = target { + let ids = group.consumers.iter().copied().collect::>(); + let now = u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX); + for id in ids { + let Ok((ticket, new_owner)) = state.registry.join(key.clone(), now) else { + break; + }; + if new_owner { + state.registry.leave(ticket); + break; + } + let Some(consumer) = state.consumers.get_mut(&id) else { + state.registry.leave(ticket); + continue; + }; + let old = std::mem::replace(&mut consumer.ticket, ticket); + state.registry.leave(old); + if let Some(group) = state.groups.get_mut(&generation) { + group.consumers.remove(&id); + } + if let Some(group) = state.groups.get_mut(&target) { + group.consumers.insert(id); + } + state.handoffs = state.handoffs.saturating_add(1); + } + if state + .groups + .get(&generation) + .is_some_and(|group| group.consumers.is_empty()) + { + state.groups.remove(&generation); + return false; + } + } + true + } +} +fn remove(state: &mut State, id: u64, reason: &'static str) { + let Some(consumer) = state.consumers.remove(&id) else { + return; + }; + let generation = consumer.ticket.generation(); + if reason != "consumer_left" && reason != "complete" { + state.resets = state.resets.saturating_add(1); + } + let _ = consumer.reset.send(reason); + let last = state.registry.leave(consumer.ticket); + if let Some(group) = state.groups.get_mut(&generation) { + group.consumers.remove(&id); + } + if last { + state.groups.remove(&generation); + } +} +fn end(state: &mut State, generation: u64, reason: &'static str) { + let ids = state + .groups + .get(&generation) + .map(|group| group.consumers.iter().copied().collect::>()) + .unwrap_or_default(); + for id in ids { + remove(state, id, reason); + } + state.groups.remove(&generation); +} +async fn drive(owner: Weak, generation: u64) -> &'static str { + loop { + let input = match owner.upgrade().and_then(|owner| owner.input(generation)) { + Some(input) => input, + None => return "AUTH_EXPIRED", + }; + let expiry = Duration::from_secs(input.admission.expires_at.saturating_sub(super::now())); + let run = async { + let mut stream = (input.source)(input.request.clone()).await?; + while let Some(frame) = stream.next().await { + let frame = frame?; + if !owner + .upgrade() + .is_some_and(|owner| owner.emit(generation, &input, frame)) + { + return Err("upstream no longer owned".into()); + } + } + Ok::<(), String>(()) + }; + match tokio::time::timeout(expiry, run).await { + Ok(Ok(())) => return "complete", + Ok(Err(_)) => return "LIVE_RESET_REQUIRED", + Err(_) => continue, // Reauthenticate with a remaining unexpired consumer. + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures_util::{FutureExt, StreamExt}; + use std::sync::atomic::{AtomicUsize, Ordering}; + fn request() -> serde_json::Value { + serde_json::json!({"query":"subscription Watch { rows { title } }"}) + } + fn admission(request: &serde_json::Value) -> OriginAdmission { + let identity = OriginIdentity { + application: "app".into(), + endpoint: "origin".into(), + schema_hash: "schema".into(), + protocol_hash: "protocol".into(), + authorization_generation: "policy".into(), + cache_scope: "alice".into(), + }; + OriginAdmission { + key: OperationKey::from_origin(&identity, request).unwrap(), + identity, + operation: "operation".into(), + validator: "v1".into(), + validated_at: super::super::now(), + expires_at: super::super::now() + 30, + policy: SnapshotPolicy::Current, + } + } + fn frame(position: u64, proof: &str) -> serde_json::Value { + serde_json::json!({"data":{"rows":[{"title":"unchanged"}]},"extensions":{"distributed":{ + "protocolVersion":1,"schemaHash":"schema","authorizationGeneration":"policy","cacheScope":"alice","operation":"operation", + "snapshot":{"recordsComplete":true,"indexesComparable":true,"records":[],"indexes":[{"projection":"rows","scopeToken":"scope","position":position.to_string()}],"observations":[proof]}, + "live":{"supported":true,"reset":false,"cursors":[{"projection":"rows","position":position.to_string(),"token":format!("token-{position}")}]} + }}}) + } + struct DropCount(Arc); + impl Drop for DropCount { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + fn source() -> ( + LiveSourceFactory, + mpsc::UnboundedSender, + Arc, + Arc, + ) { + let (sender, receiver) = mpsc::unbounded_channel(); + let receiver = Arc::new(Mutex::new(Some(receiver))); + let calls = Arc::new(AtomicUsize::new(0)); + let dropped = Arc::new(AtomicUsize::new(0)); + let count = calls.clone(); + let drop_count = dropped.clone(); + let source: LiveSourceFactory = Arc::new(move |_| { + let receiver = receiver.lock().unwrap().take(); + let count = count.clone(); + let drop_count = drop_count.clone(); + async move { + let receiver = receiver.ok_or_else(|| "fixture source consumed".to_owned())?; + count.fetch_add(1, Ordering::SeqCst); + let guard = DropCount(drop_count); + Ok(futures_util::stream::unfold( + (receiver, guard), + |(mut receiver, guard)| async move { + receiver + .recv() + .await + .map(|frame| (Ok(frame), (receiver, guard))) + }, + ) + .boxed()) + } + .boxed() + }); + (source, sender, calls, dropped) + } + async fn wait(condition: impl Fn() -> bool) { + tokio::time::timeout(Duration::from_secs(3), async { + while !condition() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + #[tokio::test] + async fn hundred_live_leases_preserve_proof_and_last_leave() { + let live = NativeLive::new(LiveLimits::default()).unwrap(); + let request = request(); + let admission = admission(&request); + let (factory, sender, calls, dropped) = source(); + let mut leases = Vec::new(); + for _ in 0..100 { + leases.push( + live.join(admission.clone(), request.clone(), None, factory.clone()) + .unwrap(), + ); + } + assert_eq!((live.counts().0, live.counts().1), (1, 100)); + wait(|| calls.load(Ordering::SeqCst) == 1).await; + sender.send(frame(1, "first-proof")).unwrap(); + for lease in &mut leases { + assert_eq!( + lease.next().await.unwrap().unwrap().payload(), + &frame(1, "first-proof") + ); + } + sender.send(frame(1, "first-proof")).unwrap(); + sender.send(frame(1, "new-confirmation")).unwrap(); + for lease in &mut leases { + assert_eq!( + lease.next().await.unwrap().unwrap().payload(), + &frame(1, "new-confirmation") + ); + } + assert_eq!( + live.counts().5, + 1, + "only a full data-plus-proof duplicate is suppressed" + ); + while leases.len() > 1 { + leases.pop(); + } + assert_eq!((live.counts().0, live.counts().1), (1, 1)); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + leases.clear(); + wait(|| dropped.load(Ordering::SeqCst) == 1).await; + assert_eq!((live.counts().0, live.counts().1), (0, 0)); + } + #[tokio::test] + async fn independent_resume_handoff_keeps_prior_confirmation() { + let live = NativeLive::new(LiveLimits::default()).unwrap(); + let request = request(); + let (first, first_sender, _, first_dropped) = source(); + let mut existing = live + .join(admission(&request), request.clone(), None, first) + .unwrap(); + first_sender.send(frame(1, "existing")).unwrap(); + existing.next().await.unwrap().unwrap(); + let mut replay = request.clone(); + replay["extensions"] = serde_json::json!({"distributed":{"resume":{"cursors":[{"projection":"rows","position":"0","token":"old"}]}}}); + let (second, second_sender, _, second_dropped) = source(); + let mut replaying = live.join(admission(&replay), replay, None, second).unwrap(); + assert_eq!( + live.counts().0, + 2, + "different resume cursor initially replays independently" + ); + second_sender + .send(frame(1, "replayed-command-confirmation")) + .unwrap(); + assert_eq!( + replaying.next().await.unwrap().unwrap().payload(), + &frame(1, "replayed-command-confirmation") + ); + wait(|| live.counts().0 == 1).await; + assert_eq!(live.counts().6, 1); + wait(|| second_dropped.load(Ordering::SeqCst) == 1).await; + first_sender.send(frame(2, "next")).unwrap(); + assert_eq!( + existing.next().await.unwrap().unwrap().payload(), + &frame(2, "next") + ); + assert_eq!( + replaying.next().await.unwrap().unwrap().payload(), + &frame(2, "next") + ); + drop(existing); + assert_eq!(live.counts().1, 1); + assert_eq!(first_dropped.load(Ordering::SeqCst), 0); + drop(replaying); + wait(|| first_dropped.load(Ordering::SeqCst) == 1).await; + } + #[tokio::test] + async fn slow_consumer_gets_explicit_reset_and_releases_origin() { + let live = NativeLive::new(LiveLimits { + queue_frames: 1, + history_frames: 1, + ..Default::default() + }) + .unwrap(); + let request = request(); + let (factory, sender, _, dropped) = source(); + let mut lease = live + .join(admission(&request), request, None, factory) + .unwrap(); + sender.send(frame(1, "one")).unwrap(); + sender.send(frame(2, "two")).unwrap(); + wait(|| live.counts().0 == 0).await; + assert_eq!(lease.next().await.unwrap_err(), "LIVE_RESET_REQUIRED"); + assert_eq!(live.counts().3, 1); + wait(|| dropped.load(Ordering::SeqCst) == 1).await; + } + #[tokio::test] + async fn expired_consumer_does_not_own_remaining_consumers_upstream() { + let live = NativeLive::new(LiveLimits::default()).unwrap(); + let request = request(); + let mut early = admission(&request); + early.expires_at = super::super::now() + 1; + let (first, first_sender, first_calls, first_dropped) = source(); + let mut expires = live.join(early, request.clone(), None, first).unwrap(); + wait(|| first_calls.load(Ordering::SeqCst) == 1).await; + first_sender.send(frame(1, "before-renewal")).unwrap(); + expires.next().await.unwrap(); + let (second, second_sender, second_calls, second_dropped) = source(); + let mut remaining = live + .join(admission(&request), request, None, second) + .unwrap(); + remaining.next().await.unwrap(); + assert_eq!(expires.next().await.unwrap_err(), "AUTH_EXPIRED"); + drop(expires); + wait(|| second_calls.load(Ordering::SeqCst) == 1).await; + wait(|| first_dropped.load(Ordering::SeqCst) == 1).await; + second_sender.send(frame(2, "after-renewal")).unwrap(); + assert_eq!( + remaining.next().await.unwrap().unwrap().payload(), + &frame(2, "after-renewal") + ); + assert_eq!((live.counts().0, live.counts().1), (1, 1)); + drop(remaining); + wait(|| second_dropped.load(Ordering::SeqCst) == 1).await; + } +} diff --git a/src/gateway/native/live_transport.rs b/src/gateway/native/live_transport.rs new file mode 100644 index 000000000..02df27c03 --- /dev/null +++ b/src/gateway/native/live_transport.rs @@ -0,0 +1,676 @@ +use super::{ + graphql::{EmbeddedGraphql, GraphqlBinding, RemoteGraphql}, + live::{LiveSource, LiveSourceFactory}, + proxy, NativeDelivery, NativeInner, RequestContext, +}; +use crate::gateway::{BindingKind, GraphqlCapabilities, GraphqlExecutor}; +use axum::{ + body::Bytes, + extract::ws::{Message, WebSocket}, + http::{header, request::Parts, HeaderMap, HeaderValue, Method}, +}; +use futures_util::{FutureExt, SinkExt, StreamExt}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use tokio::sync::{mpsc, watch}; +use tokio_tungstenite::{ + tungstenite::{protocol::Role, Message as Upstream}, + WebSocketStream, +}; + +type OriginSocket = WebSocketStream; +#[derive(Clone)] +pub(super) struct Execution { + pub binding: GraphqlBinding, + pub inner: Arc, + pub declaration: BindingKind, + pub context: RequestContext, +} +impl Execution { + fn executor(&self) -> &GraphqlExecutor { + let BindingKind::Graphql { executor, .. } = &self.declaration else { + unreachable!() + }; + executor + } + fn capabilities(&self) -> GraphqlCapabilities { + let BindingKind::Graphql { capabilities, .. } = &self.declaration else { + unreachable!() + }; + *capabilities + } + fn source( + &self, + headers: HeaderMap, + query: Option, + init: serde_json::Value, + ) -> LiveSourceFactory { + let execution = self.clone(); + Arc::new(move |mut request: serde_json::Value| { + if let Some(extensions) = request + .get_mut("extensions") + .and_then(serde_json::Value::as_object_mut) + { + extensions.remove("gatewayDelivery"); + } + let execution = execution.clone(); + let headers = headers.clone(); + let init = init.clone(); + let query = query.clone(); + async move { + match &execution.binding { + GraphqlBinding::Embedded(embedded)=>{ + let engine=embedded.engine.as_ref().ok_or_else(||"custom embedded live source is not eligible".to_owned())?; + let (session,principal)=crate::graphql::http::resolve_gateway_ws_identity(engine,&headers,&init).await?.into_parts(); + let mut request:async_graphql::Request=serde_json::from_value(request).map_err(|_|"invalid live request".to_owned())?; + if let Some(principal)=principal {request=request.data(principal);} + Ok(engine.execute_stream(&session,request).map(|response|serde_json::to_value(response).map_err(|_|"invalid origin envelope".into())).boxed()) + } + GraphqlBinding::Remote(remote)=>{ + let GraphqlExecutor::Remote{origin}=execution.executor() else {return Err("invalid origin binding".into())}; + let mut socket=connect(&execution.inner,origin,remote,&execution.context,headers,query.as_deref(),&init).await?; + socket.send(Upstream::Text(serde_json::json!({"id":"upstream","type":"subscribe","payload":request}).to_string().into())).await.map_err(|_|"upstream subscribe failed")?; + Ok(origin_stream(socket)) + } + } + }.boxed() + }) + } +} +async fn connect( + inner: &Arc, + origin: &str, + remote: &RemoteGraphql, + context: &RequestContext, + mut headers: HeaderMap, + query: Option<&str>, + init: &serde_json::Value, +) -> Result { + let path = remote.live_path.as_deref().ok_or("live endpoint missing")?; + proxy::prepare_headers(&mut headers, inner, context, true) + .map_err(|_| "invalid live headers")?; + headers.remove(header::CONTENT_LENGTH); + headers.remove(header::CONTENT_TYPE); + let key = tokio_tungstenite::tungstenite::handshake::client::generate_key(); + headers.insert( + header::SEC_WEBSOCKET_KEY, + HeaderValue::from_str(&key).map_err(|_| "invalid websocket key")?, + ); + headers.insert( + header::SEC_WEBSOCKET_VERSION, + HeaderValue::from_static("13"), + ); + headers.insert( + header::SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static("graphql-transport-ws"), + ); + proxy::add_hop(&mut headers, inner).map_err(|_| "gateway loop")?; + let url = format!( + "{}{path}{}", + origin.trim_end_matches('/'), + query.map(|q| format!("?{q}")).unwrap_or_default() + ); + let response = tokio::time::timeout( + inner.options.limits.response_header_timeout, + inner.client.get(url).headers(headers).send(), + ) + .await + .map_err(|_| "upstream handshake timed out")? + .map_err(|_| "upstream handshake failed")?; + if response.status() == axum::http::StatusCode::UNAUTHORIZED { + return Err("AUTH_EXPIRED".into()); + } + if response.status() != axum::http::StatusCode::SWITCHING_PROTOCOLS + || response + .headers() + .get(header::SEC_WEBSOCKET_ACCEPT) + .and_then(|v| v.to_str().ok()) + != Some( + tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes()) + .as_str(), + ) + || response + .headers() + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|v| v.to_str().ok()) + != Some("graphql-transport-ws") + { + return Err("invalid upstream handshake".into()); + } + let socket = response + .upgrade() + .await + .map_err(|_| "upstream upgrade failed")?; + let mut socket = WebSocketStream::from_raw_socket( + socket, + Role::Client, + Some( + tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(inner.options.limits.request_body_bytes)), + ), + ) + .await; + socket + .send(Upstream::Text( + serde_json::json!({"type":"connection_init","payload":init}) + .to_string() + .into(), + )) + .await + .map_err(|_| "upstream init failed")?; + tokio::time::timeout(inner.options.limits.response_header_timeout, async { + loop { + match socket.next().await { + Some(Ok(Upstream::Text(text))) => { + let value: serde_json::Value = + serde_json::from_str(&text).map_err(|_| "invalid upstream init")?; + if value["type"] == "connection_ack" { + return Ok(()); + } + if value["type"] == "connection_error" { + return Err("AUTH_EXPIRED"); + } + } + Some(Ok(Upstream::Ping(data))) => { + socket + .send(Upstream::Pong(data)) + .await + .map_err(|_| "upstream ping failed")?; + } + _ => return Err("AUTH_EXPIRED"), + } + } + }) + .await + .map_err(|_| "upstream admission timed out")??; + Ok(socket) +} +fn origin_stream(socket: OriginSocket) -> LiveSource { + futures_util::stream::unfold(socket, |mut socket| async move { + loop { + match socket.next().await { + Some(Ok(Upstream::Text(text))) => { + let value = match serde_json::from_str::(&text) { + Ok(value) => value, + Err(_) => return Some((Err("invalid upstream frame".into()), socket)), + }; + match value["type"].as_str() { + Some("next") if value["id"] == "upstream" => { + return Some((Ok(value["payload"].clone()), socket)) + } + Some("complete") if value["id"] == "upstream" => return None, + Some("error") => { + return Some((Err("upstream operation failed".into()), socket)) + } + Some("ping") => { + let _ = socket + .send(Upstream::Text( + serde_json::json!({"type":"pong","payload":value["payload"]}) + .to_string() + .into(), + )) + .await; + } + _ => {} + } + } + Some(Ok(Upstream::Ping(data))) => { + let _ = socket.send(Upstream::Pong(data)).await; + } + Some(Ok(Upstream::Pong(_))) => {} + Some(Ok(Upstream::Close(_))) | None => { + return Some(( + Err("upstream disconnected before operation completion".into()), + socket, + )); + } + _ => return Some((Err("upstream stream failed".into()), socket)), + } + } + }) + .boxed() +} + +pub(super) async fn remote( + mut client: WebSocket, + mut origin: OriginSocket, + execution: Execution, + parts: Parts, + coordinator: Arc, + protocol: &'static str, +) { + let init = match initial_message( + &mut client, + execution.inner.options.limits.response_header_timeout, + ) + .await + { + Some(init) => init, + None => return, + }; + if origin + .send(Upstream::Text( + serde_json::json!({"type":"connection_init","payload":init}) + .to_string() + .into(), + )) + .await + .is_err() + { + return; + } + let ack = tokio::time::timeout( + execution.inner.options.limits.response_header_timeout, + async { + loop { + match origin.next().await { + Some(Ok(Upstream::Text(text))) => { + let value: serde_json::Value = serde_json::from_str(&text).ok()?; + if value["type"] == "connection_ack" { + return Some(text.to_string()); + } + if value["type"] == "connection_error" { + let _ = client.send(Message::Text(text.to_string().into())).await; + return None; + } + } + Some(Ok(Upstream::Ping(data))) => { + let _ = origin.send(Upstream::Pong(data)).await; + } + Some(Ok(Upstream::Close(frame))) => { + let _ = client + .send(Message::Close(frame.map(|frame| { + axum::extract::ws::CloseFrame { + code: frame.code.into(), + reason: frame.reason.to_string().into(), + } + }))) + .await; + return None; + } + _ => return None, + } + } + }, + ) + .await + .ok() + .flatten(); + let Some(ack) = ack else { + return; + }; + if client.send(Message::Text(ack.into())).await.is_err() { + return; + } + // The temporary per-consumer origin connection authenticated connection_init. + // It owns no subscription and is closed before shared operation admission. + let _ = origin.close(None).await; + drop(origin); + client_loop(client, execution, parts, coordinator, protocol, init).await; +} +pub(super) async fn embedded( + mut client: WebSocket, + embedded: EmbeddedGraphql, + execution: Execution, + parts: Parts, + coordinator: Arc, + protocol: &'static str, +) { + let init = match initial_message( + &mut client, + execution.inner.options.limits.response_header_timeout, + ) + .await + { + Some(init) => init, + None => return, + }; + let Some(engine) = &embedded.engine else { + return; + }; + if crate::graphql::http::resolve_gateway_ws_identity(engine, &parts.headers, &init) + .await + .is_err() + { + let _ = client + .send(Message::Close(Some(axum::extract::ws::CloseFrame { + code: 4401, + reason: "unauthorized".into(), + }))) + .await; + return; + } + if client + .send(Message::Text( + serde_json::json!({"type":"connection_ack"}) + .to_string() + .into(), + )) + .await + .is_err() + { + return; + } + client_loop(client, execution, parts, coordinator, protocol, init).await; +} +async fn initial_message(client: &mut WebSocket, timeout: Duration) -> Option { + let message = tokio::time::timeout(timeout, client.recv()) + .await + .ok()?? + .ok()?; + let Message::Text(text) = message else { + return None; + }; + if text.len() > 65536 { + return None; + } + let value: serde_json::Value = serde_json::from_str(&text).ok()?; + (value["type"] == "connection_init").then(|| { + value + .get("payload") + .cloned() + .unwrap_or_else(|| serde_json::json!({})) + }) +} +struct Operation(tokio::task::JoinHandle<()>); +impl Drop for Operation { + fn drop(&mut self) { + self.0.abort(); + } +} +fn packet(id: &str, kind: &str, payload: serde_json::Value) -> serde_json::Value { + serde_json::json!({"id":id,"type":kind,"payload":payload}) +} +fn failure(id: &str, code: &str) -> serde_json::Value { + packet( + id, + "error", + serde_json::json!([{"message":code,"extensions":{"code":code}}]), + ) +} +async fn client_loop( + client: WebSocket, + execution: Execution, + mut parts: Parts, + coordinator: Arc, + protocol: &'static str, + init: serde_json::Value, +) { + let source = execution.source( + parts.headers.clone(), + parts.uri.query().map(str::to_owned), + init.clone(), + ); + parts.method = Method::POST; + proxy::strip_hop_headers(&mut parts.headers); + let parts = Arc::new(parts); + let (mut sink, mut stream) = client.split(); + let (out, mut output) = mpsc::channel::(32); + let (close, mut closed) = watch::channel(false); + let mut operations = BTreeMap::::new(); + let mut close_frame = Some(axum::extract::ws::CloseFrame { + code: 1013, + reason: "LIVE_RESET_REQUIRED".into(), + }); + loop { + tokio::select! {biased; + _=closed.changed()=>break, + next=output.recv()=>{ + let Some(value)=next else {break;}; + let id=value["id"].as_str().map(str::to_owned); + let terminal=matches!(value["type"].as_str(),Some("complete"|"error")); + let sending=sink.send(Message::Text(value.to_string().into())); + tokio::select! { _=closed.changed()=>break, sent=tokio::time::timeout(execution.inner.options.limits.read_timeout,sending)=>{if !matches!(sent,Ok(Ok(()))){break;}} } + if terminal {if let Some(id)=id {operations.remove(&id);}} + } + next=stream.next()=>{ + let Some(Ok(message))=next else {break;}; + match message { + Message::Text(text)=>{ + let Ok(value)=serde_json::from_str::(&text) else {break;}; + match value["type"].as_str() { + Some(kind @ ("subscribe"|"start")) if kind == if protocol == "graphql-ws" {"start"} else {"subscribe"}=>{ + let Some(id)=value["id"].as_str() else {break;}; + if id.is_empty()||id.len()>256||operations.len()>=128||operations.contains_key(id){break;} + if let Err(error)=crate::gateway::graphql::admit_request(&value["payload"],execution.capabilities()) {let _=out.try_send(packet(id,if protocol=="graphql-ws"{"data"}else{"next"},error.envelope()));let _=out.try_send(serde_json::json!({"id":id,"type":"complete"}));continue;} + let id=id.to_owned();let payload=value["payload"].clone();let execution=execution.clone();let parts=parts.clone();let coordinator=coordinator.clone();let source=source.clone();let out=out.clone();let close=close.clone();let init=init.clone();let task_id=id.clone(); + operations.insert(id,Operation(tokio::spawn(async move {operation(task_id,payload,execution,parts,coordinator,source,out,close,protocol,init).await;}))); + } + Some(kind @ ("complete"|"stop")) if kind == if protocol == "graphql-ws" {"stop"} else {"complete"}=>{if let Some(id)=value["id"].as_str(){operations.remove(id);}} + Some("ping")=>{if !matches!(tokio::time::timeout(execution.inner.options.limits.read_timeout,sink.send(Message::Text(serde_json::json!({"type":"pong","payload":value["payload"]}).to_string().into()))).await,Ok(Ok(()))){break;}} + Some("pong")=>{}, + Some("connection_terminate") if protocol == "graphql-ws"=>{close_frame=Some(axum::extract::ws::CloseFrame{code:1000,reason:"".into()});break;}, + _=>break, + } + } + Message::Ping(data)=>{if !matches!(tokio::time::timeout(execution.inner.options.limits.read_timeout,sink.send(Message::Pong(data))).await,Ok(Ok(()))){break;}}, + Message::Pong(_)=>{},Message::Close(frame)=>{close_frame=frame;break;},_=>break, + } + } + } + } + drop(operations); + let _ = tokio::time::timeout( + Duration::from_millis(100), + sink.send(Message::Close(close_frame)), + ) + .await; +} +#[allow(clippy::too_many_arguments)] +async fn operation( + id: String, + mut payload: serde_json::Value, + execution: Execution, + parts: Arc, + coordinator: Arc, + source: LiveSourceFactory, + out: mpsc::Sender, + close: watch::Sender, + protocol: &str, + init: serde_json::Value, +) { + let next = if protocol == "graphql-ws" { + "data" + } else { + "next" + }; + if !payload["extensions"].is_object() { + payload["extensions"] = serde_json::json!({}); + } + payload["extensions"]["gatewayDelivery"] = + serde_json::json!({"action":"execute","connectionInit":init}); + let kind = crate::gateway::graphql::operation_kind( + payload["query"].as_str().unwrap_or(""), + payload["operationName"].as_str(), + ); + if kind != Ok(crate::gateway::graphql::OperationKind::Subscription) { + let request = super::delivery::request(&parts, payload); + let result = execution + .binding + .execute_operation( + &execution.inner, + &execution.declaration, + execution.context, + request, + ) + .await; + let value = match axum::body::to_bytes( + result.into_body(), + execution.inner.options.limits.request_body_bytes, + ) + .await + { + Ok(body) => serde_json::from_slice(&body).unwrap_or_else( + |_| serde_json::json!({"errors":[{"message":"origin unavailable"}]}), + ), + Err(_) => serde_json::json!({"errors":[{"message":"origin unavailable"}]}), + }; + if out.send(packet(&id, next, value)).await.is_ok() { + let _ = out + .send(serde_json::json!({"id":id,"type":"complete"})) + .await; + } + return; + } + let freshness = match payload["extensions"].get("gatewayFreshness") { + Some(value) => match crate::gateway::delivery::FreshnessContext::parse(value) { + Ok(value) => Some(value), + Err(_) => { + let _ = out.send(failure(&id, "FRESHNESS_SCOPE_CHANGED")).await; + return; + } + }, + None => None, + }; + let admission = super::delivery::validate( + &execution.binding, + &execution.inner, + execution.executor(), + &execution.context, + &parts, + &payload, + ) + .await; + match admission { + super::delivery::AdmissionResult::Eligible(admission) => { + let Some(live) = &coordinator.live else { + return; + }; + let mut lease = match live.join(admission, payload, freshness, source) { + Ok(lease) => lease, + Err(_) => { + let _ = out.send(failure(&id, "LIVE_RESET_REQUIRED")).await; + return; + } + }; + loop { + match lease.next().await { + Ok(Some(frame)) => { + let message = packet(&id, next, frame.payload().clone()); + tokio::select! { + sent=out.send(message)=>{if sent.is_err(){break;}}, + reason=lease.interrupted()=>{let _=out.try_send(failure(&id,reason));let _=close.send(true);break;} + } + } + Ok(None) => { + let _ = out + .send(serde_json::json!({"id":id,"type":"complete"})) + .await; + break; + } + Err(reason) => { + if out.try_send(failure(&id, reason)).is_err() { + let _ = close.send(true); + } + break; + } + } + } + } + super::delivery::AdmissionResult::Bypass => match source(payload).await { + Ok(mut stream) => { + while let Some(result) = stream.next().await { + match result { + Ok(payload) => { + if out.send(packet(&id, next, payload)).await.is_err() { + return; + } + } + Err(_) => { + let _ = out.send(failure(&id, "LIVE_RESET_REQUIRED")).await; + return; + } + } + } + let _ = out + .send(serde_json::json!({"id":id,"type":"complete"})) + .await; + } + Err(_) => { + let _ = out.send(failure(&id, "LIVE_RESET_REQUIRED")).await; + } + }, + super::delivery::AdmissionResult::Error(response) => { + let bytes = axum::body::to_bytes(response.into_body(), 65536) + .await + .unwrap_or_else(|_| Bytes::new()); + let value = serde_json::from_slice(&bytes).unwrap_or_else( + |_| serde_json::json!({"errors":[{"message":"origin unavailable"}]}), + ); + let _ = out.send(packet(&id, next, value)).await; + let _ = out + .send(serde_json::json!({"id":id,"type":"complete"})) + .await; + } + } +} + +pub(super) async fn upgrade_embedded( + execution: Execution, + request: axum::http::Request, + embedded: EmbeddedGraphql, + coordinator: Arc, +) -> axum::response::Response { + use axum::{ + extract::{ws::WebSocketUpgrade, FromRequestParts}, + response::IntoResponse, + }; + let permit = match execution.inner.permits.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => return super::response(axum::http::StatusCode::SERVICE_UNAVAILABLE), + }; + let (mut parts, _) = request.into_parts(); + let offered = parts + .headers + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let protocol = if offered + .split(',') + .any(|p| p.trim() == "graphql-transport-ws") + { + "graphql-transport-ws" + } else if offered.split(',').any(|p| p.trim() == "graphql-ws") { + "graphql-ws" + } else { + return super::response(axum::http::StatusCode::BAD_REQUEST); + }; + let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &()).await { + Ok(upgrade) => upgrade, + Err(error) => return error.into_response(), + }; + if proxy::prepare_headers( + &mut parts.headers, + &execution.inner, + &execution.context, + false, + ) + .is_err() + { + return super::response(axum::http::StatusCode::BAD_REQUEST); + } + let lifetime = execution.context.identity().map_or( + execution.inner.options.limits.upgrade_lifetime, + |id| { + execution + .inner + .options + .limits + .upgrade_lifetime + .min(Duration::from_secs( + id.expires_at().saturating_sub(super::now()), + )) + }, + ); + upgrade + .protocols([protocol]) + .max_message_size(execution.inner.options.limits.request_body_bytes) + .on_upgrade(move |socket| async move { + let _permit = permit; + let _ = tokio::time::timeout( + lifetime, + self::embedded(socket, embedded, execution, parts, coordinator, protocol), + ) + .await; + }) + .into_response() +} diff --git a/src/gateway/native/mod.rs b/src/gateway/native/mod.rs new file mode 100644 index 000000000..bbb0c7481 --- /dev/null +++ b/src/gateway/native/mod.rs @@ -0,0 +1,468 @@ +//! Opt-in native HTTP gateway. Construction starts no listener or projector. +mod assets; +#[cfg(feature = "gateway-graphql-native")] +mod graphql; +mod proxy; +#[cfg(feature = "gateway-graphql-native")] +pub use graphql::{EmbeddedGraphql, GraphqlBinding, RemoteGraphql}; + +use super::{ + Admission, AuthError, BackendCredential, BindingKind, Credentials, Gateway, GatewayAdapter, + GatewayError, Rejection, RequestContext, SelectedRoute, +}; +pub use assets::{Asset, StaticAssets}; +use axum::{ + body::Body, + extract::State, + http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode}, + response::Response, + Router, +}; +use std::{ + collections::BTreeMap, + future::Future, + pin::Pin, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Semaphore; +use tower::ServiceExt; +use url::Url; + +/// Boxed native provider result. Providers receive credentials, never identity +/// headers. Use one shared validator/session provider for all route owners. +type AuthFuture = Pin> + Send>>; + +/// Native provider registry entry. Worker adapters can use the local-future +/// portable AuthProvider trait without this Send/Sync runtime requirement. +#[derive(Clone)] +pub struct NativeAuth(Arc AuthFuture + Send + Sync>); +impl NativeAuth { + /// Adapt a configured native provider; the closure runs on every request. + pub fn new(provider: F) -> Self + where + F: Fn(Credentials) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + Self(Arc::new(move |credentials| Box::pin(provider(credentials)))) + } + /// Gateway without identity admission. Cookies remain opaque for delegated + /// UI/auth handlers; bearer credentials fail closed rather than being trusted. + pub fn anonymous() -> Self { + Self::new(|credentials| async move { + if credentials.authorization.is_some() { + return Err(AuthError::Unauthorized); + } + RequestContext::from_provider(None, "anonymous-v1", BackendCredential::None) + .map_err(|_| AuthError::Unavailable) + }) + } +} + +/// Explicit proxy resource limits, shared by all mounted targets. +#[derive(Clone, Debug)] +pub struct ProxyLimits { + /// Maximum incoming body size. Known oversize bodies fail before forwarding. + pub request_body_bytes: usize, + /// Maximum active HTTP streams and upgraded connections; overload gets 503. + pub concurrent_requests: usize, + /// Connect and response-header wait bound. + pub response_header_timeout: Duration, + /// Idle time between response reads; no whole-response buffering. + pub read_timeout: Duration, + /// Maximum upgraded-connection lifetime, also capped by identity expiry. + pub upgrade_lifetime: Duration, +} +impl Default for ProxyLimits { + fn default() -> Self { + Self { + request_body_bytes: 16 * 1024 * 1024, + concurrent_requests: 1024, + response_header_timeout: Duration::from_secs(30), + read_timeout: Duration::from_secs(60), + upgrade_lifetime: Duration::from_secs(3600), + } + } +} + +/// Host configuration. The public origin is trusted configuration, not Host or +/// forwarded headers from a request. Upstreams cannot point back to this origin. +#[derive(Clone, Debug)] +pub struct NativeOptions { + /// Absolute HTTP(S) public origin, without path or credentials. + pub public_origin: String, + /// Resource limits applied before/throughout forwarding. + pub limits: ProxyLimits, + /// Deployment-specific incoming identity/secret names to strip in addition + /// to the framework denylist. Values never appear in configuration errors. + pub strip_headers: Vec, +} +impl NativeOptions { + /// Default bounded proxy settings for this public origin. + pub fn new(public_origin: impl Into) -> Self { + Self { + public_origin: public_origin.into(), + limits: ProxyLimits::default(), + strip_headers: Vec::new(), + } + } +} + +/// Runtime resource for one portable binding. Duplicate or incompatible binding +/// entries are rejected before serving. Application handlers receive admitted +/// RequestContext in request extensions; backend authorization stays theirs. +#[derive(Clone)] +pub enum NativeBinding { + /// Complete application router; URI is preserved, without prefix stripping. + Handler(Router), + /// Embedded or complete remote GraphQL executor. + #[cfg(feature = "gateway-graphql-native")] + Graphql(GraphqlBinding), + /// GraphQL with an explicitly allocated bounded delivery coordinator. + #[cfg(all(feature = "gateway-graphql-native", feature = "gateway-delivery"))] + GraphqlWithDelivery(GraphqlBinding, Arc), + /// Configured UI proxy. Upgrades are disabled unless explicitly selected. + UiProxy { + /// Whether this target may negotiate a WebSocket upgrade. + websocket: bool, + }, + /// Bounded, preloaded static inventory; no request-selected filesystem I/O. + Assets(StaticAssets), + /// Named route admission policy. + Admission(Admission), +} + +/// Validated native adapter, shareable between explicit application mounts. +#[derive(Clone)] +pub struct NativeGateway(Arc); +struct NativeInner { + gateway: Gateway, + options: NativeOptions, + origin: Url, + bindings: BTreeMap, + auth: NativeAuth, + client: reqwest::Client, + permits: Arc, + hop_id: String, +} + +impl NativeGateway { + /// Validate runtime bindings and construct only the selected adapter. Caller + /// owns the listener/process lifecycle; this method opens no connections. + pub fn new( + gateway: Gateway, + options: NativeOptions, + bindings: impl IntoIterator, + auth: NativeAuth, + ) -> Result { + super::config::validate_origin(&options.public_origin)?; + let origin = Url::parse(&options.public_origin) + .map_err(|_| GatewayError("invalid public origin"))?; + let limits = &options.limits; + if limits.request_body_bytes == 0 + || limits.concurrent_requests == 0 + || limits.concurrent_requests > 65536 + || limits.response_header_timeout.is_zero() + || limits.read_timeout.is_zero() + || limits.upgrade_lifetime.is_zero() + { + return Err(GatewayError("invalid native proxy limits")); + } + if options.strip_headers.len() > 128 + || options + .strip_headers + .iter() + .any(|name| HeaderName::from_bytes(name.as_bytes()).is_err()) + { + return Err(GatewayError("invalid identity header strip list")); + } + let mut registry = BTreeMap::new(); + for (id, binding) in bindings { + let declaration = gateway + .binding(&id) + .ok_or(GatewayError("undeclared native binding"))?; + #[allow(unused_mut)] + let mut compatible = matches!( + (&declaration.kind, &binding), + (BindingKind::Handler, NativeBinding::Handler(_)) + | (BindingKind::UiProxy { .. }, NativeBinding::UiProxy { .. }) + | (BindingKind::Assets, NativeBinding::Assets(_)) + | (BindingKind::Admission, NativeBinding::Admission(_)) + ); + #[cfg(feature = "gateway-graphql-native")] + if let ( + BindingKind::Graphql { + executor, + capabilities, + delivery, + schema_extensions, + }, + NativeBinding::Graphql(binding), + ) = (&declaration.kind, &binding) + { + binding.validate( + executor, + *capabilities, + *delivery, + schema_extensions, + &origin, + )?; + compatible = true; + } + #[cfg(all(feature = "gateway-graphql-native", feature = "gateway-delivery"))] + if let ( + BindingKind::Graphql { + executor, + capabilities, + delivery, + schema_extensions, + }, + NativeBinding::GraphqlWithDelivery(binding, coordinator), + ) = (&declaration.kind, &binding) + { + if *delivery != coordinator.capabilities() { + return Err(GatewayError("delivery resource does not match declaration")); + } + binding.validate( + executor, + *capabilities, + super::DeliveryCapabilities::default(), + schema_extensions, + &origin, + )?; + compatible = true; + } + if !compatible { + return Err(GatewayError("incompatible native binding")); + } + if let BindingKind::UiProxy { origin: upstream } = &declaration.kind { + let upstream = + Url::parse(upstream).map_err(|_| GatewayError("invalid upstream"))?; + if upstream.origin() == origin.origin() { + return Err(GatewayError("upstream points to public gateway")); + } + } + if registry.insert(id, binding).is_some() { + return Err(GatewayError("duplicate native binding")); + } + } + for route in gateway.routes() { + for id in std::iter::once(&route.target).chain(&route.admission) { + if !registry.contains_key(id) { + return Err(GatewayError("missing native runtime binding")); + } + } + } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .no_proxy() + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .connect_timeout(limits.response_header_timeout) + .read_timeout(limits.read_timeout) + .build() + .map_err(|_| GatewayError("cannot construct proxy client"))?; + let permits = Arc::new(Semaphore::new(limits.concurrent_requests)); + Ok(Self(Arc::new(NativeInner { + gateway, + options, + origin, + bindings: registry, + auth, + client, + permits, + hop_id: uuid::Uuid::now_v7().to_string(), + }))) + } + + /// Mount on the application's HTTP server. Every path goes through portable + /// selection/admission before an application handler or proxy executes. + pub fn router(self) -> Router { + Router::new().fallback(Self::handle).with_state(self) + } + + async fn handle(State(this): State, request: Request) -> Response { + if let Some(hops) = request.headers().get("x-distributed-gateway-hops") { + let Ok(hops) = hops.to_str() else { + return response(StatusCode::BAD_REQUEST); + }; + if hops.len() > 512 + || hops.split(',').count() >= 8 + || hops.split(',').any(|hop| hop.trim() == this.0.hop_id) + { + return response(StatusCode::LOOP_DETECTED); + } + } + this.0.gateway.dispatch(&this, request).await + } +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(u64::MAX, |d| d.as_secs()) +} +fn response(status: StatusCode) -> Response { + Response::builder() + .status(status) + .body(Body::empty()) + .expect("static response") +} +fn auth_response(error: AuthError) -> Response { + response(match error { + AuthError::Unauthorized => StatusCode::UNAUTHORIZED, + AuthError::Forbidden => StatusCode::FORBIDDEN, + AuthError::Unavailable => StatusCode::SERVICE_UNAVAILABLE, + }) +} + +fn credentials(headers: &HeaderMap) -> Result { + let mut authorization = headers.get_all(header::AUTHORIZATION).iter(); + let authorization_value = authorization + .next() + .map(|v| v.to_str().map(str::to_owned)) + .transpose() + .map_err(|_| AuthError::Unauthorized)?; + if authorization.next().is_some() { + return Err(AuthError::Unauthorized); + } + let cookies = headers + .get_all(header::COOKIE) + .iter() + .map(|v| v.to_str()) + .collect::, _>>() + .map_err(|_| AuthError::Unauthorized)?; + if authorization_value.as_ref().map_or(0, String::len) + + cookies.iter().map(|v| v.len()).sum::() + > 32768 + { + return Err(AuthError::Unauthorized); + } + Ok(Credentials { + authorization: authorization_value, + cookie: if cookies.is_empty() { + None + } else { + Some(cookies.join("; ")) + }, + }) +} + +impl GatewayAdapter for NativeGateway { + type Request = Request; + type Context = RequestContext; + type Response = Response; + fn method<'a>(&self, request: &'a Self::Request) -> &'a str { + request.method().as_str() + } + fn target<'a>(&self, request: &'a Self::Request) -> &'a str { + request.uri().path_and_query().map_or("/", |p| p.as_str()) + } + fn admit( + &self, + selected: SelectedRoute<'_>, + request: &Self::Request, + ) -> impl Future> { + // Own credential metadata before awaiting. A streaming HTTP Body is + // Send but not Sync, so never retain &Request across provider I/O. + let credentials = credentials(request.headers()); + let auth = self.0.auth.clone(); + let policies: Vec<_> = selected + .route() + .admission + .iter() + .filter_map(|name| match self.0.bindings.get(name) { + Some(NativeBinding::Admission(policy)) => Some(policy.clone()), + _ => None, + }) + .collect(); + async move { + let context = (auth.0)(credentials.map_err(auth_response)?) + .await + .map_err(auth_response)?; + Admission::Public + .check(&context, now()) + .map_err(auth_response)?; + for policy in policies { + policy.check(&context, now()).map_err(auth_response)?; + } + Ok(context) + } + } + async fn execute( + &self, + selected: SelectedRoute<'_>, + context: RequestContext, + mut request: Self::Request, + ) -> Response { + let Some(binding) = self.0.bindings.get(&selected.binding().id) else { + return response(StatusCode::SERVICE_UNAVAILABLE); + }; + match binding { + NativeBinding::Handler(handler) => { + if proxy::prepare_headers(request.headers_mut(), &self.0, &context, false).is_err() + { + return response(StatusCode::BAD_REQUEST); + } + request.extensions_mut().insert(context); + match handler.clone().oneshot(request).await { + Ok(response) => response, + Err(never) => match never {}, + } + } + NativeBinding::UiProxy { websocket } => { + let BindingKind::UiProxy { origin } = &selected.binding().kind else { + return response(StatusCode::SERVICE_UNAVAILABLE); + }; + proxy::forward(&self.0, origin, *websocket, context, request).await + } + #[cfg(feature = "gateway-graphql-native")] + NativeBinding::Graphql(binding) => { + binding + .execute(&self.0, &selected.binding().kind, context, request) + .await + } + #[cfg(all(feature = "gateway-graphql-native", feature = "gateway-delivery"))] + NativeBinding::GraphqlWithDelivery(binding, coordinator) => { + request.extensions_mut().insert(Arc::clone(coordinator)); + binding + .execute(&self.0, &selected.binding().kind, context, request) + .await + } + NativeBinding::Assets(assets) => assets.serve(request), + NativeBinding::Admission(_) => response(StatusCode::SERVICE_UNAVAILABLE), + } + } + fn reject(&self, rejection: Rejection<'_>) -> Response { + match rejection { + Rejection::BadRequest => response(StatusCode::BAD_REQUEST), + Rejection::NotFound => response(StatusCode::NOT_FOUND), + Rejection::MethodNotAllowed(selected) => { + let mut response = response(StatusCode::METHOD_NOT_ALLOWED); + if let super::Methods::Only(methods) = &selected.route().methods { + if let Ok(allow) = HeaderValue::from_str(&methods.join(", ")) { + response.headers_mut().insert(header::ALLOW, allow); + } + } + response + } + } + } +} + +#[cfg(all(feature = "gateway-graphql-native", feature = "gateway-delivery"))] +mod delivery; +#[cfg(all(feature = "gateway-graphql-native", feature = "gateway-delivery"))] +pub use delivery::{NativeDelivery, NativeDeliveryOptions}; + +#[cfg(all(feature = "gateway-graphql-native", feature = "gateway-delivery"))] +mod flight; + +#[cfg(all(feature = "gateway-graphql-native", feature = "gateway-delivery"))] +mod live; + +#[cfg(all(feature = "gateway-graphql-native", feature = "gateway-delivery"))] +mod live_transport; diff --git a/src/gateway/native/proxy.rs b/src/gateway/native/proxy.rs new file mode 100644 index 000000000..64e9a50f9 --- /dev/null +++ b/src/gateway/native/proxy.rs @@ -0,0 +1,256 @@ +use super::{ + response, BackendCredential, Body, HeaderMap, HeaderValue, NativeInner, Request, + RequestContext, Response, StatusCode, Url, +}; +use axum::http::header; +use futures_util::{Stream, StreamExt}; +use std::{io, time::Duration}; + +pub(super) fn strip_hop_headers(headers: &mut HeaderMap) { + let named: Vec<_> = headers + .get_all(header::CONNECTION) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(',')) + .map(|v| v.trim().to_owned()) + .collect(); + for name in named { + headers.remove(name); + } + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ] { + headers.remove(name); + } +} + +pub(super) fn prepare_headers( + headers: &mut HeaderMap, + inner: &NativeInner, + context: &RequestContext, + upgrade: bool, +) -> Result<(), ()> { + strip_hop_headers(headers); + let remove: Vec<_> = headers + .keys() + .filter(|name| { + super::super::is_untrusted_identity_header(name.as_str()) + || inner + .options + .strip_headers + .iter() + .any(|s| s.eq_ignore_ascii_case(name.as_str())) + }) + .cloned() + .collect(); + for name in remove { + headers.remove(name); + } + headers.remove(header::AUTHORIZATION); + if let BackendCredential::Bearer(token) = context.backend_credential() { + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| ())?, + ); + } + let authority = &inner.origin[url::Position::BeforeHost..url::Position::AfterPort]; + let host = HeaderValue::from_str(authority).map_err(|_| ())?; + headers.insert(header::HOST, host.clone()); + headers.insert("x-forwarded-host", host); + headers.insert( + "x-forwarded-proto", + HeaderValue::from_str(inner.origin.scheme()).map_err(|_| ())?, + ); + if upgrade { + headers.insert(header::CONNECTION, HeaderValue::from_static("upgrade")); + headers.insert(header::UPGRADE, HeaderValue::from_static("websocket")); + } + Ok(()) +} + +pub(super) async fn forward( + inner: &NativeInner, + origin: &str, + allow_websocket: bool, + context: RequestContext, + request: Request, +) -> Response { + let permit = match inner.permits.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => return response(StatusCode::SERVICE_UNAVAILABLE), + }; + forward_with_permit( + inner, + origin, + allow_websocket, + context, + request, + Some(permit), + ) + .await +} + +pub(super) async fn forward_with_permit( + inner: &NativeInner, + origin: &str, + allow_websocket: bool, + context: RequestContext, + mut request: Request, + permit: Option, +) -> Response { + if request + .headers() + .get(header::CONTENT_LENGTH) + .is_some_and(|v| { + v.to_str() + .ok() + .and_then(|s| s.parse::().ok()) + .is_none_or(|n| n > inner.options.limits.request_body_bytes as u64) + }) + { + return response(StatusCode::PAYLOAD_TOO_LARGE); + } + let wants_upgrade = request.headers().contains_key(header::UPGRADE); + if wants_upgrade + && (!allow_websocket + || request.method() != "GET" + || !request.headers()[header::UPGRADE] + .as_bytes() + .eq_ignore_ascii_case(b"websocket")) + { + return response(StatusCode::BAD_REQUEST); + } + let on_upgrade = wants_upgrade.then(|| hyper::upgrade::on(&mut request)); + if prepare_headers(request.headers_mut(), inner, &context, wants_upgrade).is_err() { + return response(StatusCode::BAD_REQUEST); + } + if add_hop(request.headers_mut(), inner).is_err() { + return response(StatusCode::BAD_REQUEST); + } + let target = request.uri().path_and_query().map_or("/", |p| p.as_str()); + // Path ownership already rejected traversal/authority aliases. A configured + // origin is concatenated with origin-form target, never joined to a URL. + let url = format!("{}{target}", origin.trim_end_matches('/')); + let (parts, body) = request.into_parts(); + let max = inner.options.limits.request_body_bytes; + let mut read = 0usize; + let body = body.into_data_stream().map(move |chunk| { + let chunk = chunk.map_err(io::Error::other)?; + read = read + .checked_add(chunk.len()) + .ok_or_else(|| io::Error::other("request body limit"))?; + if read > max { + return Err(io::Error::other("request body limit")); + } + Ok(chunk) + }); + let pending = inner + .client + .request(parts.method, url) + .headers(parts.headers) + .body(reqwest::Body::wrap_stream(body)) + .send(); + let upstream = + match tokio::time::timeout(inner.options.limits.response_header_timeout, pending).await { + Ok(Ok(upstream)) => upstream, + Err(_) => return response(StatusCode::GATEWAY_TIMEOUT), + _ => return response(StatusCode::BAD_GATEWAY), + }; + let status = upstream.status(); + let mut headers = upstream.headers().clone(); + strip_hop_headers(&mut headers); + if let Some(location) = headers.get(header::LOCATION).and_then(|v| v.to_str().ok()) { + if let (Ok(location), Ok(upstream_origin)) = ( + Url::parse(origin).and_then(|origin| origin.join(location)), + Url::parse(origin), + ) { + if location.origin() == upstream_origin.origin() { + let suffix = &location[url::Position::BeforePath..]; + if let Ok(location) = HeaderValue::from_str(&format!( + "{}{suffix}", + inner.options.public_origin.trim_end_matches('/') + )) { + headers.insert(header::LOCATION, location); + } + } + } + } + if status == StatusCode::SWITCHING_PROTOCOLS { + let Some(on_upgrade) = on_upgrade else { + return response(StatusCode::BAD_GATEWAY); + }; + if !upstream + .headers() + .get(header::UPGRADE) + .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"websocket")) + { + return response(StatusCode::BAD_GATEWAY); + } + headers.insert(header::CONNECTION, HeaderValue::from_static("upgrade")); + headers.insert(header::UPGRADE, HeaderValue::from_static("websocket")); + let lifetime = + context + .identity() + .map_or(inner.options.limits.upgrade_lifetime, |identity| { + inner + .options + .limits + .upgrade_lifetime + .min(Duration::from_secs( + identity.expires_at().saturating_sub(super::now()), + )) + }); + tokio::spawn(async move { + let _permit = permit; + let _ = tokio::time::timeout(lifetime, async move { + let (Ok(downstream), Ok(mut upstream)) = + tokio::join!(on_upgrade, upstream.upgrade()) + else { + return; + }; + let mut downstream = hyper_util::rt::TokioIo::new(downstream); + let _ = tokio::io::copy_bidirectional(&mut downstream, &mut upstream).await; + }) + .await; + }); + let mut result = response(status); + *result.headers_mut() = headers; + return result; + } + // Dropping the downstream body drops both upstream body and capacity permit. + // No detached response pump keeps reading after the client disconnects. + let mut stream = Box::pin(upstream.bytes_stream()); + let body = futures_util::stream::poll_fn(move |cx| { + let _permit = &permit; + stream.as_mut().poll_next(cx) + }); + let mut result = Response::new(Body::from_stream(body)); + *result.status_mut() = status; + *result.headers_mut() = headers; + result +} + +pub(super) fn add_hop(headers: &mut HeaderMap, inner: &NativeInner) -> Result<(), ()> { + let hops = headers + .get("x-distributed-gateway-hops") + .and_then(|v| v.to_str().ok()) + .map_or_else( + || inner.hop_id.clone(), + |previous| format!("{previous},{}", inner.hop_id), + ); + if hops.len() > 512 { + return Err(()); + } + headers.insert( + "x-distributed-gateway-hops", + HeaderValue::from_str(&hops).map_err(|_| ())?, + ); + Ok(()) +} diff --git a/src/gateway/route.rs b/src/gateway/route.rs new file mode 100644 index 000000000..a3d7c2d13 --- /dev/null +++ b/src/gateway/route.rs @@ -0,0 +1,220 @@ +use super::config::{validate_id, Binding, GatewayError}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Maximum UTF-8 bytes in a route or request path before decoding. +pub const MAX_PATH_BYTES: usize = 4096; +/// Maximum ordered admission policies on one route. +pub const MAX_ADMISSIONS: usize = 16; + +/// Explicit ownership of an exact path or a segment-delimited prefix. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde( + tag = "kind", + content = "path", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum RoutePath { + /// Matches just this path, before every prefix match. + Exact(String), + /// Matches this path and descendants; `/` is the explicit UI fallback. + Prefix(String), +} + +impl RoutePath { + /// Declare an exact route. Validation happens during configuration build. + pub fn exact(path: impl Into) -> Self { + Self::Exact(path.into()) + } + /// Declare a prefix. A trailing slash is normalized during build. + pub fn prefix(path: impl Into) -> Self { + Self::Prefix(path.into()) + } + pub(crate) fn matches(&self, path: &str) -> bool { + match self { + Self::Exact(value) => value == path, + Self::Prefix(value) => { + value == "/" + || value == path + || path + .strip_prefix(value) + .is_some_and(|tail| tail.starts_with('/')) + } + } + } +} + +/// HTTP method selection within one path owner. Disjoint method registrations +/// at the same path are rejected too: use one owner with an explicit list. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde( + tag = "kind", + content = "methods", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum Methods { + /// Delegate all valid HTTP methods, including HEAD/OPTIONS, to this owner. + #[default] + Any, + /// Exactly these case-sensitive methods. HEAD is not implicitly GET. + Only(Vec), +} + +impl Methods { + /// Whether this selection admits the supplied method. + pub fn allows(&self, method: &str) -> bool { + match self { + Self::Any => true, + Self::Only(methods) => methods.iter().any(|m| m == method), + } + } +} + +/// One route declaration. Public routes have an empty admission chain; protected +/// assets use the same ordered policies as API/custom routes. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Route { + /// Unique route identifier. + pub id: String, + /// Owned exact path or prefix. + pub path: RoutePath, + /// Configured execution binding identifier. + pub target: String, + /// Supported HTTP methods within this owner. + pub methods: Methods, + /// Ordered admission binding identifiers, all required before execution. + pub admission: Vec, +} + +impl Route { + /// Declare a public route accepting all methods. Add policies/methods + /// explicitly before building the configuration. + pub fn new(id: impl Into, path: RoutePath, target: impl Into) -> Self { + Self { + id: id.into(), + path, + target: target.into(), + methods: Methods::Any, + admission: Vec::new(), + } + } + + pub(crate) fn validate(&mut self) -> Result<(), GatewayError> { + validate_id(&self.id)?; + validate_id(&self.target)?; + match &mut self.path { + RoutePath::Exact(path) => *path = normalize_path(path)?, + RoutePath::Prefix(path) => { + *path = normalize_path(path)?; + if path.len() > 1 { + *path = path.trim_end_matches('/').to_owned(); + } + } + } + if let Methods::Only(methods) = &mut self.methods { + if methods.is_empty() || methods.len() > 32 { + return Err(GatewayError("method selection must contain 1..=32 methods")); + } + let mut seen = BTreeSet::new(); + for method in methods.iter() { + validate_method(method)?; + if !seen.insert(method) { + return Err(GatewayError("duplicate route method")); + } + } + methods.sort(); + } + if self.admission.len() > MAX_ADMISSIONS { + return Err(GatewayError("too many route admission policies")); + } + let mut seen = BTreeSet::new(); + for policy in &self.admission { + validate_id(policy)?; + if !seen.insert(policy) { + return Err(GatewayError("duplicate route admission policy")); + } + } + Ok(()) + } +} + +/// Validated selection borrowed from one gateway. No caller can construct a +/// selection that bypasses binding validation. An adapter response is terminal. +#[derive(Clone, Copy, Debug)] +pub struct SelectedRoute<'a> { + pub(crate) route: &'a Route, + pub(crate) binding: &'a Binding, + pub(crate) method_allowed: bool, +} + +impl<'a> SelectedRoute<'a> { + /// Fixed route owner, including its admission policy chain. + pub fn route(self) -> &'a Route { + self.route + } + /// Validated configured execution resource. + pub fn binding(self) -> &'a Binding { + self.binding + } + /// False means an owned 405, never a search for another matching route. + pub fn method_allowed(self) -> bool { + self.method_allowed + } +} + +pub(crate) fn validate_method(method: &str) -> Result<(), GatewayError> { + if method.is_empty() + || method.len() > 32 + || !method + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)) + { + return Err(GatewayError("invalid HTTP method")); + } + Ok(()) +} + +pub(crate) fn normalize_path(path: &str) -> Result { + if path.len() > MAX_PATH_BYTES || !path.starts_with('/') { + return Err(GatewayError("gateway path must be a bounded absolute path")); + } + let mut decoded = Vec::with_capacity(path.len()); + let mut input = path.bytes(); + while let Some(byte) = input.next() { + let byte = if byte == b'%' { + let hi = input.next().and_then(hex); + let lo = input.next().and_then(hex); + let (Some(hi), Some(lo)) = (hi, lo) else { + return Err(GatewayError("invalid percent-encoded path")); + }; + let value = hi * 16 + lo; + if matches!(value, b'/' | b'\\' | b'%') { + return Err(GatewayError("ambiguous encoded path separator")); + } + value + } else { + byte + }; + if byte.is_ascii_control() || matches!(byte, b'\\' | b'?' | b'#') { + return Err(GatewayError("invalid gateway path character")); + } + decoded.push(byte); + } + let path = String::from_utf8(decoded).map_err(|_| GatewayError("gateway path is not UTF-8"))?; + if path.contains("//") || path.split('/').any(|s| matches!(s, "." | "..")) { + return Err(GatewayError("ambiguous gateway path segments")); + } + Ok(path) +} + +fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} diff --git a/src/gateway/worker/cancellation.rs b/src/gateway/worker/cancellation.rs new file mode 100644 index 000000000..fcc1c5846 --- /dev/null +++ b/src/gateway/worker/cancellation.rs @@ -0,0 +1,73 @@ +use futures_channel::oneshot; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; +use worker::{ + wasm_bindgen::{closure::Closure, JsCast}, + web_sys::AbortSignal, + Request, Response, Result, +}; + +/// Own the event listener so cancellation drops the actual in-flight Rust work. +pub(super) struct Cancelled { + signal: AbortSignal, + callback: Closure, + receiver: oneshot::Receiver<()>, +} +impl Cancelled { + pub fn new(signal: AbortSignal) -> Result { + let (sender, receiver) = oneshot::channel(); + let mut sender = Some(sender); + let callback = Closure::wrap_assert_unwind_safe(Box::new(move || { + if let Some(sender) = sender.take() { + let _ = sender.send(()); + } + }) as Box); + signal.add_event_listener_with_callback("abort", callback.as_ref().unchecked_ref())?; + Ok(Self { + signal, + callback, + receiver, + }) + } +} +impl Future for Cancelled { + type Output = (); + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + if self.signal.aborted() { + return Poll::Ready(()); + } + Pin::new(&mut self.receiver).poll(cx).map(|_| ()) + } +} +impl Drop for Cancelled { + fn drop(&mut self) { + let _ = self + .signal + .remove_event_listener_with_callback("abort", self.callback.as_ref().unchecked_ref()); + } +} +pub(super) async fn run( + signal: AbortSignal, + future: impl Future>, +) -> Result { + match futures_util::future::select(Box::pin(future), Box::pin(Cancelled::new(signal)?)).await { + futures_util::future::Either::Left((result, _)) => result, + futures_util::future::Either::Right(_) => Response::error("request cancelled", 499), + } +} +pub(super) fn preserve_signal(request: Request, signal: &AbortSignal) -> Result { + let init = worker::web_sys::RequestInit::new(); + init.set_signal(Some(signal)); + Ok(worker::web_sys::Request::new_with_request_and_init(request.inner(), &init)?.into()) +} + +/// Dropping a WebSocket operation also cancels its HTTP/DO fetch promise. +pub(super) struct AbortOnDrop(pub worker::web_sys::AbortController); +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} diff --git a/src/gateway/worker/coordinator.rs b/src/gateway/worker/coordinator.rs new file mode 100644 index 000000000..4b3979328 --- /dev/null +++ b/src/gateway/worker/coordinator.rs @@ -0,0 +1,793 @@ +use super::{proxy, RequestContext, WorkerOptions}; +use crate::gateway::{delivery::*, DeliveryCapabilities, GatewayError}; +use futures_util::{ + future::{LocalBoxFuture, Shared, WeakShared}, + FutureExt, StreamExt, +}; +use std::{cell::RefCell, collections::BTreeMap, rc::Rc}; +use worker::{Headers, Request, RequestInit, RequestRedirect, Response, Result}; + +/// Optional delivery resources, allocated inside a Durable Object only. +#[derive(Clone, Copy, Debug, Default)] +pub struct WorkerDeliveryOptions { + /// Bounded complete response cache. + pub snapshots: Option, + /// Bounded concurrent query sharing. + pub coalescing: Option, + /// Bounded live sharing. + pub live: Option, +} +impl WorkerDeliveryOptions { + pub(super) fn capabilities(&self) -> DeliveryCapabilities { + DeliveryCapabilities { + snapshots: self.snapshots.is_some(), + coalescing: self.coalescing.is_some(), + live_sharing: self.live.is_some(), + } + } + pub(super) fn validate(&self) -> std::result::Result<(), GatewayError> { + if self.snapshots.is_none() && self.coalescing.is_none() && self.live.is_none() { + return Err(GatewayError("no Worker delivery capability selected")); + } + if let Some(limits) = self.snapshots { + SnapshotCache::new(limits).map_err(|_| GatewayError("invalid snapshot limits"))?; + } + if let Some(limits) = self.coalescing { + FlightRegistry::new(limits).map_err(|_| GatewayError("invalid flight limits"))?; + } + if let Some(limits) = self.live { + limits + .validate() + .map_err(|_| GatewayError("invalid live limits"))?; + } + // Bound retained wire payload independently of the native defaults. + // Parsed JSON, credentials and runtime socket overhead require additional + // headroom; this is a payload budget, not the platform heap limit. + let snapshots = self.snapshots.map_or(0, |l| l.bytes); + let flights = self + .coalescing + .map_or(0, |l| l.groups.saturating_mul(l.response_bytes)); + let live = self.live.map_or(0, |l| { + l.groups + .saturating_mul( + l.queue_frames + .saturating_add(l.history_frames) + .saturating_add(2), + ) + .saturating_mul(l.frame_bytes) + }); + if snapshots.saturating_add(flights).saturating_add(live) > 16 * 1024 * 1024 { + return Err(GatewayError( + "Worker coordinator retained payload budget exceeds 16 MiB", + )); + } + Ok(()) + } +} +/// Stable sharded namespace configuration, distinct from domain aggregate cells. +#[derive(Clone, Debug)] +pub struct WorkerDeliveryBinding { + /// Application-declared Durable Object namespace binding name. + pub namespace: String, + /// Stable deployment/application namespace. Change to abandon prior coordinator state. + pub epoch: String, + /// Number of operation shards, 1..=1024; every ingress uses the same configuration. + pub shards: u16, + /// Independent resources and limits for each selected Durable Object. + pub options: WorkerDeliveryOptions, +} +impl WorkerDeliveryBinding { + pub(super) fn validate(&self) -> std::result::Result<(), GatewayError> { + if self.namespace.is_empty() + || self.namespace.len() > 256 + || self.epoch.is_empty() + || self.epoch.len() > 256 + || self.shards == 0 + || self.shards > 1024 + { + return Err(GatewayError("invalid Worker coordinator binding")); + } + self.options.validate() + } + pub(super) fn shard(&self, binding: &str, value: &serde_json::Value) -> Result { + use sha2::{Digest, Sha256}; + // Routing uses no caller scope assertion or bearer material. The DO + // independently authenticates and derives exact scope before any reuse. + let bytes = canonical_json(&serde_json::json!([ + binding, + value["query"], + value["operationName"], + value["variables"] + ])) + .map_err(|_| worker::Error::RustError("invalid operation shard".into()))?; + let hash = Sha256::digest(bytes); + let shard = u16::from_be_bytes([hash[0], hash[1]]) % self.shards; + Ok(format!( + "gateway-delivery-v1:{}:{binding}:{shard}", + self.epoch + )) + } +} +/// Volatile cache/work state owned by one platform Durable Object instance. +/// A restart starts empty; every reuse still requires current origin validation. +/// Do not keep this in ordinary ingress isolate memory. +pub struct WorkerCoordinator { + bypasses: std::cell::Cell, + options: WorkerDeliveryOptions, + cache: Option>, + flights: Option>, + live: Option>, +} +impl WorkerCoordinator { + /// Construct only in an application's DurableObject::new implementation. + pub fn new(options: WorkerDeliveryOptions) -> std::result::Result, GatewayError> { + options.validate()?; + Ok(Rc::new(Self { + bypasses: std::cell::Cell::new(0), + options, + live: options + .live + .map(|limits| { + super::live::WorkerLive::new( + limits, + 16 * 1024 * 1024 + - options.snapshots.map_or(0, |l| l.bytes) + - options + .coalescing + .map_or(0, |l| l.groups * l.response_bytes), + ) + }) + .transpose()?, + cache: options + .snapshots + .map(SnapshotCache::new) + .transpose() + .map_err(|_| GatewayError("invalid snapshot limits"))? + .map(RefCell::new), + flights: options.coalescing.map(Flights::new).transpose()?, + })) + } + /// Identifier-free cache decisions and origin-ineligible query bypasses. + pub fn metrics(&self) -> (Option, u64) { + ( + self.cache.as_ref().map(|cache| cache.borrow().metrics()), + self.bypasses.get(), + ) + } + /// Forget cached data and fence in-progress fills after reset/lost feed. + pub fn invalidate_all(&self) { + if let Some(cache) = &self.cache { + cache.borrow_mut().invalidate_all(); + } + } + /// Current cache entries, active query groups and query consumers; no identity values. + pub fn counts(&self) -> (usize, usize, usize) { + let (groups, consumers) = self.flights.as_ref().map_or((0, 0), |flights| { + let state = flights.state.borrow(); + (state.registry.len(), state.registry.consumers()) + }); + ( + self.cache.as_ref().map_or(0, |cache| cache.borrow().len()), + groups, + consumers, + ) + } + /// Active live groups and consumers, followed by source/reset/frame counters. + pub fn live_counts(&self) -> (usize, usize, u64, u64, u64, u64, u64) { + self.live + .as_ref() + .map_or((0, 0, 0, 0, 0, 0, 0), |live| live.counts()) + } + pub(super) fn upgrade( + self: &Rc, + origin: OriginRequest, + live_path: String, + capabilities: crate::gateway::GraphqlCapabilities, + ) -> Result { + let pair = worker::WebSocketPair::new()?; + let mut socket = super::socket::Socket::new( + pair.server, + origin.options.limits.websocket_buffer_bytes, + 4, + )?; + let owner = self.clone(); + worker::wasm_bindgen_futures::spawn_local(async move { + let first = super::timer::deadline( + std::time::Duration::from_millis(origin.options.limits.header_timeout_ms), + socket.next(), + ) + .await; + let Ok(Ok(first)) = first else { + return; + }; + let value = first["payload"].clone(); + if first["type"] != "subscribe" + || crate::gateway::graphql::admit_request(&value, capabilities).is_err() + || crate::gateway::graphql::operation_kind( + value["query"].as_str().unwrap_or(""), + value["operationName"].as_str(), + ) != Ok(crate::gateway::graphql::OperationKind::Subscription) + { + return; + } + let _ = owner.subscribe(origin, live_path, value, socket).await; + }); + Response::from_websocket(pair.client) + } + pub(super) async fn subscribe( + self: &Rc, + origin: OriginRequest, + live_path: String, + value: serde_json::Value, + mut socket: super::socket::Socket, + ) -> Result<()> { + let freshness = value["extensions"] + .get("gatewayFreshness") + .map(FreshnessContext::parse) + .transpose() + .map_err(delivery_error)?; + let init = value["extensions"]["gatewayDelivery"] + .get("connectionInit") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let source = super::live_transport::source(origin.clone(), live_path, init); + let admission = origin.validate(&value).await?; + let lifetime = origin.options.limits.websocket_lifetime_ms; + let ack_timeout = origin.options.limits.read_timeout_ms; + match admission { + Admitted::Eligible(admission) => { + let live = self + .live + .as_ref() + .ok_or_else(|| worker::Error::RustError("live coordinator absent".into()))?; + let mut lease = live + .join(admission, value, freshness, source) + .map_err(delivery_error)?; + worker::wasm_bindgen_futures::spawn_local(async move { + let run = async { + loop { + enum Event { + Frame( + std::result::Result< + Option>, + &'static str, + >, + ), + Client(std::result::Result), + } + let event = match futures_util::future::select( + Box::pin(lease.next()), + Box::pin(socket.next()), + ) + .await + { + futures_util::future::Either::Left((frame, _)) => { + Event::Frame(frame) + } + futures_util::future::Either::Right((client, _)) => { + Event::Client(client) + } + }; + let frame = match event { + Event::Frame(frame) => frame, + Event::Client(Ok(value)) if value["type"] == "ping" => { + let _=socket.send(&serde_json::json!({"type":"pong","payload":value["payload"]})); + continue; + } + _ => return, + }; + let frame = match frame { + Ok(Some(frame)) => frame, + Ok(None) => { + let _ = socket.send(&serde_json::json!({"type":"complete"})); + return; + } + Err(reason) => { + let _ = socket.send( + &serde_json::json!({"type":"error","payload":reason}), + ); + return; + } + }; + if socket + .send(&serde_json::json!({"type":"next","payload":frame.payload()})) + .is_err() + { + return; + } + // Standard GraphQL ping/pong provides a delivery credit on + // workerd, whose WebSocket API has no bufferedAmount. + let nonce = uuid::Uuid::now_v7().to_string(); + if socket.send(&serde_json::json!({"type":"ping","payload":{"gatewayDeliveryAck":nonce}})).is_err(){return;} + let acknowledged = { + let ack = wait_ack(&mut socket, &nonce, ack_timeout); + matches!( + futures_util::future::select( + Box::pin(ack), + Box::pin(lease.interrupted()) + ) + .await, + futures_util::future::Either::Left((Ok(()), _)) + ) + }; + if !acknowledged { + let _ = socket.ws.close(Some(1013), Some("LIVE_RESET_REQUIRED")); + return; + } + } + }; + let _ = super::timer::deadline(std::time::Duration::from_millis(lifetime), run) + .await; + }); + } + Admitted::Bypass => { + worker::wasm_bindgen_futures::spawn_local(async move { + let run = async { + let Ok(mut stream) = source(value).await else { + return; + }; + loop { + let frame = { + let result = futures_util::future::select( + Box::pin(stream.next()), + Box::pin(socket.next()), + ) + .await; + match result { + futures_util::future::Either::Left((frame, _)) => frame, + _ => return, + } + }; + let Some(frame) = frame else { + break; + }; + let Ok(frame) = frame else { + let _ = socket.ws.close(Some(1013), Some("LIVE_RESET_REQUIRED")); + return; + }; + if socket + .send(&serde_json::json!({"type":"next","payload":frame})) + .is_err() + { + return; + } + let nonce = uuid::Uuid::now_v7().to_string(); + if socket.send(&serde_json::json!({"type":"ping","payload":{"gatewayDeliveryAck":nonce}})).is_err()||wait_ack(&mut socket,&nonce,ack_timeout).await.is_err(){return;} + } + let _ = socket.send(&serde_json::json!({"type":"complete"})); + }; + let _ = super::timer::deadline(std::time::Duration::from_millis(lifetime), run) + .await; + }); + } + Admitted::Error(mut response) => { + let bytes = read_response(&mut response, 65536).await?; + let payload = serde_json::from_slice::(&bytes).unwrap_or_else( + |_| serde_json::json!({"errors":[{"message":"origin admission failed"}]}), + ); + let _ = socket.send(&serde_json::json!({"type":"next","payload":payload})); + let _ = socket.send(&serde_json::json!({"type":"complete"})); + } + } + Ok(()) + } + pub(super) fn capabilities(&self) -> DeliveryCapabilities { + self.options.capabilities() + } + pub(super) async fn execute( + self: &Rc, + origin: OriginRequest, + value: serde_json::Value, + ) -> Result { + let freshness = value["extensions"] + .get("gatewayFreshness") + .map(FreshnessContext::parse) + .transpose() + .map_err(|_| worker::Error::RustError("invalid freshness".into()))?; + let admission = match origin.validate(&value).await? { + Admitted::Eligible(admission) => admission, + Admitted::Bypass => { + self.bypasses.set(self.bypasses.get().saturating_add(1)); + return origin.execute(value).await; + } + Admitted::Error(response) => return Ok(response), + }; + let ticket = if let Some(cache) = &self.cache { + let mut cache = cache.borrow_mut(); + match cache + .lookup(&admission, freshness.as_ref(), super::now()) + .map_err(delivery_error)? + { + Some(hit) => return render(hit), + None => Some( + cache + .begin_fill(&admission, super::now()) + .map_err(delivery_error)?, + ), + } + } else { + None + }; + if let Some(flights) = &self.flights { + let key = FlightKey::admitted(&admission, &value, freshness.as_ref(), super::now()) + .map_err(delivery_error)?; + let owner = self.clone(); + let input = origin.clone(); + let request = value.clone(); + let admitted = admission.clone(); + let floor = freshness.clone(); + let expires = admission.expires_at; + let limit = flights.limits; + let lease = flights + .join(key, move || { + async move { + let work = async { + let result = owner + .fill(input, request, admitted.clone(), floor.clone(), ticket) + .await?; + let captured = capture(result, limit.response_bytes).await?; + Ok(match captured { + Captured::Bytes(snapshot) + if snapshot.shareable(&admitted, floor.as_ref()) => + { + Outcome::Shared(Rc::new(snapshot)) + } + Captured::Bytes(snapshot) => exclusive(render(snapshot)?), + Captured::Streaming(response) => exclusive(response), + }) + }; + proxy::timeout(limit.deadline_ms, work) + .await + .unwrap_or_else(|_| { + exclusive( + Response::error("origin unavailable", 502) + .expect("fixed status"), + ) + }) + } + .boxed_local() + }) + .map_err(delivery_error)?; + let outcome = proxy::timeout(expires.saturating_sub(super::now()) * 1000, async { + Ok(lease.work.clone().await) + }) + .await; + if super::now() >= expires { + return Response::error("credential expired", 401); + } + match outcome? { + Outcome::Shared(snapshot) => return render((*snapshot).clone()), + Outcome::Exclusive(response) => { + if let Some(response) = response.borrow_mut().take() { + return Ok(response); + } + } + } + return origin.execute(value).await; + } + self.fill(origin, value, admission, freshness, ticket).await + } + async fn fill( + &self, + origin: OriginRequest, + value: serde_json::Value, + admission: OriginAdmission, + freshness: Option, + ticket: Option, + ) -> Result { + let mut marked = value.clone(); + mark(&mut marked, "snapshot"); + let limit = self + .options + .coalescing + .map(|l| l.response_bytes) + .or(self.options.snapshots.map(|l| l.entry_bytes)) + .unwrap_or(1024 * 1024); + let captured = capture(origin.execute(marked).await?, limit).await?; + let snapshot = match captured { + Captured::Bytes(snapshot) => snapshot, + Captured::Streaming(response) => return Ok(response), + }; + if snapshot.shareable(&admission, freshness.as_ref()) { + match origin.validate(&value).await? { + Admitted::Eligible(current) => { + if current.identity != admission.identity || current.key != admission.key { + return Response::error("origin scope changed", 409); + } + if let (Some(cache), Some(ticket)) = (&self.cache, ticket) { + cache + .borrow_mut() + .install(ticket, current, snapshot.clone(), super::now()) + .map_err(delivery_error)?; + } + } + Admitted::Error(response) => return Ok(response), + Admitted::Bypass => {} + } + } + render(snapshot) + } +} +#[derive(Clone)] +pub(super) struct OriginRequest { + pub origin: String, + pub path: String, + pub url: String, + pub headers: Vec<(String, String)>, + pub options: WorkerOptions, + pub context: RequestContext, +} +impl OriginRequest { + pub(super) async fn execute(&self, value: serde_json::Value) -> Result { + let mut init = RequestInit::new(); + init.method = worker::Method::Post; + init.redirect = RequestRedirect::Manual; + for (name, value) in &self.headers { + if !matches!( + name.to_ascii_lowercase().as_str(), + "content-length" + | "connection" + | "upgrade" + | "sec-websocket-key" + | "sec-websocket-protocol" + | "sec-websocket-version" + ) { + init.headers.append(name, value)?; + } + } + init.headers.set("content-type", "application/json")?; + init.body = Some(value.to_string().into()); + proxy::forward( + Request::new_with_init(&self.url, &init)?, + &self.origin, + Some(&self.path), + false, + &self.options, + &self.context, + ) + .await + } + pub(super) async fn validate(&self, value: &serde_json::Value) -> Result { + let mut marked = value.clone(); + mark(&mut marked, "validate"); + let result = self.execute(marked).await?; + let Captured::Bytes(snapshot) = capture(result, 65536).await? else { + return Err(worker::Error::RustError( + "oversized origin admission".into(), + )); + }; + if snapshot.status != 200 { + return Ok(Admitted::Error(render(snapshot)?)); + } + let parsed: serde_json::Value = serde_json::from_slice(&snapshot.body)?; + if parsed + .get("errors") + .is_some_and(|v| v.as_array().is_none_or(|v| !v.is_empty())) + { + return Ok(Admitted::Error(render(snapshot)?)); + } + let delivery = &parsed["extensions"]["gatewayDelivery"]; + if delivery["eligible"] != true + || snapshot + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("set-cookie")) + { + return Ok(Admitted::Bypass); + } + let admission: OriginAdmission = serde_json::from_value(delivery["admission"].clone())?; + admission + .bind(value, super::now()) + .map_err(delivery_error)?; + Ok(Admitted::Eligible(admission)) + } +} +pub(super) enum Admitted { + Eligible(OriginAdmission), + Bypass, + Error(Response), +} +pub(super) fn mark(value: &mut serde_json::Value, action: &str) { + if !value["extensions"].is_object() { + value["extensions"] = serde_json::json!({}); + } + let init = value["extensions"]["gatewayDelivery"] + .get("connectionInit") + .cloned(); + value["extensions"]["gatewayDelivery"] = serde_json::json!({"action":action}); + if let Some(init) = init { + value["extensions"]["gatewayDelivery"]["connectionInit"] = init; + } +} +fn delivery_error(_: DeliveryError) -> worker::Error { + worker::Error::RustError("delivery unavailable".into()) +} +fn render(snapshot: SnapshotResponse) -> Result { + let headers = Headers::new(); + for (name, value) in snapshot.headers { + headers.append(&name, &value)?; + } + Ok(Response::from_bytes(snapshot.body)? + .with_status(snapshot.status) + .with_headers(headers)) +} +enum Captured { + Bytes(SnapshotResponse), + Streaming(Response), +} +async fn capture(mut response: Response, limit: usize) -> Result { + let status = response.status_code(); + let headers = response.headers().entries().collect::>(); + match response.body() { + worker::ResponseBody::Empty => { + return Ok(Captured::Bytes(SnapshotResponse { + status, + headers, + body: Vec::new(), + })) + } + worker::ResponseBody::Body(body) if body.len() <= limit => { + return Ok(Captured::Bytes(SnapshotResponse { + status, + headers, + body: body.clone(), + })) + } + worker::ResponseBody::Body(_) => return Ok(Captured::Streaming(response)), + worker::ResponseBody::Stream(_) => {} + } + let stream = response.stream()?; + let mut stream = stream; + let mut chunks = Vec::new(); + let mut size = 0; + while let Some(chunk) = stream.next().await { + let failed = chunk.is_err(); + size += chunk.as_ref().map_or(0, Vec::len); + chunks.push(chunk); + if failed || size > limit { + let head = Headers::new(); + for (name, value) in headers { + head.append(&name, &value)?; + } + return Ok(Captured::Streaming( + Response::from_stream(futures_util::stream::iter(chunks).chain(stream))? + .with_status(status) + .with_headers(head), + )); + } + } + let mut body = Vec::with_capacity(size); + for chunk in chunks { + body.extend(chunk?); + } + Ok(Captured::Bytes(SnapshotResponse { + status, + headers, + body, + })) +} +#[derive(Clone)] +enum Outcome { + Shared(Rc), + Exclusive(Rc>>), +} +fn exclusive(response: Response) -> Outcome { + Outcome::Exclusive(Rc::new(RefCell::new(Some(response)))) +} +type Work = LocalBoxFuture<'static, Outcome>; +struct FlightState { + registry: FlightRegistry, + work: BTreeMap>, +} +struct Flights { + state: RefCell, + limits: FlightLimits, +} +struct Lease { + owner: Rc, + ticket: Option, + work: Shared, +} +impl Drop for Lease { + fn drop(&mut self) { + if let Some(ticket) = self.ticket.take() { + let generation = ticket.generation(); + let mut state = self.owner.state.borrow_mut(); + if state.registry.leave(ticket) { + state.work.remove(&generation); + } + } + } +} +impl Flights { + fn new(limits: FlightLimits) -> std::result::Result, GatewayError> { + Ok(Rc::new(Self { + state: RefCell::new(FlightState { + registry: FlightRegistry::new(limits) + .map_err(|_| GatewayError("invalid flight limits"))?, + work: BTreeMap::new(), + }), + limits, + })) + } + fn join( + self: &Rc, + key: FlightKey, + start: impl FnOnce() -> Work, + ) -> std::result::Result { + let mut state = self.state.borrow_mut(); + state.registry.expire(worker::Date::now().as_millis()); + let expired = state + .work + .keys() + .filter(|generation| !state.registry.contains_generation(**generation)) + .copied() + .collect::>(); + for generation in expired { + state.work.remove(&generation); + } + let (ticket, owner) = state.registry.join(key, worker::Date::now().as_millis())?; + let generation = ticket.generation(); + let work = if owner { + let work = start().shared(); + state.work.insert( + generation, + work.downgrade().ok_or(DeliveryError::Unavailable)?, + ); + work + } else { + state + .work + .get(&generation) + .and_then(WeakShared::upgrade) + .ok_or(DeliveryError::Unavailable)? + }; + Ok(Lease { + owner: self.clone(), + ticket: Some(ticket), + work, + }) + } +} + +async fn wait_ack( + socket: &mut super::socket::Socket, + nonce: &str, + milliseconds: u64, +) -> std::result::Result<(), String> { + super::timer::deadline(std::time::Duration::from_millis(milliseconds), async { + loop { + let value = socket.next().await?; + match value["type"].as_str() { + Some("pong") if value["payload"]["gatewayDeliveryAck"] == nonce => return Ok(()), + Some("ping") => { + socket.send(&serde_json::json!({"type":"pong","payload":value["payload"]}))? + } + _ => return Err("invalid delivery acknowledgement".into()), + } + } + }) + .await + .map_err(|_| "delivery acknowledgement timeout".to_owned())? +} + +pub(super) async fn read_response(response: &mut Response, max: usize) -> Result> { + match response.body() { + worker::ResponseBody::Empty => return Ok(Vec::new()), + worker::ResponseBody::Body(bytes) if bytes.len() <= max => return Ok(bytes.clone()), + worker::ResponseBody::Body(_) => { + return Err(worker::Error::RustError("response too large".into())) + } + _ => {} + } + let mut stream = response.stream()?; + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + if bytes.len().saturating_add(chunk.len()) > max { + return Err(worker::Error::RustError("response too large".into())); + } + bytes.extend(chunk); + } + Ok(bytes) +} diff --git a/src/gateway/worker/frontend.rs b/src/gateway/worker/frontend.rs new file mode 100644 index 000000000..a4825542e --- /dev/null +++ b/src/gateway/worker/frontend.rs @@ -0,0 +1,512 @@ +use super::{ + coordinator::{OriginRequest, WorkerDeliveryBinding}, + live_transport, + socket::Socket, + WorkerGateway, +}; +use crate::gateway::{ + graphql::{admit_request, operation_kind, OperationKind}, + GraphqlCapabilities, +}; +use futures_channel::mpsc; +use futures_util::{ + future::{select, AbortHandle, Abortable, Either}, + SinkExt, StreamExt, +}; +use std::{collections::BTreeMap, time::Duration}; +use worker::{Env, Request, RequestInit, RequestRedirect, Response, Result, WebSocketPair}; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn upgrade( + gateway: WorkerGateway, + env: Env, + request: Request, + input: OriginRequest, + live_path: String, + capabilities: GraphqlCapabilities, + delivery: Option, + binding: String, +) -> Result { + if request.method() != worker::Method::Get { + return Response::error("invalid websocket method", 400); + } + let offered = request + .headers() + .get("sec-websocket-protocol")? + .unwrap_or_default(); + let protocol = if offered + .split(',') + .any(|p| p.trim() == "graphql-transport-ws") + { + "graphql-transport-ws" + } else if offered.split(',').any(|p| p.trim() == "graphql-ws") { + "graphql-ws" + } else { + return Response::error("unsupported GraphQL protocol", 400); + }; + let mut origin = match live_transport::handshake(&input, &live_path, protocol).await { + Ok(socket) => socket, + Err(_) => return Response::error("origin unavailable", 502), + }; + let pair = WebSocketPair::new()?; + let mut client = Socket::new(pair.server, input.options.limits.websocket_buffer_bytes, 32)?; + let lifetime = + input + .context + .identity() + .map_or(input.options.limits.websocket_lifetime_ms, |identity| { + input + .options + .limits + .websocket_lifetime_ms + .min(identity.expires_at().saturating_sub(super::now()) * 1000) + }); + worker::wasm_bindgen_futures::spawn_local(async move { + let run = async { + let first = match super::timer::deadline( + Duration::from_millis(input.options.limits.header_timeout_ms), + client.next(), + ) + .await + { + Ok(Ok(value)) if value["type"] == "connection_init" => value, + _ => return, + }; + let init = first + .get("payload") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let ack = match live_transport::initialize( + &mut origin, + init.clone(), + input.options.limits.header_timeout_ms, + ) + .await + { + Ok(ack) => ack, + Err(_) => { + let _ = client.ws.close(Some(4401), Some("unauthorized")); + return; + } + }; + if client.send(&ack).is_err() { + return; + } + if protocol == "graphql-ws" || delivery.is_none() { + independent( + &mut client, + &mut origin, + capabilities, + input.options.limits.websocket_buffer_bytes, + protocol, + ) + .await; + return; + } + drop(origin); + modern( + client, + gateway, + env, + input, + live_path, + capabilities, + delivery, + binding, + init, + ) + .await; + }; + let _ = super::timer::deadline(Duration::from_millis(lifetime), run).await; + }); + let response = Response::from_websocket(pair.client)?; + response.headers().set("sec-websocket-protocol", protocol)?; + Ok(response) +} +struct Operation { + abort: AbortHandle, + ack: mpsc::Sender, +} +impl Drop for Operation { + fn drop(&mut self) { + self.abort.abort(); + } +} +#[allow(clippy::too_many_arguments)] +async fn modern( + mut client: Socket, + gateway: WorkerGateway, + env: Env, + input: OriginRequest, + live_path: String, + capabilities: GraphqlCapabilities, + delivery: Option, + binding: String, + init: serde_json::Value, +) { + let (out, mut outgoing) = mpsc::channel::(32); + let mut operations = BTreeMap::::new(); + loop { + enum Event { + Client(std::result::Result), + Output(Option), + } + let event = match select(Box::pin(client.next()), Box::pin(outgoing.next())).await { + Either::Left((value, _)) => Event::Client(value), + Either::Right((value, _)) => Event::Output(value), + }; + match event { + Event::Client(Ok(value)) => { + match value["type"].as_str() { + Some("subscribe") => { + let Some(id) = value["id"] + .as_str() + .filter(|id| !id.is_empty() && id.len() <= 256) + else { + break; + }; + if operations.len() >= 128 || operations.contains_key(id) { + let _ = client + .ws + .close(Some(4409), Some("duplicate or excessive operation ID")); + break; + } + if let Err(error) = admit_request(&value["payload"], capabilities) { + let _=client.send(&serde_json::json!({"id":id,"type":"next","payload":error.envelope()})); + let _ = client.send(&serde_json::json!({"id":id,"type":"complete"})); + continue; + } + let (abort, registration) = AbortHandle::new_pair(); + let (ack, acks) = mpsc::channel(1); + operations.insert(id.to_owned(), Operation { abort, ack }); + let id = id.to_owned(); + let value = value["payload"].clone(); + let gateway = gateway.clone(); + let env = env.clone(); + let input = input.clone(); + let path = live_path.clone(); + let delivery = delivery.clone(); + let binding = binding.clone(); + let init = init.clone(); + let mut out = out.clone(); + worker::wasm_bindgen_futures::spawn_local(async move { + let task = async { + if let Err(reason) = operation( + &id, value, gateway, env, input, path, delivery, binding, init, + &mut out, acks, + ) + .await + { + let code = match reason.as_str() { + "AUTH_EXPIRED" => "AUTH_EXPIRED", + "FRESHNESS_PENDING" => "FRESHNESS_PENDING", + "FRESHNESS_SCOPE_CHANGED" => "FRESHNESS_SCOPE_CHANGED", + _ => "LIVE_RESET_REQUIRED", + }; + let _=out.send(serde_json::json!({"id":id,"type":"error","payload":[{"message":code,"extensions":{"code":code}}]})).await; + } + }; + let _ = Abortable::new(task, registration).await; + }); + } + Some("complete") => { + if let Some(id) = value["id"].as_str() { + operations.remove(id); + } + } + Some("ping") => { + if client + .send(&serde_json::json!({"type":"pong","payload":value["payload"]})) + .is_err() + { + break; + } + } + Some("pong") => { + if let Some(operation) = value["payload"]["gatewayOperation"] + .as_str() + .and_then(|id| operations.get_mut(id)) + { + let mut value = value; + value["payload"] + .as_object_mut() + .map(|payload| payload.remove("gatewayOperation")); + if operation.ack.try_send(value).is_err() { + break; + } + } + } + _ => break, + } + } + Event::Output(Some(value)) => { + if client.send(&value).is_err() { + break; + } + if matches!(value["type"].as_str(), Some("complete" | "error")) { + if let Some(id) = value["id"].as_str() { + operations.remove(id); + } + } + } + _ => break, + } + } +} +fn post_request( + input: &OriginRequest, + value: &serde_json::Value, + upgrade: bool, +) -> Result { + let mut init = RequestInit::new(); + init.method = if upgrade { + worker::Method::Get + } else { + worker::Method::Post + }; + init.redirect = RequestRedirect::Manual; + for (name, value) in &input.headers { + if !matches!( + name.as_str(), + "content-length" + | "content-type" + | "upgrade" + | "connection" + | "sec-websocket-key" + | "sec-websocket-protocol" + | "sec-websocket-version" + ) { + init.headers.append(name, value)?; + } + } + init.headers.set("content-type", "application/json")?; + if upgrade { + init.headers.set("upgrade", "websocket")?; + } + if !upgrade { + init.body = Some(value.to_string().into()); + } + Request::new_with_init(&input.url, &init) +} +#[allow(clippy::too_many_arguments)] +async fn operation( + id: &str, + mut value: serde_json::Value, + gateway: WorkerGateway, + env: Env, + input: OriginRequest, + live_path: String, + delivery: Option, + binding: String, + init: serde_json::Value, + out: &mut mpsc::Sender, + mut acks: mpsc::Receiver, +) -> std::result::Result<(), String> { + if !value["extensions"].is_object() { + value["extensions"] = serde_json::json!({}); + } + value["extensions"]["gatewayDelivery"] = + serde_json::json!({"action":"execute","connectionInit":init}); + let send = |payload| serde_json::json!({"id":id,"type":"next","payload":payload}); + let kind = operation_kind( + value["query"].as_str().unwrap_or(""), + value["operationName"].as_str(), + ); + // Command/status operations retain the origin's WebSocket identity path. + // A query may use HTTP reuse only after the origin explicitly recognizes + // its control protocol; older/custom origins remain independently executed. + let reuse_query = kind == Ok(OperationKind::Query) + && matches!( + input.validate(&value).await, + Ok(super::coordinator::Admitted::Eligible(_)) + ); + if kind != Ok(OperationKind::Subscription) && !reuse_query { + let mut stream = live_transport::source(input.clone(), live_path, init)(value).await?; + while let Some(frame) = stream.next().await { + out.send(send(frame?)).await.map_err(|_| "consumer left")?; + } + } else if reuse_query { + let request = post_request(&input, &value, false).map_err(|_| "invalid operation")?; + let abort = super::cancellation::AbortOnDrop( + worker::web_sys::AbortController::new().map_err(|_| "cancellation unavailable")?, + ); + let request = super::cancellation::preserve_signal(request, &abort.0.signal()) + .map_err(|_| "invalid operation")?; + let mut response = Box::pin(gateway.fetch(request, env)) + .await + .map_err(|_| "origin unavailable")?; + let bytes = super::coordinator::read_response( + &mut response, + input.options.limits.websocket_buffer_bytes, + ) + .await + .map_err(|_| "invalid origin response")?; + let value = serde_json::from_slice::(&bytes) + .map_err(|_| "invalid origin response")?; + out.send(send(value)).await.map_err(|_| "consumer left")?; + } else if let Some(delivery) = delivery.filter(|delivery| delivery.options.live.is_some()) { + let shard = delivery + .shard(&binding, &value) + .map_err(|_| "invalid shard")?; + let namespace = env + .durable_object(&delivery.namespace) + .map_err(|_| "coordinator unavailable")?; + let stub = namespace + .id_from_name(&shard) + .and_then(|id| id.get_stub()) + .map_err(|_| "coordinator unavailable")?; + let response = stub + .fetch_with_request( + post_request(&input, &value, true).map_err(|_| "invalid operation")?, + ) + .await + .map_err(|_| "coordinator unavailable")?; + if response.status_code() != 101 { + return Err("live admission failed".into()); + } + let mut socket = Socket::new( + response.websocket().ok_or("coordinator stream missing")?, + input.options.limits.websocket_buffer_bytes, + 32, + ) + .map_err(|_| "invalid coordinator socket")?; + socket.send(&serde_json::json!({"type":"subscribe","payload":value}))?; + loop { + enum Event { + Frame(std::result::Result), + Ack(Option), + } + let event = match select(Box::pin(socket.next()), Box::pin(acks.next())).await { + Either::Left((value, _)) => Event::Frame(value), + Either::Right((value, _)) => Event::Ack(value), + }; + match event { + Event::Frame(Ok(mut value)) => match value["type"].as_str() { + Some("next") => out + .send(send(value["payload"].take())) + .await + .map_err(|_| "consumer left")?, + Some("ping") => { + value["payload"]["gatewayOperation"] = id.into(); + out.send(value).await.map_err(|_| "consumer left")?; + } + Some("complete") => break, + Some("error") => { + return Err(value["payload"] + .as_str() + .unwrap_or("LIVE_RESET_REQUIRED") + .into()) + } + _ => return Err("LIVE_RESET_REQUIRED".into()), + }, + Event::Ack(Some(value)) => socket.send(&value)?, + _ => return Err("LIVE_RESET_REQUIRED".into()), + } + } + } else { + let mut stream = live_transport::source(input.clone(), live_path, init)(value).await?; + while let Some(frame) = stream.next().await { + out.send(send(frame?)).await.map_err(|_| "consumer left")?; + let nonce = uuid::Uuid::now_v7().to_string(); + out.send(serde_json::json!({"type":"ping","payload":{"gatewayOperation":id,"gatewayDeliveryAck":nonce}})).await.map_err(|_|"consumer left")?; + let ack = super::timer::deadline( + Duration::from_millis(input.options.limits.read_timeout_ms), + acks.next(), + ) + .await + .map_err(|_| "slow consumer")? + .ok_or("consumer left")?; + if ack["payload"]["gatewayDeliveryAck"] != nonce { + return Err("invalid acknowledgement".into()); + } + } + } + out.send(serde_json::json!({"id":id,"type":"complete"})) + .await + .map_err(|_| "consumer left".into()) +} +// Independent forwarding works with ordinary origins without delivery control. +// Bound cumulative output because workerd has no socket backpressure API; +// exhaustion explicitly requires recovery. Legacy has no ping/pong credits. +async fn independent( + client: &mut Socket, + origin: &mut Socket, + capabilities: GraphqlCapabilities, + max_bytes: usize, + protocol: &str, +) { + let mut ids = std::collections::BTreeSet::new(); + let mut bytes = 0usize; + loop { + let (value, from_client) = + match select(Box::pin(client.next()), Box::pin(origin.next())).await { + Either::Left((value, _)) => (value, true), + Either::Right((value, _)) => (value, false), + }; + let Ok(value) = value else { + return; + }; + if from_client { + match value["type"].as_str() { + Some(kind @ ("start" | "subscribe")) + if kind + == if protocol == "graphql-ws" { + "start" + } else { + "subscribe" + } => + { + let Some(id) = value["id"] + .as_str() + .filter(|id| !id.is_empty() && id.len() <= 256) + else { + return; + }; + if ids.len() >= 128 || !ids.insert(id.to_owned()) { + return; + } + if let Err(error) = admit_request(&value["payload"], capabilities) { + let _ = client.send( + &serde_json::json!({"id":id,"type":if protocol=="graphql-ws"{"data"}else{"next"},"payload":error.envelope()}), + ); + let _ = client.send(&serde_json::json!({"id":id,"type":"complete"})); + ids.remove(id); + continue; + } + } + Some(kind @ ("stop" | "complete")) + if kind + == if protocol == "graphql-ws" { + "stop" + } else { + "complete" + } => + { + if let Some(id) = value["id"].as_str() { + ids.remove(id); + } + } + Some("connection_terminate") if protocol == "graphql-ws" => return, + Some("ping" | "pong") if protocol == "graphql-transport-ws" => {} + _ => return, + } + if origin.send(&value).is_err() { + return; + } + } else { + bytes = bytes.saturating_add(value.to_string().len()); + if bytes > max_bytes { + let _ = client.ws.close(Some(1013), Some("LIVE_RESET_REQUIRED")); + return; + } + if matches!(value["type"].as_str(), Some("complete" | "error")) { + if let Some(id) = value["id"].as_str() { + ids.remove(id); + } + } + if client.send(&value).is_err() { + return; + } + } + } +} diff --git a/src/gateway/worker/live.rs b/src/gateway/worker/live.rs new file mode 100644 index 000000000..b575ce664 --- /dev/null +++ b/src/gateway/worker/live.rs @@ -0,0 +1,541 @@ +use crate::gateway::delivery::*; +use futures_channel::{mpsc, oneshot}; +use futures_util::future::{select, AbortHandle, Abortable, Either}; +use futures_util::{future::LocalBoxFuture, stream::LocalBoxStream, StreamExt}; +use std::{ + cell::{Cell, RefCell}, + collections::{BTreeMap, BTreeSet, VecDeque}, + rc::{Rc, Weak}, + time::Duration, +}; + +/// The charge follows the shared frame through queues, history and handoff. +pub(super) struct WorkerFrame { + frame: LiveFrame, + bytes: usize, + used: Rc>, +} +impl std::ops::Deref for WorkerFrame { + type Target = LiveFrame; + fn deref(&self) -> &LiveFrame { + &self.frame + } +} +impl Drop for WorkerFrame { + fn drop(&mut self) { + self.used.set(self.used.get().saturating_sub(self.bytes)); + } +} +pub(super) type LiveSource = LocalBoxStream<'static, Result>; +pub(super) type LiveSourceFactory = + Rc LocalBoxFuture<'static, Result>>; +#[derive(Clone)] +struct Input { + admission: OriginAdmission, + request: serde_json::Value, + freshness: Option, + source: LiveSourceFactory, +} +struct Consumer { + ticket: LiveTicket, + input: Input, + frames: mpsc::Sender>, + reset: oneshot::Sender<&'static str>, +} +struct Group { + key: LiveKey, + initial: LiveKey, + consumers: BTreeSet, + history: VecDeque>, + complete_history: bool, + driver: Option, +} +impl Drop for Group { + fn drop(&mut self) { + if let Some(driver) = &self.driver { + driver.abort(); + } + } +} +struct State { + registry: LiveRegistry, + groups: BTreeMap, + consumers: BTreeMap, + next: u64, + upstreams: u64, + resets: u64, + frames: u64, + deduplicated: u64, + handoffs: u64, +} +/// Local stream drivers are owned by these groups; removing the last consumer +/// aborts the upstream and releases its socket/stream, including on disconnect. +pub(super) struct WorkerLive { + state: RefCell, + limits: LiveLimits, + payload_limit: usize, + payload_used: Rc>, + started: u64, +} +pub(super) struct LiveLease { + id: u64, + owner: Rc, + frames: mpsc::Receiver>, + reset: oneshot::Receiver<&'static str>, + expiry: u64, + terminal: Option<&'static str>, +} +impl Drop for LiveLease { + fn drop(&mut self) { + self.owner.remove(self.id, "consumer_left"); + } +} +impl LiveLease { + pub(super) async fn next(&mut self) -> Result>, &'static str> { + loop { + let remaining = Duration::from_secs(self.expiry.saturating_sub(super::now())); + if remaining.is_zero() { + return Err("AUTH_EXPIRED"); + } + if let Some(reason) = self.terminal { + if reason != "complete" { + return Err(reason); + } + let frame = self.frames.next().await; + return if super::now() >= self.expiry { + Err("AUTH_EXPIRED") + } else { + Ok(frame) + }; + } + enum Event { + Reset(&'static str), + Frame(Option>), + } + let next = async { + match select(Box::pin(&mut self.reset), Box::pin(self.frames.next())).await { + Either::Left((reason, _)) => { + Event::Reset(reason.unwrap_or("LIVE_RESET_REQUIRED")) + } + Either::Right((frame, _)) => Event::Frame(frame), + } + }; + match super::timer::deadline(remaining, next).await { + Err(_) => return Err("AUTH_EXPIRED"), + Ok(Event::Reset(reason)) => self.terminal = Some(reason), + Ok(Event::Frame(frame)) => { + return if super::now() >= self.expiry { + Err("AUTH_EXPIRED") + } else { + Ok(frame) + } + } + } + } + } + pub(super) async fn interrupted(&mut self) -> &'static str { + let remaining = Duration::from_secs(self.expiry.saturating_sub(super::now())); + if remaining.is_zero() { + return "AUTH_EXPIRED"; + } + if let Some(reason) = self.terminal { + if reason != "complete" { + return reason; + } + let _ = super::timer::Timer::new(remaining.as_millis() as u64).await; + return "AUTH_EXPIRED"; + } + match super::timer::deadline(remaining, &mut self.reset).await { + Ok(reason) => { + let reason = reason.unwrap_or("LIVE_RESET_REQUIRED"); + self.terminal = Some(reason); + if reason == "complete" { + let _ = super::timer::Timer::new(remaining.as_millis() as u64).await; + "AUTH_EXPIRED" + } else { + reason + } + } + Err(_) => "AUTH_EXPIRED", + } + } +} + +impl WorkerLive { + pub(super) fn new( + limits: LiveLimits, + payload_limit: usize, + ) -> Result, crate::gateway::GatewayError> { + let registry = LiveRegistry::new(limits) + .map_err(|_| crate::gateway::GatewayError("invalid live limits"))?; + Ok(Rc::new(Self { + state: RefCell::new(State { + registry, + groups: BTreeMap::new(), + consumers: BTreeMap::new(), + next: 0, + upstreams: 0, + resets: 0, + frames: 0, + deduplicated: 0, + handoffs: 0, + }), + limits, + payload_limit, + payload_used: Rc::new(Cell::new(0)), + started: worker::Date::now().as_millis(), + })) + } + pub(super) fn counts(&self) -> (usize, usize, u64, u64, u64, u64, u64) { + self.state + .try_borrow_mut() + .map_or((0, 0, 0, 0, 0, 0, 0), |s| { + ( + s.groups.len(), + s.consumers.len(), + s.upstreams, + s.resets, + s.frames, + s.deduplicated, + s.handoffs, + ) + }) + } + pub(super) fn join( + self: &Rc, + admission: OriginAdmission, + request: serde_json::Value, + freshness: Option, + source: LiveSourceFactory, + ) -> Result { + let initial = LiveKey::admitted(&admission, &request, freshness.as_ref(), super::now())?; + let input = Input { + admission, + request, + freshness, + source, + }; + let (mut frames, receiver) = mpsc::channel(self.limits.queue_frames); + let (reset, reset_receiver) = oneshot::channel(); + let mut state = self + .state + .try_borrow_mut() + .map_err(|_| DeliveryError::Unavailable)?; + let now = worker::Date::now().as_millis().saturating_sub(self.started); + state.registry.expire(now); + let expired = state + .groups + .keys() + .filter(|generation| !state.registry.contains_generation(**generation)) + .copied() + .collect::>(); + for generation in expired { + end(&mut state, generation, "LIVE_RESET_REQUIRED"); + } + state.next = state + .next + .checked_add(1) + .ok_or(DeliveryError::Unavailable)?; + let id = state.next; + let existing = state + .groups + .iter() + .find(|(_, group)| { + group.initial.same_initial(&initial) + && group.complete_history + && group.consumers.len() < self.limits.consumers + }) + .map(|(generation, group)| (*generation, group.key.clone())); + let key = existing + .map(|(_, key)| key) + .unwrap_or_else(|| initial.fork(id)); + let (ticket, owner) = state.registry.join(key.clone(), now)?; + let generation = ticket.generation(); + if owner { + state.groups.insert( + generation, + Group { + key, + initial, + consumers: BTreeSet::new(), + history: VecDeque::new(), + complete_history: true, + driver: None, + }, + ); + } + let group = state + .groups + .get_mut(&generation) + .ok_or(DeliveryError::Unavailable)?; + for frame in &group.history { + if !frame.satisfies(&input.admission, input.freshness.as_ref()) { + state.registry.leave(ticket); + return Err(DeliveryError::Pending); + } + frames + .try_send(frame.clone()) + .map_err(|_| DeliveryError::Unavailable)?; + } + group.consumers.insert(id); + let expiry = input.admission.expires_at; + state.consumers.insert( + id, + Consumer { + ticket, + input, + frames, + reset, + }, + ); + drop(state); + if owner { + let weak = Rc::downgrade(self); + let lifetime = self.limits.lifetime_ms; + let (handle, registration) = AbortHandle::new_pair(); + worker::wasm_bindgen_futures::spawn_local(async move { + let task = async move { + let reason = super::timer::deadline( + Duration::from_millis(lifetime), + drive(weak.clone(), generation), + ) + .await + .unwrap_or("LIVE_RESET_REQUIRED"); + if let Some(owner) = weak.upgrade() { + if let Ok(mut state) = owner.state.try_borrow_mut() { + end(&mut state, generation, reason); + } + } + }; + let _ = Abortable::new(task, registration).await; + }); + // No detached owner: Group owns the abort handle, and the driver + // retains only Weak so the coordinator can be dropped cleanly. + if let Ok(mut state) = self.state.try_borrow_mut() { + if let Some(group) = state.groups.get_mut(&generation) { + group.driver = Some(handle); + } else { + handle.abort(); + } + } else { + handle.abort(); + } + } + Ok(LiveLease { + id, + owner: self.clone(), + frames: receiver, + reset: reset_receiver, + expiry, + terminal: None, + }) + } + fn remove(&self, id: u64, reason: &'static str) { + if let Ok(mut state) = self.state.try_borrow_mut() { + remove(&mut state, id, reason); + } + } + fn input(&self, generation: u64) -> Option { + let mut state = self.state.try_borrow_mut().ok()?; + let group = state.groups.get(&generation)?; + let mut input = group + .consumers + .iter() + .filter_map(|id| state.consumers.get(id)) + .filter(|consumer| consumer.input.admission.expires_at > super::now()) + .max_by_key(|consumer| consumer.input.admission.expires_at)? + .input + .clone(); + // Reconnect under a remaining consumer's current credentials. The + // origin handles replay/reset at the last observed proven cursor. + if let Some(frame) = group.history.back() { + if !input.request["extensions"].is_object() { + input.request["extensions"] = serde_json::json!({}); + } + if !input.request["extensions"]["distributed"].is_object() { + input.request["extensions"]["distributed"] = serde_json::json!({}); + } + input.request["extensions"]["distributed"]["resume"] = serde_json::json!({"cursors":frame.payload()["extensions"]["distributed"]["live"]["cursors"]}); + } + state.upstreams = state.upstreams.saturating_add(1); + Some(input) + } + fn emit(&self, generation: u64, input: &Input, payload: serde_json::Value) -> bool { + let frame = match LiveFrame::from_origin( + &input.admission, + payload, + None, + self.limits.frame_bytes, + ) { + Ok(frame) => { + let bytes = + serde_json::to_vec(frame.payload()).map_or(usize::MAX, |bytes| bytes.len()); + let used = self.payload_used.get().saturating_add(bytes); + if used > self.payload_limit { + if let Ok(mut state) = self.state.try_borrow_mut() { + end(&mut state, generation, "LIVE_RESET_REQUIRED"); + } + return false; + } + self.payload_used.set(used); + Rc::new(WorkerFrame { + frame, + bytes, + used: self.payload_used.clone(), + }) + } + Err(_) => { + if let Ok(mut state) = self.state.try_borrow_mut() { + end(&mut state, generation, "LIVE_RESET_REQUIRED"); + } + return false; + } + }; + let Ok(mut state) = self.state.try_borrow_mut() else { + return false; + }; + state.frames = state.frames.saturating_add(1); + let Some(group) = state.groups.get_mut(&generation) else { + return false; + }; + if group + .history + .back() + .is_some_and(|last| last.same_frame(&frame)) + { + state.deduplicated = state.deduplicated.saturating_add(1); + return true; + } + group.history.push_back(frame.clone()); + if group.history.len() > self.limits.history_frames { + group.history.pop_front(); + group.complete_history = false; + } + let ids = group.consumers.iter().copied().collect::>(); + for id in ids { + let Some(consumer) = state.consumers.get_mut(&id) else { + continue; + }; + let reason = if consumer.input.admission.expires_at <= super::now() { + Some("AUTH_EXPIRED") + } else if !frame.satisfies(&consumer.input.admission, consumer.input.freshness.as_ref()) + { + Some("FRESHNESS_PENDING") + } else if consumer.frames.try_send(frame.clone()).is_err() { + Some("LIVE_RESET_REQUIRED") + } else { + None + }; + if let Some(reason) = reason { + remove(&mut state, id, reason); + } + } + // Each replay's own frame is queued first. At equal proven cursor and + // data, future frames can move to an existing operation without a gap. + let Some(group) = state.groups.get(&generation) else { + return false; + }; + let target = state + .groups + .iter() + .find(|(other, target)| { + **other < generation + && target.key.same_operation(&group.key) + && target + .history + .back() + .is_some_and(|head| head.same_cursor(&frame)) + }) + .map(|(id, group)| (*id, group.key.clone())); + if let Some((target, key)) = target { + let ids = group.consumers.iter().copied().collect::>(); + let now = worker::Date::now().as_millis().saturating_sub(self.started); + for id in ids { + let Ok((ticket, new_owner)) = state.registry.join(key.clone(), now) else { + break; + }; + if new_owner { + state.registry.leave(ticket); + break; + } + let Some(consumer) = state.consumers.get_mut(&id) else { + state.registry.leave(ticket); + continue; + }; + let old = std::mem::replace(&mut consumer.ticket, ticket); + state.registry.leave(old); + if let Some(group) = state.groups.get_mut(&generation) { + group.consumers.remove(&id); + } + if let Some(group) = state.groups.get_mut(&target) { + group.consumers.insert(id); + } + state.handoffs = state.handoffs.saturating_add(1); + } + if state + .groups + .get(&generation) + .is_some_and(|group| group.consumers.is_empty()) + { + state.groups.remove(&generation); + return false; + } + } + true + } +} +fn remove(state: &mut State, id: u64, reason: &'static str) { + let Some(consumer) = state.consumers.remove(&id) else { + return; + }; + let generation = consumer.ticket.generation(); + if reason != "consumer_left" && reason != "complete" { + state.resets = state.resets.saturating_add(1); + } + let _ = consumer.reset.send(reason); + let last = state.registry.leave(consumer.ticket); + if let Some(group) = state.groups.get_mut(&generation) { + group.consumers.remove(&id); + } + if last { + state.groups.remove(&generation); + } +} +fn end(state: &mut State, generation: u64, reason: &'static str) { + let ids = state + .groups + .get(&generation) + .map(|group| group.consumers.iter().copied().collect::>()) + .unwrap_or_default(); + for id in ids { + remove(state, id, reason); + } + state.groups.remove(&generation); +} +async fn drive(owner: Weak, generation: u64) -> &'static str { + loop { + let input = match owner.upgrade().and_then(|owner| owner.input(generation)) { + Some(input) => input, + None => return "AUTH_EXPIRED", + }; + let expiry = Duration::from_secs(input.admission.expires_at.saturating_sub(super::now())); + let run = async { + let mut stream = (input.source)(input.request.clone()).await?; + while let Some(frame) = stream.next().await { + let frame = frame?; + if !owner + .upgrade() + .is_some_and(|owner| owner.emit(generation, &input, frame)) + { + return Err("upstream no longer owned".into()); + } + } + Ok::<(), String>(()) + }; + match super::timer::deadline(expiry, run).await { + Ok(Ok(())) => return "complete", + Ok(Err(_)) => return "LIVE_RESET_REQUIRED", + Err(_) => continue, // Reauthenticate with a remaining unexpired consumer. + } + } +} diff --git a/src/gateway/worker/live_transport.rs b/src/gateway/worker/live_transport.rs new file mode 100644 index 000000000..fb02b41f0 --- /dev/null +++ b/src/gateway/worker/live_transport.rs @@ -0,0 +1,122 @@ +use super::{coordinator::OriginRequest, live::LiveSourceFactory, proxy, socket::Socket}; +use futures_util::{FutureExt, StreamExt}; +use std::rc::Rc; +use worker::{Fetch, Headers, Request, RequestInit, RequestRedirect, Result}; + +pub(super) async fn handshake( + input: &OriginRequest, + live_path: &str, + protocol: &str, +) -> Result { + let headers = Headers::new(); + for (name, value) in &input.headers { + if !matches!( + name.as_str(), + "content-length" | "content-type" | "sec-websocket-key" + ) { + headers.append(name, value)?; + } + } + proxy::prepare_headers(&headers, &input.options, &input.context, true)?; + headers.set("sec-websocket-protocol", protocol)?; + let public = worker::Url::parse(&input.url)?; + let url = format!( + "{}{}{}", + input.origin.trim_end_matches('/'), + live_path, + public.query().map(|q| format!("?{q}")).unwrap_or_default() + ); + let mut init = RequestInit::new(); + init.headers = headers; + init.redirect = RequestRedirect::Manual; + let response = proxy::timeout( + input.options.limits.header_timeout_ms, + Fetch::Request(Request::new_with_init(&url, &init)?).send(), + ) + .await?; + if response.status_code() != 101 + || response.headers().get("sec-websocket-protocol")?.as_deref() != Some(protocol) + { + return Err(worker::Error::RustError("upstream upgrade denied".into())); + } + let socket = response + .websocket() + .ok_or_else(|| worker::Error::RustError("upstream upgrade missing".into()))?; + Socket::new(socket, input.options.limits.websocket_buffer_bytes, 32) +} +pub(super) async fn initialize( + socket: &mut Socket, + init: serde_json::Value, + milliseconds: u64, +) -> std::result::Result { + socket.send(&serde_json::json!({"type":"connection_init","payload":init}))?; + super::timer::deadline(std::time::Duration::from_millis(milliseconds), async { + loop { + let value = socket.next().await?; + match value["type"].as_str() { + Some("connection_ack") => return Ok(value), + Some("ping") => { + socket.send(&serde_json::json!({"type":"pong","payload":value["payload"]}))? + } + Some("connection_error" | "error") => return Err("origin admission denied".into()), + _ => return Err("invalid origin initialization".into()), + } + } + }) + .await + .map_err(|_| "origin admission timed out".to_owned())? +} +pub(super) fn source( + input: OriginRequest, + live_path: String, + init: serde_json::Value, +) -> LiveSourceFactory { + Rc::new(move |mut request| { + let input = input.clone(); + let live_path = live_path.clone(); + let init = init.clone(); + async move { + let mut socket = handshake(&input, &live_path, "graphql-transport-ws") + .await + .map_err(|_| "origin unavailable".to_owned())?; + initialize(&mut socket, init, input.options.limits.header_timeout_ms).await?; + if let Some(extensions) = request + .get_mut("extensions") + .and_then(serde_json::Value::as_object_mut) + { + extensions.remove("gatewayDelivery"); + } + socket + .send(&serde_json::json!({"id":"upstream","type":"subscribe","payload":request}))?; + Ok( + futures_util::stream::unfold(socket, |mut socket| async move { + loop { + let value = match socket.next().await { + Ok(value) => value, + Err(error) => return Some((Err(error), socket)), + }; + match value["type"].as_str() { + Some("next") if value["id"] == "upstream" => { + return Some((Ok(value["payload"].clone()), socket)) + } + Some("complete") if value["id"] == "upstream" => return None, + Some("error") => { + return Some((Err("upstream operation failed".into()), socket)) + } + Some("ping") => { + if let Err(error) = socket.send( + &serde_json::json!({"type":"pong","payload":value["payload"]}), + ) { + return Some((Err(error), socket)); + } + } + _ => {} + } + } + }) + .boxed_local(), + ) + } + .boxed_local() + }) +} diff --git a/src/gateway/worker/mod.rs b/src/gateway/worker/mod.rs new file mode 100644 index 000000000..45627dcf2 --- /dev/null +++ b/src/gateway/worker/mod.rs @@ -0,0 +1,448 @@ +//! Explicit workers-rs gateway mounting. No listener or domain projector is linked. +mod cancellation; +mod coordinator; +mod frontend; +mod live; +mod live_transport; +mod proxy; +mod raw_socket; +mod socket; +mod timer; +pub use coordinator::{WorkerCoordinator, WorkerDeliveryBinding, WorkerDeliveryOptions}; + +use super::{ + Admission, AuthError, BackendCredential, BindingKind, Credentials, Gateway, GatewayError, + GraphqlExecutor, RequestContext, +}; +use futures_util::future::LocalBoxFuture; +use std::{collections::BTreeMap, future::Future, rc::Rc}; +use worker::{Env, Request, Response, Result}; + +/// Local-future provider; every consumer authenticates before route admission. +#[derive(Clone)] +pub struct WorkerAuth( + Rc< + dyn Fn( + Credentials, + ) -> LocalBoxFuture<'static, std::result::Result>, + >, +); +impl WorkerAuth { + /// Bind a trusted application/session provider. Backend authorization remains mandatory. + pub fn new(provider: F) -> Self + where + F: Fn(Credentials) -> Fut + 'static, + Fut: Future> + 'static, + { + Self(Rc::new(move |credentials| Box::pin(provider(credentials)))) + } + /// Opaque cookies may reach delegated auth/UI handlers. Unvalidated bearer input fails closed. + pub fn anonymous() -> Self { + Self::new(|credentials| async move { + if credentials.authorization.is_some() { + return Err(AuthError::Unauthorized); + } + RequestContext::from_provider(None, "anonymous-v1", BackendCredential::None) + .map_err(|_| AuthError::Unavailable) + }) + } +} +/// Custom mounted handler, including application-owned auth lifecycle handlers. +#[derive(Clone)] +pub struct WorkerHandler( + Rc LocalBoxFuture<'static, Result>>, +); +impl WorkerHandler { + /// Adapt a local Worker handler without requiring Send or a native runtime. + pub fn new(handler: F) -> Self + where + F: Fn(Request, RequestContext, Env) -> Fut + 'static, + Fut: Future> + 'static, + { + Self(Rc::new(move |request, context, env| { + Box::pin(handler(request, context, env)) + })) + } +} +/// Explicit per-request and upgraded-connection bounds. +#[derive(Clone, Debug)] +pub struct WorkerLimits { + /// Maximum incoming request bytes. + pub request_bytes: usize, + /// Maximum aggregate queued wire bytes per WebSocket (also bounds one frame). + pub websocket_buffer_bytes: usize, + /// Maximum response header wait in milliseconds. + pub header_timeout_ms: u64, + /// Maximum response stream idle wait in milliseconds. + pub read_timeout_ms: u64, + /// Maximum upgraded connection lifetime in milliseconds. + pub websocket_lifetime_ms: u64, +} +impl Default for WorkerLimits { + fn default() -> Self { + Self { + request_bytes: 256 * 1024, + websocket_buffer_bytes: 256 * 1024, + header_timeout_ms: 30000, + read_timeout_ms: 60000, + websocket_lifetime_ms: 3600000, + } + } +} +/// Trusted ingress configuration; public origin is never inferred from forwarded headers. +#[derive(Clone, Debug)] +pub struct WorkerOptions { + /// Canonical public HTTP(S) origin. + pub public_origin: String, + /// Bounded transport settings. + pub limits: WorkerLimits, + /// Additional incoming identity/secret header names to remove. + pub strip_headers: Vec, + /// Stable deployment identity used to reject proxy loops, not a secret. + pub hop_id: String, +} +impl WorkerOptions { + /// Default bounded settings for the public origin. + pub fn new(public_origin: impl Into) -> Self { + Self { + public_origin: public_origin.into(), + limits: WorkerLimits::default(), + strip_headers: Vec::new(), + hop_id: "application-gateway-worker-v1".into(), + } + } +} +/// Resources allocated only for explicitly selected portable bindings. +#[derive(Clone)] +pub enum WorkerBinding { + /// Local application handler. + Handler(WorkerHandler), + /// Static asset service binding, fetched only after route admission. + Assets(String), + /// Whole UI/auth reverse proxy. + UiProxy { + /// Allow transparent WebSocket upgrades to this UI target. + websocket: bool, + }, + /// Named portable admission policy. + Admission(Admission), + /// Whole remote GraphQL endpoint; embedded executors are rejected. + Graphql { + /// Origin HTTP path, independent of the public mount path. + http_path: String, + /// Optional origin live endpoint path. + live_path: Option, + /// Optional sharded Durable Object delivery; never isolate-local state. + delivery: Option, + }, +} +/// A mounted Worker gateway. Construction starts no network work. +#[derive(Clone)] +pub struct WorkerGateway(Rc); +struct Inner { + gateway: Gateway, + options: WorkerOptions, + bindings: BTreeMap, + auth: WorkerAuth, +} +impl WorkerGateway { + /// Validate bindings and capabilities before handling requests. + pub fn new( + gateway: Gateway, + options: WorkerOptions, + bindings: impl IntoIterator, + auth: WorkerAuth, + ) -> std::result::Result { + super::config::validate_origin(&options.public_origin)?; + if options.hop_id.is_empty() + || options.hop_id.len() > 128 + || !options + .hop_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) + || options.strip_headers.len() > 128 + || options + .strip_headers + .iter() + .any(|s| s.is_empty() || !s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) + { + return Err(GatewayError("invalid Worker header configuration")); + } + let limits = &options.limits; + if limits.request_bytes == 0 + || limits.request_bytes > 16 * 1024 * 1024 + || limits.websocket_buffer_bytes == 0 + || limits.websocket_buffer_bytes > 1024 * 1024 + || limits.header_timeout_ms == 0 + || limits.header_timeout_ms > 300000 + || limits.read_timeout_ms == 0 + || limits.read_timeout_ms > 300000 + || limits.websocket_lifetime_ms == 0 + || limits.websocket_lifetime_ms > 3600000 + { + return Err(GatewayError("invalid Worker transport limits")); + } + let mut registry = BTreeMap::new(); + for (id, binding) in bindings { + let declaration = gateway + .binding(&id) + .ok_or(GatewayError("undeclared Worker binding"))?; + let compatible = match (&declaration.kind, &binding) { + (BindingKind::Handler, WorkerBinding::Handler(_)) + | (BindingKind::Admission, WorkerBinding::Admission(_)) + | (BindingKind::Assets, WorkerBinding::Assets(_)) => true, + (BindingKind::UiProxy { origin }, WorkerBinding::UiProxy { .. }) => { + origin.trim_end_matches('/') != options.public_origin.trim_end_matches('/') + } + ( + BindingKind::Graphql { + executor: GraphqlExecutor::Remote { origin }, + capabilities, + delivery, + .. + }, + WorkerBinding::Graphql { + http_path, + live_path, + delivery: resource, + }, + ) => { + origin.trim_end_matches('/') != options.public_origin.trim_end_matches('/') + && super::route::normalize_path(http_path).is_ok() + && live_path + .as_ref() + .is_none_or(|p| super::route::normalize_path(p).is_ok()) + && capabilities.live == live_path.is_some() + && resource.as_ref().map_or_else( + || { + !delivery.snapshots + && !delivery.coalescing + && !delivery.live_sharing + }, + |resource| { + resource.validate().is_ok() + && resource.options.capabilities() == *delivery + }, + ) + } + _ => false, + }; + if !compatible || registry.insert(id, binding).is_some() { + return Err(GatewayError("incompatible or duplicate Worker binding")); + } + } + if gateway.routes().iter().any(|route| { + !registry.contains_key(&route.target) + || route.admission.iter().any(|id| !registry.contains_key(id)) + }) { + return Err(GatewayError("missing Worker binding")); + } + Ok(Self(Rc::new(Inner { + gateway, + options, + bindings: registry, + auth, + }))) + } + /// Select one owner, authenticate, admit and execute exactly once. + pub async fn fetch(&self, request: Request, env: Env) -> Result { + cancellation::run(request.inner().signal(), self.dispatch(request, env, None)).await + } + /// Execute inside the selected Durable Object, repeating provider and origin admission. + /// This entrypoint is an in-process mount, never a client header or URL flag. + pub async fn fetch_coordinated( + &self, + request: Request, + env: Env, + coordinator: Rc, + ) -> Result { + cancellation::run( + request.inner().signal(), + self.dispatch(request, env, Some(coordinator)), + ) + .await + } + async fn dispatch( + &self, + mut request: Request, + env: Env, + coordinator: Option>, + ) -> Result { + let url = request.url()?; + let selected = match self.0.gateway.select(request.method().as_ref(), url.path()) { + Ok(Some(selected)) => selected, + Ok(None) => return Response::error("not found", 404), + Err(_) => return Response::error("invalid route", 400), + }; + let credentials = Credentials { + authorization: request.headers().get("authorization")?, + cookie: request.headers().get("cookie")?, + }; + let context = match (self.0.auth.0)(credentials).await { + Ok(context) => context, + Err(error) => return auth_error(error), + }; + if let Err(error) = Admission::Public.check(&context, now()) { + return auth_error(error); + } + for id in &selected.route().admission { + let Some(WorkerBinding::Admission(policy)) = self.0.bindings.get(id) else { + return Response::error("invalid admission", 503); + }; + if let Err(error) = policy.check(&context, now()) { + return auth_error(error); + } + } + if !selected.method_allowed() { + return Response::error("method not allowed", 405); + } + let binding = &self.0.bindings[&selected.binding().id]; + match (binding, &selected.binding().kind) { + (WorkerBinding::Handler(handler), _) => (handler.0)(request, context, env).await, + (WorkerBinding::Assets(binding), _) => { + if !matches!(request.method(), worker::Method::Get | worker::Method::Head) { + return Response::error("method not allowed", 405); + } + env.service(binding)?.fetch_request(request).await + } + (WorkerBinding::UiProxy { websocket }, BindingKind::UiProxy { origin }) => { + proxy::forward(request, origin, None, *websocket, &self.0.options, &context).await + } + ( + WorkerBinding::Graphql { + http_path, + live_path, + delivery, + }, + BindingKind::Graphql { + executor: GraphqlExecutor::Remote { origin }, + capabilities, + .. + }, + ) => { + if proxy::is_upgrade(&request)? { + if let (Some(coordinator), Some(delivery)) = (&coordinator, delivery) { + if coordinator.capabilities() != delivery.options.capabilities() { + return Response::error("coordinator configuration mismatch", 503); + } + let input = coordinator::OriginRequest { + origin: origin.clone(), + path: http_path.clone(), + url: request.url()?.to_string(), + headers: request.headers().entries().collect(), + options: self.0.options.clone(), + context, + }; + return coordinator.upgrade( + input, + live_path + .clone() + .ok_or_else(|| worker::Error::RustError("live disabled".into()))?, + *capabilities, + ); + } + } + if proxy::is_upgrade(&request)? && coordinator.is_none() { + if !capabilities.live { + return Response::error("live disabled", 405); + } + let input = coordinator::OriginRequest { + origin: origin.clone(), + path: http_path.clone(), + url: request.url()?.to_string(), + headers: request.headers().entries().collect(), + options: self.0.options.clone(), + context, + }; + return frontend::upgrade( + self.clone(), + env, + request, + input, + live_path.clone().expect("validated live path"), + *capabilities, + delivery.clone(), + selected.binding().id.clone(), + ) + .await; + } + if request.method() != worker::Method::Post { + return Response::error("method not allowed", 405); + } + let bytes = match proxy::timeout( + self.0.options.limits.header_timeout_ms, + proxy::read_request(&mut request, self.0.options.limits.request_bytes), + ) + .await + { + Ok(bytes) => bytes, + Err(worker::Error::RustError(message)) if message == "request too large" => { + return Response::error(message, 413) + } + Err(_) => return Response::error("invalid or timed out request body", 400), + }; + let value: serde_json::Value = match serde_json::from_slice(&bytes) { + Ok(value) => value, + Err(_) => return Response::error("invalid GraphQL request", 400), + }; + if let Err(error) = super::graphql::admit_request(&value, *capabilities) { + return Response::from_json(&error.envelope()); + } + if let Some(delivery) = delivery.as_ref().filter(|delivery| { + delivery.options.snapshots.is_some() || delivery.options.coalescing.is_some() + }) { + if super::graphql::operation_kind( + value["query"].as_str().unwrap_or(""), + value["operationName"].as_str(), + ) == Ok(super::graphql::OperationKind::Query) + { + if let Some(coordinator) = coordinator { + if coordinator.capabilities() != delivery.options.capabilities() { + return Response::error("coordinator configuration mismatch", 503); + } + let input = coordinator::OriginRequest { + origin: origin.clone(), + path: http_path.clone(), + url: request.url()?.to_string(), + headers: request.headers().entries().collect(), + options: self.0.options.clone(), + context, + }; + return coordinator.execute(input, value).await; + } + let shard = delivery.shard(&selected.binding().id, &value)?; + let namespace = env.durable_object(&delivery.namespace)?; + let stub = namespace.id_from_name(&shard)?.get_stub()?; + return stub + .fetch_with_request(proxy::with_body(&request, bytes)?) + .await; + } + } + let request = proxy::with_body(&request, bytes)?; + proxy::forward( + request, + origin, + Some(http_path), + false, + &self.0.options, + &context, + ) + .await + } + _ => Response::error("invalid execution binding", 503), + } + } +} +fn auth_error(error: AuthError) -> Result { + Response::error( + error.to_string(), + match error { + AuthError::Unauthorized => 401, + AuthError::Forbidden => 403, + AuthError::Unavailable => 503, + }, + ) +} +fn now() -> u64 { + worker::Date::now().as_millis() / 1000 +} diff --git a/src/gateway/worker/proxy.rs b/src/gateway/worker/proxy.rs new file mode 100644 index 000000000..6e2c693c7 --- /dev/null +++ b/src/gateway/worker/proxy.rs @@ -0,0 +1,297 @@ +use super::{RequestContext, WorkerOptions}; +use crate::gateway::{is_untrusted_identity_header, BackendCredential}; +use futures_util::{ + future::{select, Either}, + StreamExt, +}; +use std::{cell::Cell, future::Future, rc::Rc}; +use worker::{ + AbortController, Fetch, Headers, Request, RequestInit, RequestRedirect, Response, Result, + WebSocketPair, +}; + +pub(super) async fn timeout( + milliseconds: u64, + future: impl Future>, +) -> Result { + match select( + Box::pin(future), + Box::pin(super::timer::Timer::new(milliseconds)), + ) + .await + { + Either::Left((result, _)) => result, + Either::Right(_) => Err(worker::Error::RustError("gateway deadline exceeded".into())), + } +} +struct AbortOnDrop(Option); +impl Drop for AbortOnDrop { + fn drop(&mut self) { + if let Some(controller) = self.0.take() { + controller.abort(); + } + } +} +pub(super) fn is_upgrade(request: &Request) -> Result { + Ok(request + .headers() + .get("upgrade")? + .is_some_and(|value| value.eq_ignore_ascii_case("websocket"))) +} +pub(super) fn prepare_headers( + headers: &Headers, + options: &WorkerOptions, + context: &RequestContext, + upgrade: bool, +) -> Result<()> { + let nominated = headers.get("connection")?.unwrap_or_default(); + for name in nominated + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + { + headers.delete(name)?; + } + for name in headers.keys().collect::>() { + if is_untrusted_identity_header(&name) + || options + .strip_headers + .iter() + .any(|s| s.eq_ignore_ascii_case(&name)) + || matches!( + name.as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "authorization" + | "host" + ) + { + headers.delete(&name)?; + } + } + if let BackendCredential::Bearer(token) = context.backend_credential() { + headers.set("authorization", &format!("Bearer {token}"))?; + } + let public = worker::Url::parse(&options.public_origin)?; + let authority = &public[url::Position::BeforeHost..url::Position::AfterPort]; + headers.set("x-forwarded-host", authority)?; + headers.set("x-forwarded-proto", public.scheme())?; + // Workers require Host to agree with the fetched URL. Trusted public origin + // is therefore carried in forwarded headers for delegated auth handlers. + if upgrade { + headers.set("upgrade", "websocket")?; + } + let hops = headers + .get("x-distributed-gateway-hops")? + .unwrap_or_default(); + if hops.len() > 1024 + || hops.split(',').count() > 8 + || hops.split(',').any(|s| s.trim() == options.hop_id) + { + return Err(worker::Error::RustError("gateway loop".into())); + } + headers.set( + "x-distributed-gateway-hops", + &if hops.is_empty() { + options.hop_id.clone() + } else { + format!("{hops},{}", options.hop_id) + }, + )?; + Ok(()) +} +pub(super) async fn read_request(request: &mut Request, max: usize) -> Result> { + if request + .headers() + .get("content-length")? + .and_then(|s| s.parse::().ok()) + .is_some_and(|n| n > max) + { + return Err(worker::Error::RustError("request too large".into())); + } + if request.inner().body().is_none() { + return Ok(Vec::new()); + } + let mut stream = request.stream()?; + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + if bytes.len().saturating_add(chunk.len()) > max { + return Err(worker::Error::RustError("request too large".into())); + } + bytes.extend(chunk); + } + Ok(bytes) +} +pub(super) fn with_body(request: &Request, bytes: Vec) -> Result { + let mut init = RequestInit::new(); + init.method = request.method(); + init.headers = copy_headers(request.headers())?; + init.redirect = RequestRedirect::Manual; + init.body = Some(worker::js_sys::Uint8Array::from(bytes.as_slice()).into()); + super::cancellation::preserve_signal( + Request::new_with_init(request.url()?.as_str(), &init)?, + &request.inner().signal(), + ) +} +pub(super) async fn forward( + mut request: Request, + origin: &str, + path: Option<&str>, + allow_upgrade: bool, + options: &WorkerOptions, + context: &RequestContext, +) -> Result { + let upgrade = is_upgrade(&request)?; + if upgrade && !allow_upgrade { + return Response::error("upgrade disabled", 400); + } + if request + .headers() + .get("content-length")? + .and_then(|s| s.parse::().ok()) + .is_some_and(|n| n > options.limits.request_bytes) + { + return Response::error("request too large", 413); + } + let source = request.url()?; + let url = format!( + "{}{}{}", + origin.trim_end_matches('/'), + path.unwrap_or(source.path()), + source.query().map(|q| format!("?{q}")).unwrap_or_default() + ); + let headers = copy_headers(request.headers())?; + if prepare_headers(&headers, options, context, upgrade).is_err() { + return Response::error("invalid proxy request", 400); + } + let mut init = RequestInit::new(); + init.method = request.method(); + init.headers = headers; + init.redirect = RequestRedirect::Manual; + init.cache = Some(worker::CacheMode::NoStore); + let oversized = Rc::new(Cell::new(false)); + if request.inner().body().is_some() { + let oversized = oversized.clone(); + let max = options.limits.request_bytes; + let stream = request.stream()?; + let bounded = + futures_util::stream::try_unfold((stream, 0usize), move |(mut stream, total)| { + let oversized = oversized.clone(); + async move { + match stream.next().await { + Some(chunk) => { + let chunk = chunk?; + let total = total.saturating_add(chunk.len()); + if total > max { + oversized.set(true); + return Err(worker::Error::RustError("request too large".into())); + } + Ok(Some((chunk, (stream, total)))) + } + None => Ok(None), + } + } + }); + let body: worker::web_sys::Response = Response::from_stream(bounded)?.into(); + init.body = body.body().map(Into::into); + } + let outbound = Request::new_with_init(&url, &init)?; + let controller = AbortController::default(); + let signal = controller.signal(); + let guard = AbortOnDrop(Some(controller)); + let mut response = match timeout( + options.limits.header_timeout_ms, + Fetch::Request(outbound).send_with_signal(&signal), + ) + .await + { + Ok(response) => response, + Err(_) if oversized.get() => return Response::error("request too large", 413), + Err(_) => return Response::error("origin unavailable", 502), + }; + if oversized.get() { + return Response::error("request too large", 413); + } + let mutable_headers = copy_headers(response.headers())?; + response = response.with_headers(mutable_headers); + rewrite_redirect(&mut response, origin, &options.public_origin)?; + if response.status_code() == 101 { + if !allow_upgrade { + return Response::error("unexpected upgrade", 502); + } + let headers = copy_headers(response.headers())?; + let Some(origin) = response.websocket() else { + return Response::error("invalid upgrade", 502); + }; + let pair = WebSocketPair::new()?; + let client = pair.client; + let lifetime = + context + .identity() + .map_or(options.limits.websocket_lifetime_ms, |identity| { + options + .limits + .websocket_lifetime_ms + .min(identity.expires_at().saturating_sub(super::now()) * 1000) + }); + let max = options.limits.websocket_buffer_bytes; + worker::wasm_bindgen_futures::spawn_local(async move { + let _guard = guard; + let _ = timeout( + lifetime, + super::raw_socket::bridge(pair.server, origin, max), + ) + .await; + }); + return Ok(Response::from_websocket(client)?.with_headers(headers)); + } + if request.method() == worker::Method::Head || matches!(response.status_code(), 204 | 304) { + return Ok(response); + } + let headers = copy_headers(response.headers())?; + let status = response.status_code(); + let encode = *response.encode_body(); + if !matches!(response.body(), worker::ResponseBody::Stream(_)) { + return Ok(response); + } + let stream = response.stream()?; + let idle = options.limits.read_timeout_ms; + let bounded = + futures_util::stream::try_unfold((stream, guard), move |(mut stream, guard)| async move { + let next = timeout(idle, async { stream.next().await.transpose() }).await?; + Ok::<_, worker::Error>(next.map(|chunk| (chunk, (stream, guard)))) + }); + Ok(Response::from_stream(bounded)? + .with_status(status) + .with_headers(headers) + .with_encode_body(encode)) +} +fn rewrite_redirect(response: &mut Response, origin: &str, public: &str) -> Result<()> { + if let Some(location) = response.headers().get("location")? { + if let Ok(url) = worker::Url::parse(&location) { + if url.origin() == worker::Url::parse(origin)?.origin() { + response.headers_mut().set( + "location", + &format!( + "{}{}", + public.trim_end_matches('/'), + &url[url::Position::BeforePath..] + ), + )?; + } + } + } + Ok(()) +} +pub(super) fn copy_headers(headers: &Headers) -> Result { + Ok(Headers(worker::web_sys::Headers::new_with_headers( + &headers.0, + )?)) +} diff --git a/src/gateway/worker/raw_socket.rs b/src/gateway/worker/raw_socket.rs new file mode 100644 index 000000000..df6430d7d --- /dev/null +++ b/src/gateway/worker/raw_socket.rs @@ -0,0 +1,166 @@ +use futures_channel::mpsc; +use futures_util::StreamExt; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; +use worker::{ + js_sys::{ArrayBuffer, Reflect, Uint8Array}, + wasm_bindgen::{closure::Closure, JsCast, JsValue}, + Result, WebSocket, +}; + +type SocketCallback = (&'static str, Closure); +pub(super) enum Frame { + Text(String), + Bytes(Vec), + Close(u16, String), +} +impl Frame { + fn size(&self) -> usize { + match self { + Self::Text(s) => s.len(), + Self::Bytes(b) => b.len(), + Self::Close(_, s) => s.len(), + } + } + pub fn send(&self, socket: &WebSocket) -> Result<()> { + match self { + Self::Text(s) => socket.send_with_str(s), + Self::Bytes(b) => socket.send_with_bytes(b), + Self::Close(code, reason) => socket.close(Some(*code), Some(reason)), + } + } +} +/// UI proxy callbacks have both frame-count and aggregate-byte bounds. +pub(super) struct RawSocket { + pub ws: WebSocket, + receiver: mpsc::Receiver>, + bytes: Rc>, + overflow: Rc>, + callbacks: Vec, +} +impl RawSocket { + pub fn new(ws: WebSocket, max: usize) -> Result { + Reflect::set(ws.as_ref(), &"binaryType".into(), &"arraybuffer".into())?; + let (sender, receiver) = mpsc::channel(16); + let sender = Rc::new(RefCell::new(sender)); + let bytes = Rc::new(Cell::new(0usize)); + let overflow = Rc::new(Cell::new(false)); + let mut callbacks = Vec::new(); + for kind in ["message", "close", "error"] { + let sender = sender.clone(); + let bytes = bytes.clone(); + let overflow = overflow.clone(); + let socket = ws.clone(); + let callback = Closure::wrap_assert_unwind_safe(Box::new(move |event: JsValue| { + let frame = match kind { + "message" => Reflect::get(&event, &"data".into()).ok().and_then(|data| { + if let Some(text) = data.as_string() { + Some(Frame::Text(text)) + } else if data.is_instance_of::() { + Some(Frame::Bytes(Uint8Array::new(&data).to_vec())) + } else { + None + } + }), + "close" => { + let code = Reflect::get(&event, &"code".into()) + .ok() + .and_then(|c| c.as_f64()) + .unwrap_or(1012.0) as u16; + let reason = Reflect::get(&event, &"reason".into()) + .ok() + .and_then(|c| c.as_string()) + .unwrap_or_default(); + Some(Frame::Close( + if matches!(code, 1005 | 1006 | 1015) { + 1012 + } else { + code + }, + reason, + )) + } + _ => None, + }; + let total = bytes + .get() + .saturating_add(frame.as_ref().map_or(0, Frame::size)); + let mut sender = sender.borrow_mut(); + if total > max || sender.try_send(frame).is_err() { + overflow.set(true); + sender.close_channel(); + let _ = socket.close(Some(1013), Some("proxy queue limit")); + } else { + bytes.set(total); + } + }) + as Box); + ws.as_ref() + .add_event_listener_with_callback(kind, callback.as_ref().unchecked_ref())?; + callbacks.push((kind, callback)); + } + ws.accept()?; + Ok(Self { + ws, + receiver, + bytes, + overflow, + callbacks, + }) + } + async fn next(&mut self) -> Option { + if self.overflow.get() { + return None; + } + let frame = self.receiver.next().await??; + self.bytes + .set(self.bytes.get().saturating_sub(frame.size())); + if self.overflow.get() { + None + } else { + Some(frame) + } + } +} +impl Drop for RawSocket { + fn drop(&mut self) { + for (kind, callback) in self.callbacks.drain(..) { + let _ = self + .ws + .as_ref() + .remove_event_listener_with_callback(kind, callback.as_ref().unchecked_ref()); + } + let _ = self.ws.close(Some(1012), Some("gateway connection ended")); + } +} +pub(super) async fn bridge(client: WebSocket, origin: WebSocket, max: usize) -> Result<()> { + let mut client = RawSocket::new(client, max)?; + let mut origin = RawSocket::new(origin, max)?; + let mut delivered = 0usize; + loop { + let (frame, from_client) = + match futures_util::future::select(Box::pin(client.next()), Box::pin(origin.next())) + .await + { + futures_util::future::Either::Left((frame, _)) => (frame, true), + futures_util::future::Either::Right((frame, _)) => (frame, false), + }; + let Some(frame) = frame else { + return Ok(()); + }; + delivered = delivered.saturating_add(frame.size()); + // workerd exposes no bufferedAmount or send backpressure for arbitrary + // UI protocols. Bound cumulative delivery, then require reconnect. + if delivered > max.saturating_mul(8) { + let _ = client.ws.close(Some(1013), Some("proxy delivery limit")); + let _ = origin.ws.close(Some(1013), Some("proxy delivery limit")); + return Ok(()); + } + frame.send(if from_client { &origin.ws } else { &client.ws })?; + if matches!(frame, Frame::Close(..)) { + return Ok(()); + } + } +} diff --git a/src/gateway/worker/socket.rs b/src/gateway/worker/socket.rs new file mode 100644 index 000000000..7226b0ca7 --- /dev/null +++ b/src/gateway/worker/socket.rs @@ -0,0 +1,99 @@ +use futures_channel::mpsc; +use futures_util::StreamExt; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; +use worker::{ + js_sys::Reflect, + wasm_bindgen::{closure::Closure, JsCast, JsValue}, + Result, WebSocket, +}; +/// Runtime callbacks feed a bounded queue. workers-rs EventStream is unbounded. +type SocketCallback = (&'static str, Closure); +pub(super) struct Socket { + pub ws: WebSocket, + receiver: mpsc::Receiver>, + overflow: Rc>, + queued_bytes: Rc>, + callbacks: Vec, +} +impl Socket { + pub fn new(ws: WebSocket, max_bytes: usize, queue: usize) -> Result { + let (sender, receiver) = mpsc::channel(queue); + let sender = Rc::new(RefCell::new(sender)); + let overflow = Rc::new(Cell::new(false)); + let queued_bytes = Rc::new(Cell::new(0usize)); + let mut callbacks = Vec::new(); + for kind in ["message", "close", "error"] { + let sender = sender.clone(); + let overflow = overflow.clone(); + let queued_bytes = queued_bytes.clone(); + let socket = ws.clone(); + let callback = Closure::wrap_assert_unwind_safe(Box::new(move |event: JsValue| { + let value = if kind == "message" { + Reflect::get(&event, &"data".into()) + .ok() + .and_then(|data| data.as_string()) + .filter(|text| text.len() <= max_bytes) + .ok_or_else(|| "invalid or oversized GraphQL frame".to_owned()) + } else { + Err("socket disconnected".to_owned()) + }; + let mut sender = sender.borrow_mut(); + let bytes = value.as_ref().map_or(0, String::len); + let total = queued_bytes.get().saturating_add(bytes); + if total > max_bytes || sender.try_send(value).is_err() { + overflow.set(true); + sender.close_channel(); + let _ = socket.close(Some(1013), Some("LIVE_RESET_REQUIRED")); + } else { + queued_bytes.set(total); + } + }) + as Box); + ws.as_ref() + .add_event_listener_with_callback(kind, callback.as_ref().unchecked_ref())?; + callbacks.push((kind, callback)); + } + ws.accept()?; + Ok(Self { + ws, + receiver, + overflow, + queued_bytes, + callbacks, + }) + } + pub async fn next(&mut self) -> std::result::Result { + if self.overflow.get() { + return Err("LIVE_RESET_REQUIRED".into()); + } + let value = self + .receiver + .next() + .await + .ok_or_else(|| "socket disconnected".to_owned())?; + if self.overflow.get() { + return Err("LIVE_RESET_REQUIRED".into()); + } + let text = value?; + self.queued_bytes + .set(self.queued_bytes.get().saturating_sub(text.len())); + serde_json::from_str(&text).map_err(|_| "invalid GraphQL frame".to_owned()) + } + pub fn send(&self, value: &serde_json::Value) -> std::result::Result<(), String> { + self.ws.send(value).map_err(|_| "socket send failed".into()) + } +} +impl Drop for Socket { + fn drop(&mut self) { + for (kind, callback) in self.callbacks.drain(..) { + let _ = self + .ws + .as_ref() + .remove_event_listener_with_callback(kind, callback.as_ref().unchecked_ref()); + } + let _ = self.ws.close(Some(1012), Some("gateway connection ended")); + } +} diff --git a/src/gateway/worker/timer.rs b/src/gateway/worker/timer.rs new file mode 100644 index 000000000..8954fba22 --- /dev/null +++ b/src/gateway/worker/timer.rs @@ -0,0 +1,99 @@ +use std::{ + cell::{Cell, RefCell}, + future::Future, + pin::Pin, + rc::Rc, + task::{Context, Poll, Waker}, +}; +use worker::{ + js_sys::{self, Function, Reflect}, + wasm_bindgen::{closure::Closure, JsCast, JsValue}, + Result, +}; +// workers-rs 0.8 Delay requires a numeric timer ID. Recent workerd can return +// a Timeout object; retain the opaque handle and pass it back unchanged. +pub(super) struct Timer { + millis: u64, + callback: Option>, + handle: Option, + ready: Rc>, + waker: Rc>>, +} +impl Timer { + pub(super) fn new(millis: u64) -> Self { + Self { + millis, + callback: None, + handle: None, + ready: Rc::new(Cell::new(false)), + waker: Rc::new(RefCell::new(None)), + } + } +} +impl Future for Timer { + type Output = Result<()>; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + if this.ready.get() { + return Poll::Ready(Ok(())); + } + *this.waker.borrow_mut() = Some(cx.waker().clone()); + if this.callback.is_none() { + let ready = this.ready.clone(); + let waker = this.waker.clone(); + let callback = Closure::wrap_assert_unwind_safe(Box::new(move || { + ready.set(true); + if let Some(waker) = waker.borrow_mut().take() { + waker.wake(); + } + }) as Box); + let global = js_sys::global(); + let result = (|| -> Result { + let function = Reflect::get(&global, &"setTimeout".into())? + .dyn_into::() + .map_err(worker::Error::from)?; + Ok(function.call2( + &global, + callback.as_ref(), + &JsValue::from_f64(this.millis as f64), + )?) + })(); + match result { + Ok(handle) => { + this.handle = Some(handle); + this.callback = Some(callback); + } + Err(error) => return Poll::Ready(Err(error)), + } + } + Poll::Pending + } +} +impl Drop for Timer { + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + let global = js_sys::global(); + if let Ok(function) = Reflect::get(&global, &"clearTimeout".into()) + .and_then(|value| value.dyn_into::()) + { + let _ = function.call1(&global, &handle); + } + } + } +} + +// Generic runtime deadline used by cancellation-owned stream drivers. +pub(super) async fn deadline( + duration: std::time::Duration, + future: impl Future, +) -> std::result::Result { + match futures_util::future::select( + Box::pin(future), + Box::pin(Timer::new(duration.as_millis().min(u64::MAX as u128) as u64)), + ) + .await + { + futures_util::future::Either::Left((value, _)) => Ok(value), + futures_util::future::Either::Right(_) => Err(()), + } +} diff --git a/src/graphql/client_manifest/export.rs b/src/graphql/client_manifest/export.rs index 533d29bb4..a7be2ffb9 100644 --- a/src/graphql/client_manifest/export.rs +++ b/src/graphql/client_manifest/export.rs @@ -63,13 +63,11 @@ impl DistributedClientSurfaceExport { name, eligible_roles, schema_roles, - } => { - ClientSurfaceIdentity::application_with_schema_roles( - name, - eligible_roles.clone(), - schema_roles.clone(), - ) - } + } => ClientSurfaceIdentity::application_with_schema_roles( + name, + eligible_roles.clone(), + schema_roles.clone(), + ), }; validate_service_provenance(&service_id, &surface)?; Ok(Self::new(service_id, identity, surface, execution)) @@ -98,13 +96,11 @@ impl DistributedClientSurfaceExport { name, eligible_roles, schema_roles, - } => { - ClientSurfaceIdentity::application_with_schema_roles( - name, - eligible_roles.clone(), - schema_roles.clone(), - ) - } + } => ClientSurfaceIdentity::application_with_schema_roles( + name, + eligible_roles.clone(), + schema_roles.clone(), + ), }; if surface.service_binding.is_some() { return Err(ClientManifestError( @@ -175,17 +171,15 @@ pub fn prune_client_manifest( ))); } } - manifest.models.retain(|model| { - allowed.contains(&model.id) || allowed.contains(&model.typename) - }); + manifest + .models + .retain(|model| allowed.contains(&model.id) || allowed.contains(&model.typename)); let kept: BTreeSet = manifest .models .iter() .flat_map(|model| [model.id.clone(), model.typename.clone()]) .collect(); - manifest - .roots - .retain(|root| kept.contains(&root.model)); + manifest.roots.retain(|root| kept.contains(&root.model)); for projector in &mut manifest.projectors { projector.models.retain(|model| kept.contains(model)); } @@ -194,19 +188,22 @@ pub fn prune_client_manifest( .retain(|projector| !projector.models.is_empty()); for program in &mut manifest.projection_programs { for arm in &mut program.arms { - arm.operations.retain(|operation| kept.contains(&operation.model)); + arm.operations + .retain(|operation| kept.contains(&operation.model)); for operation in &mut arm.operations { operation.relationships.retain(|rel| { kept.contains(&rel.source_model) && kept.contains(&rel.target_model) }); - operation.invalidations.retain(|invalidation| match invalidation { - ClientProjectionInvalidation::Model { model } => kept.contains(model), - ClientProjectionInvalidation::Relationship { - source_model, - target_model, - .. - } => kept.contains(source_model) && kept.contains(target_model), - }); + operation + .invalidations + .retain(|invalidation| match invalidation { + ClientProjectionInvalidation::Model { model } => kept.contains(model), + ClientProjectionInvalidation::Relationship { + source_model, + target_model, + .. + } => kept.contains(source_model) && kept.contains(target_model), + }); } } program.arms.retain(|arm| !arm.operations.is_empty()); diff --git a/src/graphql/client_manifest/identity.rs b/src/graphql/client_manifest/identity.rs index eb059670c..e5cc72f8e 100644 --- a/src/graphql/client_manifest/identity.rs +++ b/src/graphql/client_manifest/identity.rs @@ -3,7 +3,9 @@ use super::*; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ClientSurfaceIdentity { - Role { name: String }, + Role { + name: String, + }, /// `eligible_roles` is the canonical wire identity for principals who may /// open the application surface. `schema_roles` is the distinct role set /// used to derive the shared schema/command contract. diff --git a/src/graphql/client_manifest/mod.rs b/src/graphql/client_manifest/mod.rs index 22c7e2d9b..9a5f8db20 100644 --- a/src/graphql/client_manifest/mod.rs +++ b/src/graphql/client_manifest/mod.rs @@ -27,7 +27,6 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use super::command_contract::{CommandConsistency, EffectExpression}; use super::complexity_contract::{default_weights, DEFAULT_MAX_COMPLEXITY, DEFAULT_MAX_DEPTH}; use super::filter::{FilterExpr, Operand}; use super::naming::{aggregate_fields_type_name, aggregate_type_name}; @@ -36,6 +35,7 @@ use super::surface::{ SurfaceCommand, SurfaceCommandShape, SurfaceRelationshipKeys, SurfaceRowPolicy, SurfaceSelection, SurfaceTypeDef, }; +use crate::command::{CommandConsistency, EffectExpression}; use crate::table::RelationshipKind; use build::client_manifest_from_surface_with_execution; @@ -57,9 +57,10 @@ pub use identity::ClientSurfaceIdentity; pub use limits::{ClientComplexityWeights, ClientExecutionLimits}; pub use types::{ ClientAggregateSemantics, ClientArgument, ClientArgumentKind, ClientCapabilities, - ClientCommand, ClientCommandExtensionSlots, ClientCommandShape, ClientField, ClientFilterField, - ClientFilterInput, ClientFilterInputRelationship, ClientFilterSemantics, ClientKeyField, - ClientModel, ClientOrderSemantics, ClientPaginationSemantics, ClientProjectionArm, + ClientCommand, ClientCommandExtensionSlots, ClientCommandPureArg, ClientCommandPureReduce, + ClientCommandShape, ClientField, ClientFilterField, ClientFilterInput, + ClientFilterInputRelationship, ClientFilterSemantics, ClientKeyField, ClientModel, + ClientOrderSemantics, ClientPaginationSemantics, ClientProjectionArm, ClientProjectionAssignment, ClientProjectionBinding, ClientProjectionBindingState, ClientProjectionEnvelopeField, ClientProjectionEventRef, ClientProjectionExecutionClass, ClientProjectionExpression, ClientProjectionFallback, ClientProjectionField, @@ -74,9 +75,8 @@ pub use types::{ ClientRootKind, ClientRootOperation, ClientRowPolicy, ClientTrustedPresetDescriptor, ClientTypeDef, ClientTypeField, CommandConfirmationsExtension, CommandConsistencyExtension, CommandDirectProjectionExtension, CommandEffectsExtension, CommandInputDefaultsExtension, - ClientCommandPureArg, ClientCommandPureReduce, CommandProjectionArmRef, - CommandProjectionExtension, CommandProjectionPreviewOccurrence, CommandProjectionPreviewValue, - DistributedClientManifest, ModelNormalization, + CommandProjectionArmRef, CommandProjectionExtension, CommandProjectionPreviewOccurrence, + CommandProjectionPreviewValue, DistributedClientManifest, ModelNormalization, RelationshipKeyMapping, ScalarCodec, }; pub(crate) use validation::trusted_preset_descriptors; diff --git a/src/graphql/client_manifest/projections.rs b/src/graphql/client_manifest/projections.rs index 4ef863fa0..bc79d5025 100644 --- a/src/graphql/client_manifest/projections.rs +++ b/src/graphql/client_manifest/projections.rs @@ -4,7 +4,7 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use super::*; -use crate::graphql::command_contract::CommandProjectionPreviewSource; +use crate::command::CommandProjectionPreviewSource; use crate::graphql::surface::{ SurfaceProjectionArm, SurfaceProjectionOperation, SurfaceSelectedProjectionProgram, }; @@ -301,8 +301,8 @@ pub(super) fn command_projection_extension( .pure_reduces .iter() .map(|reduce| { + use crate::command::CommandProjectionPreviewSource as ServerSource; use crate::graphql::client_manifest::ClientProjectionPreviewSource as ClientSource; - use crate::graphql::command_contract::CommandProjectionPreviewSource as ServerSource; let map_source = |source: &ServerSource| -> Result { Ok(match source { ServerSource::InputPath { path } => ClientSource::Input { path: path.clone() }, @@ -1341,9 +1341,9 @@ fn mutation_kind(kind: ProjectionMutationKind) -> ClientProjectionMutationKind { #[cfg(test)] mod tests { use super::*; + use crate::command::{CommandProjectionPreview, CommandProjectionPreviewSource}; use crate::graphql::{ - build_surface, CommandProjectionPreview, CommandProjectionPreviewSource, SurfaceOptions, - SurfaceProjector, SurfaceTypeDef, SurfaceTypeField, + build_surface, SurfaceOptions, SurfaceProjector, SurfaceTypeDef, SurfaceTypeField, }; use crate::projection::lower::ProjectionPortableType; use crate::projection::placement::{ @@ -1656,7 +1656,8 @@ mod tests { #[test] fn auto_optimism_maps_input_defaults_and_row_policy_claims() { - use crate::graphql::command_contract::{CommandInputDefault, InputDefaultGenerator}; + use crate::command::{CommandInputDefault, InputDefaultGenerator}; + use crate::graphql::{claim, col}; let selector = typed_selector::(); @@ -1842,7 +1843,7 @@ mod tests { command.consistency = consistency; command.projections.add_event_set(crate::events![PreviewA]); command.projections.add_inferred_values( - crate::graphql::__command_projection_event_preview::(vec![( + crate::command::__command_projection_event_preview::(vec![( "value", CommandProjectionPreviewSource::constant(ProjectionValue::string( "transition-value", diff --git a/src/graphql/client_manifest/tests.rs b/src/graphql/client_manifest/tests.rs index 81ed853e1..08a3efe6b 100644 --- a/src/graphql/client_manifest/tests.rs +++ b/src/graphql/client_manifest/tests.rs @@ -1,9 +1,9 @@ use super::*; +use crate::command::{typed_command, Eventual, PreparedCommand, Succeeded}; +use crate::command::{CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField}; use crate::graphql::{ - build_surface, claim, col, rel, surface_for_application, surface_for_role, typed_command, - Eventual, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, - PreparedCommand, RoleGrant, Succeeded, SurfaceCommand, SurfaceOptions, SurfaceProjector, - SurfaceTypeField, + build_surface, claim, col, rel, surface_for_application, surface_for_role, RoleGrant, + SurfaceCommand, SurfaceOptions, SurfaceProjector, SurfaceTypeField, }; use crate::microsvc::{CausalCommandContext, HandlerError, Routes, Service}; use crate::table::{ @@ -97,6 +97,7 @@ fn todos() -> TableSchema { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "owner".into(), kind: RelationshipKind::BelongsTo, target_model: "UserView".into(), @@ -148,6 +149,7 @@ fn teams() -> TableSchema { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "members".into(), kind: RelationshipKind::ManyToMany, target_model: "UserView".into(), @@ -184,11 +186,11 @@ fn team_members() -> TableSchema { #[derive(Deserialize)] struct CompleteInput; -impl GraphqlInputType for CompleteInput { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandInputType for CompleteInput { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "CompleteTodoInput", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "todo_id".into(), type_name: "String".into(), nullable: false, @@ -203,11 +205,11 @@ impl GraphqlInputType for CompleteInput { #[derive(Serialize)] struct CompletePayload; -impl GraphqlOutputType for CompletePayload { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandOutputType for CompletePayload { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "CompleteTodoPayload", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "todo_id".into(), type_name: "String".into(), nullable: false, @@ -479,7 +481,7 @@ fn modeled_surface_with_base( } fn projected_surface() -> Surface { - use super::super::command_contract::{CommandEffects, CommandProjectedModel, EffectExpression}; + use crate::command::{CommandEffects, CommandProjectedModel, EffectExpression}; let todo_schema: &'static TableSchema = Box::leak(Box::new(todos())); let mut surface = build_surface(&[todo_schema.clone(), users()], &SurfaceOptions::sqlite()) @@ -601,7 +603,7 @@ fn projected_command_exports_opaque_role_safe_direct_target() { #[test] fn legacy_effect_presets_are_not_v2_client_authority() { - use super::super::command_contract::{ + use crate::command::{ CommandEffect, CommandEffects, EffectExpression, EffectFieldValue, EffectKey, }; @@ -897,18 +899,25 @@ fn role_and_application_partition_manifests_hide_raw_paths_and_denied_values() { .unwrap() .projections .add_preview( - crate::graphql::CommandProjectionPreview::new() + crate::command::CommandProjectionPreview::new() .events(crate::events![ManifestTodoProjected]) .field( ["private_partition_path"], - crate::graphql::CommandProjectionPreviewSource::constant( + crate::command::CommandProjectionPreviewSource::constant( crate::ProjectionValue::string("denied-partition-value"), ), ), ); let all_grants = grants(); let role = surface_for_role(&full, "user", &all_grants["user"]).unwrap(); - let application = surface_for_application(&full, "web", &["user".into()], &["user".into()], &all_grants).unwrap(); + let application = surface_for_application( + &full, + "web", + &["user".into()], + &["user".into()], + &all_grants, + ) + .unwrap(); for (identity, selected) in [ (ClientSurfaceIdentity::role("user"), role), @@ -1406,7 +1415,7 @@ fn denied_modeled_projection_exports_no_program_event_slot_or_preset_identity() .find(|command| command.command_name == "todo.complete") .unwrap(); command.projections.previews[0].preview.fields[0].source = - crate::graphql::CommandProjectionPreviewSource::trusted("denied-owner-secret", "string"); + crate::command::CommandProjectionPreviewSource::trusted("denied-owner-secret", "string"); let selected = surface_for_role( &full, "user", @@ -1775,6 +1784,7 @@ fn bigint_keys_embed_until_decimal_string_identity_is_available() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "account".into(), kind: RelationshipKind::BelongsTo, target_model: "AccountView".into(), @@ -1832,15 +1842,14 @@ fn bigint_keys_embed_until_decimal_string_identity_is_available() { fn application_surface_is_common_contract_with_safe_role_limit_semantics() { let full = full_surface(); let all_grants = grants(); - let selected = - surface_for_application( - &full, - "web", - &["user".into(), "admin".into()], - &["user".into(), "admin".into()], - &all_grants, - ) - .unwrap(); + let selected = surface_for_application( + &full, + "web", + &["user".into(), "admin".into()], + &["user".into(), "admin".into()], + &all_grants, + ) + .unwrap(); let manifest = client_manifest_from_surface( "todos-service", ClientSurfaceIdentity::application("web", ["admin", "user"], ["admin", "user"]), @@ -2315,15 +2324,14 @@ fn relational_row_policy_is_server_only_when_relationship_key_is_hidden() { #[test] fn application_role_sets_are_canonical_before_fingerprinting() { let full = full_surface(); - let selected = - surface_for_application( - &full, - "web", - &["admin".into(), "user".into()], - &["admin".into(), "user".into()], - &grants(), - ) - .unwrap(); + let selected = surface_for_application( + &full, + "web", + &["admin".into(), "user".into()], + &["admin".into(), "user".into()], + &grants(), + ) + .unwrap(); let first = client_manifest_from_surface( "todos-service", ClientSurfaceIdentity::Application { diff --git a/src/graphql/commands.rs b/src/graphql/commands.rs index bdff37aed..18d2ab970 100644 --- a/src/graphql/commands.rs +++ b/src/graphql/commands.rs @@ -8,23 +8,23 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; -#[cfg(feature = "graphql")] -use super::command_contract::CommandConsistency; -use super::command_contract::TypedCommandContract; #[cfg(feature = "graphql")] use super::surface::{ compile_projection_owner_topology, validate_direct_modeled_owner_compatibility, SurfaceProjectionOwner, }; use super::surface::{SurfaceCommand, SurfaceCommandShape, SurfaceTypeDef, SurfaceTypeField}; -use super::types::GraphqlTypeDef; +#[cfg(feature = "graphql")] +use crate::command::CommandConsistency; +use crate::command::CommandTypeDef; +use crate::command::TypedCommandContract; #[derive(Clone, Debug, Default)] pub(crate) struct TypedCommandInventory { contracts: Vec, } -fn surface_type(definition: &GraphqlTypeDef) -> SurfaceTypeDef { +fn surface_type(definition: &CommandTypeDef) -> SurfaceTypeDef { SurfaceTypeDef { name: definition.name.clone(), fields: definition diff --git a/src/graphql/complexity.rs b/src/graphql/complexity.rs index 89ede855a..6e9ccbbd3 100644 --- a/src/graphql/complexity.rs +++ b/src/graphql/complexity.rs @@ -146,6 +146,7 @@ mod tests { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "Child".into(), @@ -175,6 +176,7 @@ mod tests { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "grandchildren".into(), kind: RelationshipKind::HasMany, target_model: "Grandchild".into(), diff --git a/src/graphql/delivery/mod.rs b/src/graphql/delivery/mod.rs new file mode 100644 index 000000000..56478823c --- /dev/null +++ b/src/graphql/delivery/mod.rs @@ -0,0 +1,39 @@ +//! Origin-side delivery validation. SQL dependency versions are private; +//! gateway responses carry only scope-bound opaque validators. +mod versions; +pub(crate) use versions::*; +pub use versions::{GatewayOriginMetrics, GatewayVersionStore}; + +use crate::gateway::delivery::{OperationKey, OriginIdentity}; +use crate::graphql::protocol::{ProtocolTokenCodec, ProtocolTokenPurpose}; + +#[derive(Clone, Debug)] +pub(crate) struct GatewayCapture { + pub(crate) identity: OriginIdentity, + pub(crate) key: OperationKey, + pub(crate) validator: Option, +} + +pub(crate) fn validator( + codec: &ProtocolTokenCodec, + identity: &OriginIdentity, + key: &OperationKey, + versions: &VersionVector, +) -> Result { + codec + .issue( + ProtocolTokenPurpose::QuerySnapshot, + &serde_json::json!({ + "domain":"distributed.gateway.snapshot-validator", "version":1, + "identity":identity, "operation":key, "versions":versions + }), + ) + .map(|token| token.as_str().to_owned()) + .map_err(|_| "validator encoding failed".into()) +} + +#[derive(Clone, Default)] +pub(crate) struct PlanCapture( + pub(crate) std::sync::Arc>>, +); +pub(crate) const CAPTURED: &str = "gateway query plan captured"; diff --git a/src/graphql/delivery/versions.rs b/src/graphql/delivery/versions.rs new file mode 100644 index 000000000..e4b4795ac --- /dev/null +++ b/src/graphql/delivery/versions.rs @@ -0,0 +1,536 @@ +use crate::graphql::engine::GraphqlPool; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, +}; + +const MAX_TABLES: usize = 128; +// The cached envelope includes projection evidence as well as model data. +// Version all persisted evidence dependencies, including no-op/empty commits. +const PROOF_TABLES: &[&str] = &[ + "projection_partitions", + "projection_generations", + "projection_source_capabilities", + "projection_input_identities", + "projection_input_cursors", + "projection_input_receipts", + "projection_table_ownership_fences", + "projection_causal_tables", + "projection_registered_models", + "projection_model_ownership", + "projection_records", + "projection_observations", + "projection_failures", + "projection_changes", +]; + +/// Installed transactional dependency coverage for one origin database. Create +/// after application migrations, before enabling gateway caching. Every covered +/// table receives write triggers; unsupported/uncovered tables remain ineligible. +#[derive(Clone)] +pub struct GatewayVersionStore { + namespace: Arc, + tables: Arc>, + counters: Arc, + proof_tables: Arc>, + public_policies: BTreeMap<(String, Option), u64>, +} +#[derive(Default)] +struct Counters { + validations: AtomicU64, + result_executions: AtomicU64, +} +/// Origin work counters; result executions exclude dependency validation SQL. +#[derive(Clone, Copy, Debug)] +pub struct GatewayOriginMetrics { + /// Authenticated validation requests reaching the configured origin store. + pub validations: u64, + /// Actual compiled result SQL executions using the protocol snapshot path. + pub result_executions: u64, +} +#[derive(Clone, Debug, Serialize)] +pub(crate) struct DependencyVersion { + epoch: String, + version: String, +} +pub(crate) type VersionVector = BTreeMap; + +impl GatewayVersionStore { + /// Explicitly permit bounded content age for one exact ordinary operation + /// (all its variable values). Subject isolation and fresh origin admission + /// still apply. Omit this declaration for current/private reads. + pub fn public_snapshot( + mut self, + document: impl Into, + operation: Option, + max_age_seconds: u64, + ) -> Result { + let document = document.into(); + if self.public_policies.len() >= 512 + || !(1..=86400).contains(&max_age_seconds) + || crate::gateway::graphql::operation_kind(&document, operation.as_deref()) + != Ok(crate::gateway::graphql::OperationKind::Query) + { + return Err("invalid public snapshot policy".into()); + } + self.public_policies + .insert((document, operation), max_age_seconds); + Ok(self) + } + pub(crate) fn policy( + &self, + document: &str, + operation: Option<&str>, + ) -> crate::gateway::delivery::SnapshotPolicy { + self.public_policies + .get(&(document.to_owned(), operation.map(str::to_owned))) + .map_or( + crate::gateway::delivery::SnapshotPolicy::Current, + |seconds| crate::gateway::delivery::SnapshotPolicy::Public { + max_age_seconds: *seconds, + }, + ) + } + /// Snapshot origin work counters without resetting concurrent observations. + pub fn metrics(&self) -> GatewayOriginMetrics { + GatewayOriginMetrics { + validations: self.counters.validations.load(Ordering::Relaxed), + result_executions: self.counters.result_executions.load(Ordering::Relaxed), + } + } + pub(crate) fn record_validation(&self) { + self.counters.validations.fetch_add(1, Ordering::Relaxed); + } + pub(crate) fn record_result(&self) { + self.counters + .result_executions + .fetch_add(1, Ordering::Relaxed); + } + + /// Install additive version metadata and triggers in one transaction. Data + /// migration/cleanup is never implicit. Namespace is a configured app ID. + pub async fn install( + pool: &GraphqlPool, + namespace: &str, + tables: impl IntoIterator, + ) -> Result { + let tables: BTreeSet<_> = tables.into_iter().collect(); + if namespace.is_empty() + || namespace.len() > 128 + || tables.is_empty() + || tables.len() > MAX_TABLES + || tables + .iter() + .any(|t| t.is_empty() || t.len() > 128 || t.chars().any(char::is_control)) + { + return Err("invalid dependency version inventory".into()); + } + let epoch = uuid::Uuid::now_v7().to_string(); + let mut store = Self { + namespace: namespace.into(), + tables: Arc::new(tables), + counters: Arc::default(), + proof_tables: Arc::default(), + public_policies: BTreeMap::new(), + }; + match pool { + #[cfg(feature = "sqlite")] + GraphqlPool::Sqlite(pool) => { + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + let existing: Vec = + sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table'") + .fetch_all(&mut *tx) + .await + .map_err(|e| e.to_string())?; + store.proof_tables = Arc::new( + existing + .into_iter() + .filter(|table| PROOF_TABLES.contains(&table.as_str())) + .collect(), + ); + for sql in store.install_sql(&epoch, false) { + sqlx::query(sqlx::AssertSqlSafe(sql)) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + } + tx.commit().await.map_err(|e| e.to_string())?; + } + #[cfg(feature = "postgres")] + GraphqlPool::Postgres(pool) => { + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + super::super::execute::ensure_primary_backend(&mut tx, true).await?; + let existing: Vec = sqlx::query_scalar( + "SELECT tablename::text FROM pg_tables WHERE schemaname=current_schema()", + ) + .fetch_all(&mut *tx) + .await + .map_err(|e| e.to_string())?; + store.proof_tables = Arc::new( + existing + .into_iter() + .filter(|table| PROOF_TABLES.contains(&table.as_str())) + .collect(), + ); + for sql in store.install_sql(&epoch, true) { + sqlx::query(sqlx::AssertSqlSafe(sql)) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + } + tx.commit().await.map_err(|e| e.to_string())?; + } + #[allow(unreachable_patterns)] + _ => return Err("dependency versions require a SQL adapter".into()), + } + Ok(store) + } + fn install_sql(&self, epoch: &str, postgres: bool) -> Vec { + let mut sql = + vec![ + include_str!("../../../migrations/postgres/0006_gateway_dependency_versions.sql") + .to_owned(), + ]; + for table in self.tables.union(&self.proof_tables) { + let ns = literal(&self.namespace); + let table_literal = literal(table); + let name = self.trigger_name(table); + sql.push(format!("INSERT INTO distributed_gateway_versions(namespace, table_name, epoch, version) VALUES ({ns},{table_literal},{},0) ON CONFLICT(namespace,table_name) DO UPDATE SET epoch=excluded.epoch,version=0",literal(epoch))); + let update = format!("UPDATE distributed_gateway_versions SET version=version+1 WHERE namespace={ns} AND table_name={table_literal};"); + if postgres { + sql.push(format!( + "CREATE OR REPLACE FUNCTION {}() RETURNS trigger LANGUAGE plpgsql AS {}", + ident(&name), + literal(&format!("BEGIN {update} RETURN NULL; END")) + )); + sql.push(format!( + "DROP TRIGGER IF EXISTS {} ON {}", + ident(&name), + ident(table) + )); + sql.push(format!("CREATE TRIGGER {} AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON {} FOR EACH STATEMENT EXECUTE FUNCTION {}()",ident(&name),ident(table),ident(&name))); + } else { + for event in ["INSERT", "UPDATE", "DELETE"] { + sql.push(format!( + "CREATE TRIGGER IF NOT EXISTS {} AFTER {event} ON {} BEGIN {update} END", + ident(&format!("{name}_{event}")), + ident(table) + )); + } + } + } + sql + } + fn trigger_name(&self, table: &str) -> String { + let digest = + Sha256::digest(format!("gateway-version-v1:{}:{table}", self.namespace).as_bytes()); + format!("dg_v_{digest:x}")[..55].into() + } + pub(crate) fn envelope_coverage(&self) -> bool { + self.proof_tables.len() == PROOF_TABLES.len() + } + pub(crate) fn covers(&self, tables: &[String]) -> bool { + !tables.is_empty() + && tables.len() <= MAX_TABLES + && tables.iter().all(|t| self.tables.contains(t)) + } + /// Read current validators on the authoritative primary only. Result fills + /// use the connection methods inside their data snapshot instead. + pub(crate) async fn current( + &self, + pool: &GraphqlPool, + tables: &[String], + ) -> Result { + match pool { + #[cfg(feature = "sqlite")] + GraphqlPool::Sqlite(pool) => { + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + let result = self.sqlite(&mut tx, tables).await?; + tx.commit().await.map_err(|e| e.to_string())?; + Ok(result) + } + #[cfg(feature = "postgres")] + GraphqlPool::Postgres(pool) => { + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + super::super::execute::ensure_primary_backend(&mut tx, true).await?; + let result = self.postgres(&mut tx, tables).await?; + tx.commit().await.map_err(|e| e.to_string())?; + Ok(result) + } + #[allow(unreachable_patterns)] + _ => Err("dependency versions require a SQL adapter".into()), + } + } + /// Activate a new explicit epoch after a rebuild or writer-coverage change. + /// Call in a controlled migration window; old validators become unusable. + pub async fn rotate_epoch(&self, pool: &GraphqlPool) -> Result<(), String> { + let epoch = uuid::Uuid::now_v7().to_string(); + let sql = format!( + "UPDATE distributed_gateway_versions SET epoch={},version=0 WHERE namespace={}", + literal(&epoch), + literal(&self.namespace) + ); + match pool { + #[cfg(feature = "sqlite")] + GraphqlPool::Sqlite(pool) => { + sqlx::query(sqlx::AssertSqlSafe(sql)) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + } + #[cfg(feature = "postgres")] + GraphqlPool::Postgres(pool) => { + sqlx::query(sqlx::AssertSqlSafe(sql)) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + } + #[allow(unreachable_patterns)] + _ => return Err("dependency versions require a SQL adapter".into()), + } + Ok(()) + } +} + +// SQLx database implementations keep trigger coverage checks and version reads +// in the caller's actual serving snapshot. Missing/disabled hooks fail closed. +macro_rules! version_reader { + ($method:ident, $connection:ty, $coverage:expr) => { + impl GatewayVersionStore { + pub(crate) async fn $method(&self, connection: &mut $connection, tables: &[String]) -> Result { + if !self.covers(tables) { return Err("query dependency coverage is incomplete".into()); } + let selected: BTreeSet<_> = tables.iter().chain(self.proof_tables.iter()).collect(); + let queries = selected.iter().map(|table| { + let coverage_sql: String = ($coverage)(self, table); + format!("SELECT table_name, epoch, version, ({coverage_sql}) AS covered FROM distributed_gateway_versions WHERE namespace={} AND table_name={}",literal(&self.namespace),literal(table)) + }).collect::>().join(" UNION ALL "); + let rows: Vec<(String,String,i64,i64)> = sqlx::query_as(sqlx::AssertSqlSafe(queries)).fetch_all(&mut *connection).await.map_err(|e| e.to_string())?; + if rows.len() != selected.len() { return Err("dependency version coverage is unavailable".into()); } + let mut result = BTreeMap::new(); + for (table, epoch, version, covered) in rows { + if covered != 1 || version < 0 { return Err("dependency invalidation coverage is unavailable".into()); } + result.insert(table, DependencyVersion { epoch, version: version.to_string() }); + } + Ok(result) + } + } + } +} +#[cfg(feature = "sqlite")] +version_reader!( + sqlite, + sqlx::SqliteConnection, + |store: &GatewayVersionStore, table: &str| { + let name = store.trigger_name(table); + format!("SELECT CASE WHEN COUNT(*)=3 THEN 1 ELSE 0 END FROM sqlite_master WHERE type='trigger' AND tbl_name={} AND name IN ({},{},{})",literal(table),literal(&format!("{name}_INSERT")),literal(&format!("{name}_UPDATE")),literal(&format!("{name}_DELETE"))) + } +); +#[cfg(feature = "postgres")] +version_reader!( + postgres, + sqlx::PgConnection, + |store: &GatewayVersionStore, table: &str| { + format!("SELECT COUNT(*)::bigint FROM pg_trigger WHERE tgrelid={}::regclass AND tgname={} AND tgenabled IN ('O','A')",literal(&ident(table)),literal(&store.trigger_name(table))) + } +); +fn literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} +fn ident(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +#[cfg(test)] +mod tests { + use super::*; + // The same behavioral matrix runs against both actual SQL adapters. It + // includes a concurrent data/version snapshot and supported external SQL. + macro_rules! exercise { + ($pool:ident, $adapter:ident, $method:ident, $begin:expr) => {{ + sqlx::query( + "CREATE TABLE gateway_version_test (id INTEGER PRIMARY KEY, title TEXT NOT NULL)", + ) + .execute(&$pool) + .await + .unwrap(); + let adapter = GraphqlPool::$adapter($pool.clone()); + let tables = vec!["gateway_version_test".to_owned()]; + let store = GatewayVersionStore::install(&adapter, "version-test", tables.clone()) + .await + .unwrap(); + let vector = |v: VersionVector| serde_json::to_value(v).unwrap(); + let empty = vector(store.current(&adapter, &tables).await.unwrap()); + sqlx::query("INSERT INTO gateway_version_test VALUES (1,'first')") + .execute(&$pool) + .await + .unwrap(); + let inserted = vector(store.current(&adapter, &tables).await.unwrap()); + assert_ne!( + inserted, empty, + "insert into an empty result invalidates membership" + ); + let mut failed = $pool.begin().await.unwrap(); + sqlx::query("UPDATE gateway_version_test SET title='rollback'") + .execute(&mut *failed) + .await + .unwrap(); + failed.rollback().await.unwrap(); + assert_eq!( + vector(store.current(&adapter, &tables).await.unwrap()), + inserted + ); + let mut serving = $pool.begin().await.unwrap(); + if let Some(begin) = $begin { + sqlx::query(sqlx::AssertSqlSafe(begin)) + .execute(&mut *serving) + .await + .unwrap(); + } + let data: String = + sqlx::query_scalar("SELECT title FROM gateway_version_test WHERE id=1") + .fetch_one(&mut *serving) + .await + .unwrap(); + assert_eq!(data, "first"); + sqlx::query("UPDATE gateway_version_test SET title='newer'") + .execute(&$pool) + .await + .unwrap(); + assert_eq!( + vector(store.$method(&mut serving, &tables).await.unwrap()), + inserted, + "an old result must carry its own old validator, even after concurrent commit" + ); + serving.commit().await.unwrap(); + let updated = vector(store.current(&adapter, &tables).await.unwrap()); + assert_ne!(updated, inserted); + sqlx::query("DELETE FROM gateway_version_test WHERE id=1") + .execute(&$pool) + .await + .unwrap(); + assert_ne!( + vector(store.current(&adapter, &tables).await.unwrap()), + updated + ); + assert!(store + .current(&adapter, &["unknown_dependency".into()]) + .await + .is_err()); + (adapter, store, tables) + }}; + } + #[cfg(feature = "sqlite")] + #[tokio::test] + async fn sqlite_transactional_coverage_and_snapshot_race() { + use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; + let directory = + std::env::temp_dir().join(format!("gateway-cache-{}", uuid::Uuid::now_v7())); + std::fs::create_dir(&directory).unwrap(); + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(directory.clone()); + let pool = SqlitePoolOptions::new() + .max_connections(3) + .connect_with( + SqliteConnectOptions::new() + .filename(directory.join("cache.sqlite")) + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal), + ) + .await + .unwrap(); + let (adapter, store, tables) = exercise!(pool, Sqlite, sqlite, None::); + let name = store.trigger_name(&tables[0]); + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP TRIGGER {}", + ident(&format!("{name}_UPDATE")) + ))) + .execute(&pool) + .await + .unwrap(); + assert!( + store.current(&adapter, &tables).await.is_err(), + "missing hook disables validation" + ); + sqlx::query("INSERT INTO gateway_version_test VALUES (2,'during missing coverage')") + .execute(&pool) + .await + .unwrap(); + let restored = GatewayVersionStore::install(&adapter, "version-test", tables.clone()) + .await + .unwrap(); + let before = + serde_json::to_value(restored.current(&adapter, &tables).await.unwrap()).unwrap(); + restored.rotate_epoch(&adapter).await.unwrap(); + assert_ne!( + serde_json::to_value(restored.current(&adapter, &tables).await.unwrap()).unwrap(), + before + ); + } + #[cfg(feature = "postgres")] + #[tokio::test] + #[ignore = "requires owned gateway-postgres primary; run tests/gateway-postgres/run.py"] + async fn postgres_transactional_coverage_and_snapshot_race() { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(3) + .connect( + &std::env::var("GATEWAY_TEST_PRIMARY_URL").expect("run gateway-postgres fixture"), + ) + .await + .unwrap(); + let (adapter, store, tables) = exercise!( + pool, + Postgres, + postgres, + Some("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY".to_owned()) + ); + let name = store.trigger_name(&tables[0]); + let before = serde_json::to_value(store.current(&adapter, &tables).await.unwrap()).unwrap(); + sqlx::query("TRUNCATE gateway_version_test") + .execute(&pool) + .await + .unwrap(); + assert_ne!( + serde_json::to_value(store.current(&adapter, &tables).await.unwrap()).unwrap(), + before + ); + sqlx::query(sqlx::AssertSqlSafe(format!( + "ALTER TABLE gateway_version_test DISABLE TRIGGER {}", + ident(&name) + ))) + .execute(&pool) + .await + .unwrap(); + assert!( + store.current(&adapter, &tables).await.is_err(), + "disabled hooks cannot certify current data" + ); + let restored = GatewayVersionStore::install(&adapter, "version-test", tables.clone()) + .await + .unwrap(); + assert_ne!( + serde_json::to_value(restored.current(&adapter, &tables).await.unwrap()).unwrap(), + before + ); + let before = + serde_json::to_value(restored.current(&adapter, &tables).await.unwrap()).unwrap(); + restored.rotate_epoch(&adapter).await.unwrap(); + assert_ne!( + serde_json::to_value(restored.current(&adapter, &tables).await.unwrap()).unwrap(), + before + ); + } +} diff --git a/src/graphql/engine/builder.rs b/src/graphql/engine/builder.rs index 74e2a76b7..4ccc46b86 100644 --- a/src/graphql/engine/builder.rs +++ b/src/graphql/engine/builder.rs @@ -10,6 +10,10 @@ impl GraphqlEngineBuilder { command_binding: None, causal_storage_identity: source.causal_storage_identity, pool: source.pool, + #[cfg(feature = "gateway-delivery")] + read_routing: None, + #[cfg(feature = "gateway-delivery")] + gateway_versions: None, catalog: BTreeMap::new(), by_table: BTreeMap::new(), permissions: BTreeMap::new(), @@ -27,6 +31,7 @@ impl GraphqlEngineBuilder { introspection_for_anonymous: true, statement_timeout: Duration::from_secs(5), graphiql: false, + subscriptions: true, typed_commands: TypedCommandInventory::empty(), projectors: Vec::new(), change_rx: None, @@ -37,6 +42,36 @@ impl GraphqlEngineBuilder { } } + /// Include live roots in the runtime schema and generated client surface. + /// Query-only gateway executors disable this before schema construction. + pub fn subscriptions(mut self, enabled: bool) -> Self { + self.subscriptions = enabled; + self + } + + /// Opt in selected exact query documents to a read replica. The engine's + /// original repository remains the primary for current reads and evidence. + #[cfg(feature = "gateway-delivery")] + pub fn read_routing(mut self, routing: ReadRouting) -> Self { + if std::mem::discriminant(&self.pool) != std::mem::discriminant(&routing.replica) { + self.pending_errors + .push("read replica must use the primary SQL dialect".into()); + } + self.read_routing = Some(routing); + self + } + + /// Enable origin dependency validation after installing version coverage + /// in the engine's database. This allocates no gateway cache or coordinator. + #[cfg(feature = "gateway-delivery")] + pub fn gateway_versions( + mut self, + store: crate::graphql::delivery::GatewayVersionStore, + ) -> Self { + self.gateway_versions = Some(store); + self + } + pub fn model(mut self, perms: ModelPermissions) -> Self { let schema = M::schema().clone(); if let Err(e) = self.insert_catalog(schema.clone(), true) { @@ -725,7 +760,7 @@ impl GraphqlEngineBuilder { SqlDialect::Postgres => SurfaceDialect::Postgres, }, aggregates: true, - subscriptions: true, + subscriptions: self.subscriptions, default_limit: self.default_limit, max_limit: self.max_limit, }; @@ -1003,6 +1038,10 @@ impl GraphqlEngineBuilder { command_binding: self.command_binding, causal_storage_identity: self.causal_storage_identity, pool: self.pool, + #[cfg(feature = "gateway-delivery")] + read_routing: self.read_routing, + #[cfg(feature = "gateway-delivery")] + gateway_versions: self.gateway_versions, catalog: self.catalog, by_table: self.by_table, permissions: self.permissions, diff --git a/src/graphql/engine/composite_relationship_tests.rs b/src/graphql/engine/composite_relationship_tests.rs index 71588fac3..fedd878d7 100644 --- a/src/graphql/engine/composite_relationship_tests.rs +++ b/src/graphql/engine/composite_relationship_tests.rs @@ -9,7 +9,7 @@ use crate::table::{ TableKind, TableSchema, }; -#[cfg(feature = "sqlite")] +#[cfg(any(feature = "sqlite", feature = "postgres"))] fn composite_records() -> TableSchema { TableSchema { model_name: "CompositeRecord".into(), @@ -34,7 +34,7 @@ fn composite_records() -> TableSchema { } } -#[cfg(feature = "sqlite")] +#[cfg(any(feature = "sqlite", feature = "postgres"))] fn simple_records() -> TableSchema { TableSchema { model_name: "SimpleRecord".into(), @@ -69,6 +69,7 @@ fn workspaces() -> TableSchema { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "projects".into(), kind: RelationshipKind::HasMany, target_model: "ProjectView".into(), @@ -158,6 +159,7 @@ fn project_labels() -> TableSchema { fn projects_with_labels() -> TableSchema { let mut schema = projects(); schema.relationships.push(RelationshipDef { + references: None, field_name: "labels".into(), kind: RelationshipKind::ManyToMany, target_model: "LabelView".into(), @@ -175,6 +177,7 @@ fn simple_records_with_composite_fk() -> TableSchema { .columns .push(TableColumn::new("record_id", "record_id", ColumnType::Text)); schema.relationships.push(RelationshipDef { + references: None, field_name: "composite".into(), kind: RelationshipKind::BelongsTo, target_model: "CompositeRecord".into(), @@ -189,6 +192,7 @@ fn simple_records_with_composite_fk() -> TableSchema { fn projects_with_files() -> TableSchema { let mut schema = projects(); schema.relationships.push(RelationshipDef { + references: None, field_name: "files".into(), kind: RelationshipKind::HasMany, target_model: "ProjectFileView".into(), @@ -233,6 +237,7 @@ async fn belongs_to_with_a_partial_composite_foreign_key_is_rejected() { let composite = composite_records(); let mut simple = simple_records(); simple.relationships.push(RelationshipDef { + references: None, field_name: "composite".into(), kind: RelationshipKind::BelongsTo, target_model: "CompositeRecord".into(), @@ -260,6 +265,252 @@ async fn belongs_to_with_a_partial_composite_foreign_key_is_rejected() { ); } +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn unique_key_join_preserves_namespace_nulls_and_surrogate_identity() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + unique_key_join_fixture(pool.into()).await; +} + +#[cfg(feature = "postgres")] +#[tokio::test] +#[ignore = "requires dedicated DISTRIBUTED_UNIQUE_KEY_TEST_POSTGRES_URL; run explicitly in PostgreSQL CI"] +async fn unique_key_join_postgres_authorization_and_manifest() { + let url = std::env::var("DISTRIBUTED_UNIQUE_KEY_TEST_POSTGRES_URL") + .expect("dedicated test database URL"); + let options: sqlx::postgres::PgConnectOptions = url.parse().unwrap(); + assert!(options + .get_database() + .unwrap_or("") + .starts_with("distributed_unique_key_test")); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + unique_key_join_fixture(pool.into()).await; +} + +async fn unique_key_sql(pool: &GraphqlPool, statement: &'static str) { + match pool { + #[cfg(feature = "sqlite")] + GraphqlPool::Sqlite(pool) => { + sqlx::query(statement).execute(pool).await.unwrap(); + } + #[cfg(feature = "postgres")] + GraphqlPool::Postgres(pool) => { + sqlx::query(statement).execute(pool).await.unwrap(); + } + } +} + +async fn unique_key_join_fixture(pool: GraphqlPool) { + use futures_util::StreamExt; + for statement in [ + "CREATE TEMP TABLE composite_records (id TEXT PRIMARY KEY NOT NULL, tenant_id TEXT NOT NULL, record_id TEXT NOT NULL, value TEXT NOT NULL, UNIQUE(tenant_id, record_id))", + "CREATE TEMP TABLE simple_records (simple_id TEXT PRIMARY KEY NOT NULL, tenant_id TEXT NOT NULL, record_id TEXT)", + "INSERT INTO composite_records VALUES ('opaque-a','a','same','first'),('opaque-b','b','same','second')", + "INSERT INTO simple_records VALUES ('1','a','same'),('2','b','same'),('3','a',NULL),('4','missing','same')", + ] { + unique_key_sql(&pool, statement).await; + } + let mut target = composite_records(); + for column in &mut target.columns { + column.primary_key = false; + } + target.columns.push(TableColumn { + primary_key: true, + ..TableColumn::new("id", "id", ColumnType::Text) + }); + target.primary_key = PrimaryKey::new(["id"]); + target.indexes.push(crate::table::TableIndex { + name: None, + columns: vec!["tenant_id".into(), "record_id".into()], + unique: true, + }); + target.relationships.push(RelationshipDef { + references: Some("tenant_id,record_id".into()), + field_name: "refs".into(), + kind: RelationshipKind::HasMany, + target_model: "SimpleRecord".into(), + foreign_key: Some("tenant_id,record_id".into()), + through: None, + target_foreign_key: None, + }); + let mut source = simple_records(); + source.columns.push(TableColumn { + nullable: true, + ..TableColumn::new("record_id", "record_id", ColumnType::Text) + }); + source.relationships.push(RelationshipDef { + references: Some("tenant_id,record_id".into()), + field_name: "record".into(), + kind: RelationshipKind::BelongsTo, + target_model: "CompositeRecord".into(), + foreign_key: Some("tenant_id,record_id".into()), + through: None, + target_foreign_key: None, + }); + let project = ReadModelCatalog::new("unique-key-test") + .table_schema(target) + .table_schema(source); + let engine = GraphqlEngine::from_schema_catalog(&project, pool.clone()) + .unwrap() + .roles(&["admin"]) + .grant_all("admin") + .build() + .unwrap(); + let mut session = Session::new(); + session.set(crate::microsvc::ROLE_KEY, "admin"); + let response = engine.execute(&session, Request::new( + "{ simple_records(order_by: [{simple_id: asc}]) { simple_id record { id value } } }" + )).await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().unwrap(); + assert_eq!( + data["simple_records"], + serde_json::json!([ + {"simple_id":"1","record":{"id":"opaque-a","value":"first"}}, + {"simple_id":"2","record":{"id":"opaque-b","value":"second"}}, + {"simple_id":"3","record":null}, + {"simple_id":"4","record":null}, + ]) + ); + let reverse = engine + .execute( + &session, + Request::new("{ composite_records(order_by: [{id: asc}]) { id refs { simple_id } } }"), + ) + .await; + assert!(reverse.errors.is_empty(), "{:?}", reverse.errors); + assert_eq!( + reverse.data.into_json().unwrap()["composite_records"], + serde_json::json!([ + {"id":"opaque-a","refs":[{"simple_id":"1"}]}, + {"id":"opaque-b","refs":[{"simple_id":"2"}]}, + ]) + ); + // Parent visibility must not confer visibility on the referenced object. + let (changes, change_rx) = tokio::sync::broadcast::channel(8); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool.clone()) + .unwrap() + .roles(&["reader"]) + .grant_all("reader") + .change_stream(change_rx); + builder + .permissions + .get_mut(&("CompositeRecord".into(), "reader".into())) + .unwrap() + .permission + .row_filter = Some(crate::graphql::col("value").eq("first")); + let restricted = builder.build().unwrap(); + let manifest = restricted.client_manifest_for_role("reader").unwrap(); + let object_model = manifest + .models + .iter() + .find(|model| model.source_table == "composite_records") + .unwrap(); + let normalization = serde_json::to_value(&object_model.normalization).unwrap(); + assert_eq!(normalization["kind"], "normalized"); + assert_eq!(normalization["fields"].as_array().unwrap().len(), 1); + assert_eq!(normalization["fields"][0]["name"], "id"); + let ref_model = manifest + .models + .iter() + .find(|model| model.source_table == "simple_records") + .unwrap(); + let mapping = &ref_model + .relationships + .iter() + .find(|relation| relation.name == "record") + .unwrap() + .key_mapping; + assert_eq!( + serde_json::to_value(mapping).unwrap(), + serde_json::json!({ + "kind":"direct", "local":["tenant_id","record_id"], "remote":["tenant_id","record_id"] + }) + ); + session.set(crate::microsvc::ROLE_KEY, "reader"); + let query = "{ simple_records(order_by: [{simple_id: asc}]) { simple_id record { id } } }"; + let mut live = Box::pin( + restricted.execute_stream(&session, Request::new(format!("subscription {query}"))), + ); + let initial_live = tokio::time::timeout(std::time::Duration::from_secs(3), live.next()) + .await + .expect("initial candidate-key subscription timed out") + .expect("subscription ended"); + assert!(initial_live.errors.is_empty(), "{:?}", initial_live.errors); + assert_eq!( + initial_live.data.into_json().unwrap()["simple_records"][0]["record"]["id"], + "opaque-a" + ); + let response = restricted.execute(&session, Request::new(query)).await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data.into_json().unwrap()["simple_records"], + serde_json::json!([ + {"simple_id":"1","record":{"id":"opaque-a"}}, + {"simple_id":"2","record":null}, + {"simple_id":"3","record":null}, + {"simple_id":"4","record":null}, + ]) + ); + let hidden_filter = restricted + .execute( + &session, + Request::new( + "{ simple_records(where: {record: {value: {_eq: \"second\"}}}) { simple_id } }", + ), + ) + .await; + assert!( + hidden_filter.errors.is_empty(), + "{:?}", + hidden_filter.errors + ); + assert_eq!( + hidden_filter.data.into_json().unwrap()["simple_records"], + serde_json::json!([]) + ); + unique_key_sql( + &pool, + "UPDATE composite_records SET value='revoked' WHERE id='opaque-a'", + ) + .await; + changes + .send(crate::read_model::ReadModelChange::new([ + "composite_records", + ])) + .unwrap(); + let revoked_live = tokio::time::timeout(std::time::Duration::from_secs(3), live.next()) + .await + .expect("target-only change did not refresh candidate-key subscription") + .expect("subscription ended"); + assert!(revoked_live.errors.is_empty(), "{:?}", revoked_live.errors); + for row in revoked_live.data.into_json().unwrap()["simple_records"] + .as_array() + .unwrap() + { + assert!( + row["record"].is_null(), + "live result leaked revoked target: {row}" + ); + } + let revoked = restricted.execute(&session, Request::new(query)).await; + assert!(revoked.errors.is_empty(), "{:?}", revoked.errors); + for row in revoked.data.into_json().unwrap()["simple_records"] + .as_array() + .unwrap() + { + assert!(row["record"].is_null(), "revoked target leaked: {row}"); + } +} + #[cfg(feature = "sqlite")] #[tokio::test] async fn belongs_to_loads_composite_target_rows() { diff --git a/src/graphql/engine/core.rs b/src/graphql/engine/core.rs index 472db32d7..b453f60ad 100644 --- a/src/graphql/engine/core.rs +++ b/src/graphql/engine/core.rs @@ -231,6 +231,10 @@ pub(crate) struct EngineInner { pub command_binding: Option, pub causal_storage_identity: Option, pub pool: GraphqlPool, + #[cfg(feature = "gateway-delivery")] + pub read_routing: Option, + #[cfg(feature = "gateway-delivery")] + pub gateway_versions: Option, pub catalog: BTreeMap, pub by_table: BTreeMap, pub permissions: BTreeMap<(String, String), RoleModelPerm>, @@ -294,6 +298,10 @@ pub struct GraphqlEngineBuilder { pub(crate) command_binding: Option, pub(crate) causal_storage_identity: Option, pub(crate) pool: GraphqlPool, + #[cfg(feature = "gateway-delivery")] + pub(crate) read_routing: Option, + #[cfg(feature = "gateway-delivery")] + pub(crate) gateway_versions: Option, pub(crate) catalog: BTreeMap, pub(crate) by_table: BTreeMap, pub(crate) permissions: BTreeMap<(String, String), RoleModelPerm>, @@ -309,6 +317,7 @@ pub struct GraphqlEngineBuilder { pub(crate) introspection_for_anonymous: bool, pub(crate) statement_timeout: Duration, pub(crate) graphiql: bool, + pub(crate) subscriptions: bool, pub(crate) typed_commands: TypedCommandInventory, pub(crate) projectors: Vec, pub(crate) change_rx: Option>, diff --git a/src/graphql/engine/delivery.rs b/src/graphql/engine/delivery.rs new file mode 100644 index 000000000..e70fcb24b --- /dev/null +++ b/src/graphql/engine/delivery.rs @@ -0,0 +1,168 @@ +use super::*; +use crate::gateway::{ + delivery::{OperationKey, OriginAdmission}, + graphql::{operation_kind, OperationKind}, +}; +use crate::graphql::delivery::{self, GatewayCapture, PlanCapture}; + +pub(crate) fn request_value(request: &Request) -> serde_json::Value { + serde_json::json!({"query": request.query, "operationName":request.operation_name, "variables":request.variables, "extensions":request.extensions}) +} +pub(crate) fn action(request: &Request) -> Option { + request + .extensions + .get("gatewayDelivery") + .and_then(|value| serde_json::to_value(value).ok()) + .and_then(|value| value["action"].as_str().map(str::to_owned)) +} +fn unavailable() -> Response { + let mut error = ServerError::new("origin delivery validation unavailable", None); + let mut extensions = async_graphql::ErrorExtensionValues::default(); + extensions.set("code", "DELIVERY_UNAVAILABLE"); + error.extensions = Some(extensions); + Response::from_errors(vec![error]) +} +fn ineligible() -> Response { + Response::new(Value::Null).extension( + "gatewayDelivery", + Value::from_json(serde_json::json!({"eligible":false})).expect("static JSON"), + ) +} +impl GraphqlEngine { + /// Active origin live-query producers for delivery diagnostics. + pub fn live_subscriber_count(&self) -> usize { + self.inner.change_hub.subscriber_count() + } + + pub(super) fn enable_delivery_capture( + &self, + session: &Session, + request: &Request, + accumulator: &ProtocolResponseAccumulator, + ) -> Result<(), ()> { + if action(request).as_deref() != Some("snapshot") + || self + .inner + .gateway_versions + .as_ref() + .is_none_or(|store| !store.envelope_coverage()) + { + return Ok(()); + } + if operation_kind(&request.query, request.operation_name.as_deref()) + != Ok(OperationKind::Query) + { + return Err(()); + } + let identity = self.delivery_identity(session, request).map_err(|_| ())?; + let key = OperationKey::from_origin(&identity, &request_value(request)).map_err(|_| ())?; + accumulator + .enable_gateway(GatewayCapture { + identity, + key, + validator: None, + }) + .map_err(|_| ()) + } + pub(super) async fn validate_delivery(&self, session: &Session, request: Request) -> Response { + let live = match operation_kind(&request.query, request.operation_name.as_deref()) { + Ok(OperationKind::Query) => false, + Ok(OperationKind::Subscription) => true, + _ => return ineligible(), + }; + let Some(store) = &self.inner.gateway_versions else { + return ineligible(); + }; + store.record_validation(); + let Some(runtime) = &self.inner.protocol else { + return ineligible(); + }; + let identity = match self.delivery_identity(session, &request) { + Ok(identity) => identity, + Err(_) => return ineligible(), + }; + let key = match OperationKey::from_origin(&identity, &request_value(&request)) { + Ok(key) => key, + Err(_) => return ineligible(), + }; + if self.prepare_read(session, &request).is_err() { + return unavailable(); + } + let authority = match resolve_execution_authority(&self.inner, session, &request) { + Ok(authority) => authority, + Err(_) => return ineligible(), + }; + let Some(schema) = self.inner.schemas.get(&authority.privilege_role) else { + return ineligible(); + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |value| value.as_secs()); + let expiry = request + .data + .get(&TypeId::of::()) + .and_then(|p| p.downcast_ref::()) + .and_then(VerifiedPrincipal::expires_at) + .unwrap_or(now.saturating_add(if live { 3600 } else { 30 })); + if expiry <= now { + return unavailable(); + } + let operation = operation_fingerprint(&request.query); + let policy = store.policy(&request.query, request.operation_name.as_deref()); + let captured = PlanCapture::default(); + let request = request + .data(session.clone()) + .data(authority) + .data(Arc::clone(&self.inner)) + .data(captured.clone()); + let response = if live { + schema + .execute_stream(request) + .next() + .await + .unwrap_or_else(ineligible) + } else { + schema.execute(request).await + }; + // Schema validation and normal compiler authorization still run. Only + // the private capture sentinel may replace SQL; unknown/custom fields, + // cell reads and multi-root documents never acquire cache eligibility. + if response.errors.len() != 1 || response.errors[0].message != delivery::CAPTURED { + return ineligible(); + } + let plan = match captured.0.lock() { + Ok(mut plans) if plans.len() == 1 => plans.pop().expect("captured plan"), + _ => return ineligible(), + }; + if !store.covers(&plan.tables_touched) { + return ineligible(); + } + let versions = match store.current(&self.inner.pool, &plan.tables_touched).await { + Ok(versions) => versions, + Err(error) + if error.starts_with("dependency ") || error.starts_with("query dependency ") => + { + return ineligible() + } + Err(_) => return unavailable(), + }; + let validator = match delivery::validator(&runtime.codec, &identity, &key, &versions) { + Ok(validator) => validator, + Err(_) => return unavailable(), + }; + let admission = OriginAdmission { + identity, + key, + operation, + validator, + validated_at: now, + expires_at: expiry, + policy, + }; + Response::new(Value::Null).extension( + "gatewayDelivery", + Value::from_json(serde_json::json!({"eligible":true,"admission":admission})) + .expect("serializable admission"), + ) + } +} diff --git a/src/graphql/engine/mod.rs b/src/graphql/engine/mod.rs index ce85b1cc1..24d2b6c5f 100644 --- a/src/graphql/engine/mod.rs +++ b/src/graphql/engine/mod.rs @@ -24,7 +24,6 @@ use super::client_manifest::{ trusted_preset_descriptors, ClientExecutionLimits, ClientManifestError, ClientSurfaceIdentity, ClientTrustedPresetDescriptor, DistributedClientManifest, DistributedClientSurfaceExport, }; -use super::command_contract::TypedServiceCommandBinding; use super::commands::TypedCommandInventory; use super::compile::{SqlDialect, SqlPlan}; use super::execute; @@ -48,6 +47,7 @@ use super::surface::{ build_surface, surface_for_application_contract, surface_for_role, Surface, SurfaceDialect, SurfaceOptions, SurfaceProjectionOwner, SurfaceProjector, SurfaceSelection, }; +use crate::command::TypedServiceCommandBinding; const GRAPHIQL_INTROSPECTION_MAX_DEPTH_FLOOR: usize = 15; const GRAPHIQL_INTROSPECTION_MAX_COMPLEXITY_FLOOR: usize = 10_000; @@ -102,3 +102,11 @@ pub(crate) use validation::{execute_plan, validate_filter, validate_generated_na pub fn core_sdl_for_catalog(tables: &[TableSchema]) -> Result { validation::core_sdl_for_catalog(tables) } + +#[cfg(feature = "gateway-delivery")] +pub(crate) mod read_routing; +#[cfg(feature = "gateway-delivery")] +pub use read_routing::ReadRouting; + +#[cfg(feature = "gateway-delivery")] +mod delivery; diff --git a/src/graphql/engine/public_api.rs b/src/graphql/engine/public_api.rs index 05e5889c0..d3bced410 100644 --- a/src/graphql/engine/public_api.rs +++ b/src/graphql/engine/public_api.rs @@ -63,7 +63,7 @@ impl GraphqlEngine { pub(crate) fn typed_command_contracts_for_service( &self, - ) -> Result, String> { + ) -> Result, String> { Ok(self.inner.typed_commands.contracts_for_binding()) } diff --git a/src/graphql/engine/read_routing.rs b/src/graphql/engine/read_routing.rs new file mode 100644 index 000000000..e339523cd --- /dev/null +++ b/src/graphql/engine/read_routing.rs @@ -0,0 +1,197 @@ +use super::*; +use crate::gateway::{ + delivery::*, + graphql::{operation_kind, OperationKind}, +}; + +/// Explicit stale-tolerant allowlist. No timeout/stickiness is treated as replay +/// proof. With any retained floor the initial router always uses primary. +#[derive(Clone)] +pub struct ReadRouting { + pub(crate) replica: GraphqlPool, + stale_tolerant: BTreeSet<(String, Option)>, +} +impl ReadRouting { + /// Bind an optional read replica; no query uses it until explicitly registered. + pub fn new(replica: impl Into) -> Self { + Self { + replica: replica.into(), + stale_tolerant: BTreeSet::new(), + } + } + /// Register one exact selected ordinary query as stale-tolerant. + pub fn stale_tolerant( + mut self, + document: impl Into, + operation_name: Option, + ) -> Result { + let document = document.into(); + if operation_kind(&document, operation_name.as_deref()) != Ok(OperationKind::Query) + || self.stale_tolerant.len() >= 4096 + { + return Err(GraphqlBuildError( + "stale-tolerant routing requires a bounded ordinary query inventory".into(), + )); + } + self.stale_tolerant.insert((document, operation_name)); + Ok(self) + } +} + +#[derive(Clone)] +pub(crate) struct ReadRequest { + policy: ReadConsistency, + pub(crate) freshness: Option, +} +impl ReadRequest { + pub(crate) fn pool<'a>( + &self, + inner: &'a EngineInner, + plan: &SqlPlan, + ) -> (&'a GraphqlPool, bool) { + let Some(routing) = &inner.read_routing else { + return (&inner.pool, true); + }; + // Physical compiler footprint covers filters, empty membership, joins + // and counts even when no result row contains a corresponding key. + let mut dependencies = Dependencies { + complete: true, + ..Default::default() + }; + for table in &plan.tables_touched { + if let Some(model) = inner.by_table.get(table) { + dependencies.models.insert(model.clone()); + } else { + dependencies.complete = false; + } + } + match read_target(self.policy, &dependencies, self.freshness.as_ref()) { + ReadTarget::Primary => (&inner.pool, true), + ReadTarget::Replica => (&routing.replica, false), + } + } +} + +impl GraphqlEngine { + /// Establish identity on the authenticated origin control path. Callers + /// must supply the same verified principal and surface request as execution. + /// The returned scope is an identifier, never authorization by itself. + pub fn delivery_identity( + &self, + session: &Session, + request: &Request, + ) -> Result { + let authority = resolve_execution_authority(&self.inner, session, request) + .map_err(|_| DeliveryError::Ineligible)?; + let runtime = self + .inner + .protocol + .as_ref() + .ok_or(DeliveryError::Ineligible)?; + let (_, surface, _, _) = + select_protocol_surface(runtime, &authority).map_err(|_| DeliveryError::Ineligible)?; + let envelope = self + .protocol_accumulator(&authority, session, request) + .map_err(|_| DeliveryError::Ineligible)? + .ok_or(DeliveryError::Ineligible)? + .snapshot() + .map_err(|_| DeliveryError::Ineligible)?; + let identity = OriginIdentity { + application: serde_json::to_string(&authority.surface) + .map_err(|_| DeliveryError::Ineligible)?, + endpoint: runtime.service_id.clone(), + schema_hash: envelope.schema_hash, + protocol_hash: surface.protocol_fingerprint.clone(), + authorization_generation: envelope.authorization_generation, + cache_scope: envelope.cache_scope.as_str().to_owned(), + }; + identity.validate()?; + Ok(identity) + } + pub(crate) fn prepare_read( + &self, + session: &Session, + request: &Request, + ) -> Result { + let kind = operation_kind(&request.query, request.operation_name.as_deref()); + let context = request.extensions.get("gatewayFreshness"); + let freshness = if let Some(context) = context { + if !matches!(kind, Ok(OperationKind::Query | OperationKind::Subscription)) { + return Err(DeliveryError::Ineligible); + } + let context = + serde_json::to_value(context).map_err(|_| DeliveryError::InvalidContext)?; + let context = FreshnessContext::parse(&context)?; + context.bind(&self.delivery_identity(session, request)?)?; + Some(context) + } else { + None + }; + let policy = if kind == Ok(OperationKind::Query) + && self.inner.read_routing.as_ref().is_some_and(|r| { + r.stale_tolerant + .contains(&(request.query.clone(), request.operation_name.clone())) + }) { + ReadConsistency::StaleTolerant + } else { + ReadConsistency::Current + }; + Ok(ReadRequest { policy, freshness }) + } +} + +pub(crate) fn enforce_minimum(response: Response, read: &ReadRequest) -> Response { + let Some(context) = &read.freshness else { + return response; + }; + if context.minimum.is_empty() || !response.errors.is_empty() { + return response; + } + let value = serde_json::to_value(&response).unwrap_or_default(); + let snapshot = &value["extensions"]["distributed"]["snapshot"]; + let mut evidence = Vec::new(); + for record in snapshot["records"].as_array().into_iter().flatten() { + let mut record = record.clone(); + if let Some(object) = record.as_object_mut() { + object.remove("path"); + object.remove("tombstone"); + object.insert("kind".into(), "record".into()); + } + if let Ok(minimum) = serde_json::from_value::(record) { + if minimum.validate().is_ok() { + evidence.push(minimum); + } + } + } + for index in snapshot["indexes"].as_array().into_iter().flatten() { + let mut index = index.clone(); + if let Some(object) = index.as_object_mut() { + object.remove("resume"); + object.insert("kind".into(), "index".into()); + } + if let Ok(minimum) = serde_json::from_value::(index) { + if minimum.validate().is_ok() { + evidence.push(minimum); + } + } + } + if context.satisfied_by(&evidence) { + response + } else { + delivery_error(DeliveryError::Pending) + } +} +pub(crate) fn delivery_error(error: DeliveryError) -> Response { + let mut result = ServerError::new(error.to_string(), None); + let mut extensions = async_graphql::ErrorExtensionValues::default(); + extensions.set( + "code", + match error { + DeliveryError::Pending => "FRESHNESS_PENDING", + DeliveryError::ScopeChanged => "FRESHNESS_SCOPE_CHANGED", + _ => "INVALID_FRESHNESS_CONTEXT", + }, + ); + result.extensions = Some(extensions); + Response::from_errors(vec![result]) +} diff --git a/src/graphql/engine/request.rs b/src/graphql/engine/request.rs index 86dd15def..e8d3c2e77 100644 --- a/src/graphql/engine/request.rs +++ b/src/graphql/engine/request.rs @@ -1,8 +1,40 @@ use super::*; +pub(super) enum ProtocolPreparationError { + RequiredPreset, + Internal, +} + +impl From<()> for ProtocolPreparationError { + fn from(_: ()) -> Self { + Self::Internal + } +} + +impl ProtocolPreparationError { + fn into_response(self) -> Response { + match self { + Self::RequiredPreset => { + let mut error = + ServerError::new("required session input is missing or invalid", None); + let mut extensions = async_graphql::ErrorExtensionValues::default(); + extensions.set("code", "BAD_REQUEST"); + error.extensions = Some(extensions); + Response::from_errors(vec![error]) + } + Self::Internal => protocol_internal_error_response(), + } + } +} + impl GraphqlEngine { pub async fn execute(&self, session: &Session, mut request: Request) -> Response { - if selected_operation_type(&mut request) == Some(async_graphql::parser::types::OperationType::Mutation) + #[cfg(feature = "gateway-delivery")] + if delivery::action(&request).as_deref() == Some("validate") { + return self.validate_delivery(session, request).await; + } + if selected_operation_type(&mut request) + == Some(async_graphql::parser::types::OperationType::Mutation) && !crate::microsvc::lifecycle_mutations_open() { return lifecycle_mutation_rejected(); @@ -38,13 +70,29 @@ impl GraphqlEngine { let accumulator = match self.protocol_accumulator(&authority, session, &request) { Ok(accumulator) => accumulator, - Err(()) => return protocol_internal_error_response(), + Err(error) => return error.into_response(), }; + #[cfg(feature = "gateway-delivery")] + if let Some(accumulator) = &accumulator { + if self + .enable_delivery_capture(session, &request, accumulator) + .is_err() + { + return protocol_internal_error_response(); + } + } if introspection { // The relaxed schema is defense-in-depth restricted even if a // future classifier or request extension behaves unexpectedly. request = request.only_introspection(); } + #[cfg(feature = "gateway-delivery")] + let read = match self.prepare_read(session, &request) { + Ok(read) => read, + Err(error) => return read_routing::delivery_error(error), + }; + #[cfg(feature = "gateway-delivery")] + let request = request.data(read.clone()); let mut request = request .data(session.clone()) .data(authority) @@ -55,6 +103,8 @@ impl GraphqlEngine { let start = std::time::Instant::now(); let response = attach_protocol_response(schema.execute(request).await, accumulator.as_ref()); + #[cfg(feature = "gateway-delivery")] + let response = read_routing::enforce_minimum(response, &read); let status = metrics_status_for_response(&response); let root_field = match &response.data { Value::Object(map) => map.keys().next().map(|s| s.as_str()).unwrap_or("_"), @@ -70,7 +120,8 @@ impl GraphqlEngine { session: &Session, mut request: Request, ) -> BoxStream<'static, async_graphql::Response> { - if selected_operation_type(&mut request) == Some(async_graphql::parser::types::OperationType::Mutation) + if selected_operation_type(&mut request) + == Some(async_graphql::parser::types::OperationType::Mutation) && !crate::microsvc::lifecycle_mutations_open() { return stream::once(async { lifecycle_mutation_rejected() }).boxed(); @@ -111,8 +162,8 @@ impl GraphqlEngine { } let accumulator = match self.protocol_accumulator(&authority, session, &request) { Ok(accumulator) => accumulator, - Err(()) => { - return stream::once(async { protocol_internal_error_response() }).boxed(); + Err(error) => { + return stream::once(async move { error.into_response() }).boxed(); } }; if accumulator @@ -124,6 +175,15 @@ impl GraphqlEngine { if introspection { request = request.only_introspection(); } + #[cfg(feature = "gateway-delivery")] + let read = match self.prepare_read(session, &request) { + Ok(read) => read, + Err(error) => { + return stream::once(async move { read_routing::delivery_error(error) }).boxed() + } + }; + #[cfg(feature = "gateway-delivery")] + let request = request.data(read.clone()); let mut request = request .data(session.clone()) .data(authority) @@ -133,16 +193,21 @@ impl GraphqlEngine { } schema .execute_stream(request) - .map(move |response| attach_protocol_response(response, accumulator.as_ref())) + .map(move |response| { + let response = attach_protocol_response(response, accumulator.as_ref()); + #[cfg(feature = "gateway-delivery")] + let response = read_routing::enforce_minimum(response, &read); + response + }) .boxed() } - fn protocol_accumulator( + pub(super) fn protocol_accumulator( &self, authority: &ExecutionAuthority, session: &Session, request: &Request, - ) -> Result, ()> { + ) -> Result, ProtocolPreparationError> { let Some(runtime) = &self.inner.protocol else { return Ok(None); }; @@ -151,7 +216,10 @@ impl GraphqlEngine { let trusted_presets = surface_info .trusted_presets .iter() - .map(|descriptor| resolve_protocol_preset(session, descriptor).ok_or(())) + .map(|descriptor| { + resolve_protocol_preset(session, descriptor) + .ok_or(ProtocolPreparationError::RequiredPreset) + }) .collect::, _>>()?; let principal = request .data @@ -310,20 +378,32 @@ mod lifecycle_request_tests { use async_graphql::parser::types::OperationType; use async_graphql::Request; + #[test] + fn internal_protocol_preparation_errors_are_not_request_errors() { + let response = super::ProtocolPreparationError::from(()).into_response(); + let expected = super::protocol_internal_error_response(); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::to_value(expected).unwrap() + ); + } + #[test] fn selected_operation_type_fails_closed_for_ambiguous_documents() { let mut mutation = Request::new("mutation Write { __typename }"); - assert_eq!(selected_operation_type(&mut mutation), Some(OperationType::Mutation)); - - let mut selected = Request::new( - "query Read { __typename } mutation Write { __typename }", - ) - .operation_name("Write"); - assert_eq!(selected_operation_type(&mut selected), Some(OperationType::Mutation)); + assert_eq!( + selected_operation_type(&mut mutation), + Some(OperationType::Mutation) + ); - let mut ambiguous = Request::new( - "query Read { __typename } mutation Write { __typename }", + let mut selected = Request::new("query Read { __typename } mutation Write { __typename }") + .operation_name("Write"); + assert_eq!( + selected_operation_type(&mut selected), + Some(OperationType::Mutation) ); + + let mut ambiguous = Request::new("query Read { __typename } mutation Write { __typename }"); assert_eq!(selected_operation_type(&mut ambiguous), None); } } diff --git a/src/graphql/engine/tests.rs b/src/graphql/engine/tests.rs index 17ab123d1..6dcf06364 100644 --- a/src/graphql/engine/tests.rs +++ b/src/graphql/engine/tests.rs @@ -9,13 +9,14 @@ mod client_surface_parity_tests { use sha2::{Digest, Sha256}; use super::*; - use crate::graphql::command_contract::{CommandEffects, TypedCommandContract}; + use crate::command::CommandConsistency; + use crate::command::{CommandEffects, TypedCommandContract}; + use crate::command::{CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField}; use crate::graphql::commands::TypedCommandInventory; #[cfg(feature = "sqlite")] use crate::graphql::ModelNormalization; use crate::graphql::{ - claim, col, ClientRootOperation, CommandConsistency, DistributedClientSurfaceExport, - GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, RoleGrant, + claim, col, ClientRootOperation, DistributedClientSurfaceExport, RoleGrant, }; use crate::table::{ColumnType, PrimaryKey, TableColumn, TableKind, TableSchema}; #[cfg(feature = "sqlite")] @@ -64,15 +65,15 @@ mod client_surface_parity_tests { roles: &[&str], ) -> TypedCommandContract where - I: GraphqlInputType + 'static, - O: GraphqlOutputType + 'static, + I: CommandInputType + 'static, + O: CommandOutputType + 'static, { TypedCommandContract { name: command_name.into(), field_name: field_name.into(), roles: roles.iter().map(|role| (*role).into()).collect(), - input: I::graphql_type().with_type_id(TypeId::of::()), - output: O::graphql_type().with_type_id(TypeId::of::()), + input: I::command_type().with_type_id(TypeId::of::()), + output: O::command_type().with_type_id(TypeId::of::()), input_type_id: TypeId::of::(), output_type_id: TypeId::of::(), consistency: CommandConsistency::Succeeded, @@ -974,6 +975,70 @@ mod client_surface_parity_tests { ); } + #[cfg(feature = "sqlite")] + #[tokio::test] + async fn required_presets_reject_query_and_stream_without_internal_errors() { + let mut engine = preset_protocol_engine(); + Arc::get_mut(&mut engine.inner) + .unwrap() + .protocol + .as_mut() + .unwrap() + .roles + .get_mut("user") + .unwrap() + .surface + .trusted_presets = vec![ClientTrustedPresetDescriptor { + name: "x-required-number".into(), + codec: "int32".into(), + }]; + for value in [None, Some("not-a-number"), Some("01"), Some("2147483648")] { + let mut session = Session::new(); + session.set("x-roles", "user"); + if let Some(value) = value { + session.set("x-required-number", value); + } + let query = engine + .execute(&session, Request::new("{ __typename }")) + .await; + let streamed = engine + .execute_stream(&session, Request::new("{ __typename }")) + .collect::>() + .await; + assert_eq!(streamed.len(), 1); + for response in std::iter::once(query).chain(streamed) { + assert_eq!(response.errors.len(), 1); + let error = serde_json::to_value(&response.errors[0]).unwrap(); + assert_eq!(error["extensions"]["code"], "BAD_REQUEST", "{error}"); + assert_eq!( + error["message"], + "required session input is missing or invalid" + ); + assert_eq!(response.data, Value::Null); + assert!(!response.extensions.contains_key("distributed")); + } + } + let mut valid = Session::new(); + valid.set("x-roles", "user"); + valid.set("x-required-number", "42"); + let response = engine.execute(&valid, Request::new("{ __typename }")).await; + assert!(!response.is_err(), "{:?}", response.errors); + assert_eq!( + distributed_extension(&response)["trustedPresets"][0]["value"], + 42 + ); + let responses = engine + .execute_stream(&valid, Request::new("{ __typename }")) + .collect::>() + .await; + assert_eq!(responses.len(), 1); + assert!(!responses[0].is_err()); + assert_eq!( + distributed_extension(&responses[0])["trustedPresets"][0]["value"], + 42 + ); + } + #[cfg(feature = "sqlite")] #[tokio::test] async fn cache_scope_tracks_only_relevant_claims_and_private_policy() { @@ -1404,9 +1469,9 @@ mod client_surface_parity_tests { type_name: &str, nullable: bool, list: bool, - nested: Option, - ) -> GraphqlTypeField { - GraphqlTypeField { + nested: Option, + ) -> CommandTypeField { + CommandTypeField { name: name.into(), type_name: type_name.into(), nullable, @@ -1418,16 +1483,16 @@ mod client_surface_parity_tests { struct ChangeOrderInput; - impl GraphqlInputType for ChangeOrderInput { - fn graphql_type() -> GraphqlTypeDef { - let patch = GraphqlTypeDef::new( + impl CommandInputType for ChangeOrderInput { + fn command_type() -> CommandTypeDef { + let patch = CommandTypeDef::new( "OrderPatchInput", vec![ type_field("status", "String", false, false, None), type_field("metadata", "JSON", true, false, None), ], ); - GraphqlTypeDef::new( + CommandTypeDef::new( "ChangeOrderInput", vec![ type_field("patch", "OrderPatchInput", false, false, Some(patch)), @@ -1439,16 +1504,16 @@ mod client_surface_parity_tests { struct ChangeOrderPayload; - impl GraphqlOutputType for ChangeOrderPayload { - fn graphql_type() -> GraphqlTypeDef { - let changed_order = GraphqlTypeDef::new( + impl CommandOutputType for ChangeOrderPayload { + fn command_type() -> CommandTypeDef { + let changed_order = CommandTypeDef::new( "ChangedOrder", vec![ type_field("status", "String", false, false, None), type_field("order_id", "String", false, false, None), ], ); - GraphqlTypeDef::new( + CommandTypeDef::new( "ChangeOrderPayload", vec![ type_field("warnings", "String", true, true, None), @@ -2081,6 +2146,7 @@ mod client_surface_parity_tests { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "PolicyChildView".into(), diff --git a/src/graphql/execute.rs b/src/graphql/execute.rs index d0a25186d..7d5bd8852 100644 --- a/src/graphql/execute.rs +++ b/src/graphql/execute.rs @@ -11,15 +11,27 @@ use super::compile::{BindValue, ExtractedQueryEvidence, SqlDialect, SqlPlan}; use super::engine::{EngineInner, GraphqlPool}; pub async fn execute_sql(inner: &EngineInner, plan: &SqlPlan) -> Result { - match &inner.pool { + execute_sql_on_pool(inner, &inner.pool, true, plan).await +} + +pub(crate) async fn execute_sql_on_pool( + inner: &EngineInner, + pool: &GraphqlPool, + primary: bool, + plan: &SqlPlan, +) -> Result { + let _ = primary; + match pool { #[cfg(feature = "sqlite")] GraphqlPool::Sqlite(pool) => execute_sqlite(pool, plan, inner.statement_timeout) .await .map(|executed| executed.value), #[cfg(feature = "postgres")] - GraphqlPool::Postgres(pool) => execute_postgres(pool, plan, inner.statement_timeout) - .await - .map(|executed| executed.value), + GraphqlPool::Postgres(pool) => { + execute_postgres(pool, plan, inner.statement_timeout, primary) + .await + .map(|executed| executed.value) + } #[allow(unreachable_patterns)] _ => Err("no database pool available for GraphQL execution".into()), } @@ -204,6 +216,7 @@ async fn execute_postgres( pool: &sqlx::PgPool, plan: &SqlPlan, timeout: std::time::Duration, + primary: bool, ) -> Result { let mut tx = pool .begin() @@ -217,6 +230,7 @@ async fn execute_postgres( .await .map_err(|e| format!("statement_timeout: {e}"))?; + ensure_primary_backend(&mut tx, primary).await?; let value = fetch_postgres_value(&mut *tx, plan).await?; tx.commit() .await @@ -331,3 +345,24 @@ pub fn dialect_name(d: SqlDialect) -> &'static str { SqlDialect::Sqlite => "sqlite", } } + +/// Check the actual transaction connection, not another pool checkout. This +/// detects a physical standby misbound/switched into the primary slot; it does +/// not claim a general timeline or WAL replay routing implementation. +#[cfg(feature = "postgres")] +pub(crate) async fn ensure_primary_backend( + connection: &mut sqlx::PgConnection, + primary: bool, +) -> Result<(), String> { + #[cfg(feature = "gateway-delivery")] + if primary + && sqlx::query_scalar::<_, bool>("SELECT pg_is_in_recovery()") + .fetch_one(&mut *connection) + .await + .map_err(|e| format!("primary validation: {e}"))? + { + return Err("authoritative primary is unavailable".into()); + } + let _ = (connection, primary); + Ok(()) +} diff --git a/src/graphql/http.rs b/src/graphql/http.rs index b5e9591a9..91eb0f583 100644 --- a/src/graphql/http.rs +++ b/src/graphql/http.rs @@ -54,6 +54,30 @@ pub fn graphiql_page() -> Html { ) } +/// Per-operation execution admission for a composed GraphQL surface. The +/// filter runs for HTTP and each WS operation, after ordinary authentication. +/// It may restrict the surface but never supplies identity or commit evidence. +pub type GraphqlOperationFilter = Arc Result<(), ServerError> + Send + Sync>; + +/// Resource lease supplied only by the native gateway adapter. +pub struct GraphqlConnectionGuard { + pub(crate) lifetime: std::time::Duration, + pub(crate) _permit: tokio::sync::OwnedSemaphorePermit, +} +impl GraphqlConnectionGuard { + /// Run a custom executor connection within its admitted lifetime. Keep this + /// guard alive until completion to retain the gateway capacity permit. + pub async fn run(&self, connection: F) -> Option { + tokio::time::timeout(self.lifetime, connection).await.ok() + } +} + +#[derive(Default)] +struct GraphqlWsOptions { + filter: Option, + guard: Option>, +} + /// [`Executor`] for WebSocket subscriptions. /// /// Prefers a [`Session`] injected via `connection_init` / `session_data` (GraphiQL @@ -64,6 +88,7 @@ pub struct GraphqlSessionExecutor { session: Session, principal: Option, host: Option, + filter: Option, } impl GraphqlSessionExecutor { @@ -73,6 +98,7 @@ impl GraphqlSessionExecutor { session, principal: None, host: None, + filter: None, } } @@ -81,18 +107,23 @@ impl GraphqlSessionExecutor { session: Session, principal: Option, host: Option, + filter: Option, ) -> Self { Self { engine, session, principal, host, + filter, } } } impl Executor for GraphqlSessionExecutor { async fn execute(&self, request: Request) -> GqlResponse { + if let Some(Err(error)) = self.filter.as_ref().map(|filter| filter(&request)) { + return GqlResponse::from_errors(vec![error]); + } // Subscriptions must not go through execute() — that yields // "Subscription root not found". GraphiQL should use the WS path only. self.engine @@ -113,6 +144,11 @@ impl Executor for GraphqlSessionExecutor { session_data: Option>, ) -> BoxStream<'static, GqlResponse> { use std::any::TypeId; + if let Some(Err(error)) = self.filter.as_ref().map(|filter| filter(&request)) { + return Box::pin(futures_util::stream::once(async move { + GqlResponse::from_errors(vec![error]) + })); + } let session = session_data .as_ref() .and_then(|d| d.get(&TypeId::of::())) @@ -231,10 +267,22 @@ pub fn graphql_router_with_dispatcher( /// GraphQL router that wait-dispatches through an explicit command host. pub fn graphql_router_with_host(engine: Arc, host: SharedCommandHost) -> Router { + graphql_router_composed(engine, Some(host), None) +} + +/// Compose the existing HTTP and WS handlers with an operation filter. A +/// query-only executor can omit the command host entirely. Callers still own +/// schema construction; filters cannot hide fields from schema introspection. +pub fn graphql_router_composed( + engine: Arc, + host: Option, + filter: Option, +) -> Router { let graphiql = engine.graphiql_enabled(); let state = GraphqlHttpState { engine, - host: Some(host), + host, + filter, }; let mut router = Router::new() .route( @@ -256,6 +304,7 @@ pub fn graphql_router_with_host(engine: Arc, host: SharedCommandH struct GraphqlHttpState { engine: Arc, host: Option, + filter: Option, } fn unauthorized_response() -> Response { @@ -276,6 +325,56 @@ fn lifecycle_reloading_response() -> Response { .into_response() } +// Gateway-delivered WS operations carry the original connection_init payload. +// Only the origin's existing credential parser/validator resolves authority; +// the gateway neither decodes roles nor fabricates a provider identity. +async fn resolve_http_identity( + engine: &GraphqlEngine, + headers: &HeaderMap, + request: &Request, +) -> Result { + #[cfg(feature = "gateway-delivery")] + if let Some(init) = request + .extensions + .get("gatewayDelivery") + .and_then(|value| serde_json::to_value(value).ok()) + .and_then(|value| value.get("connectionInit").cloned()) + { + return resolve_gateway_ws_identity(engine, headers, &init) + .await + .map_err(|_| AuthError::Unauthorized); + } + let _ = request; + resolve_identity_with_validator( + headers, + engine.identity_config(), + engine.identity_validator(), + ) + .await +} +#[cfg(feature = "gateway-delivery")] +pub(crate) async fn resolve_gateway_ws_identity( + engine: &GraphqlEngine, + headers: &HeaderMap, + init: &serde_json::Value, +) -> Result { + let base = match engine.identity_config().mode { + IdentityMode::OidcBearer | IdentityMode::Hybrid => Session::new(), + _ => { + resolve_identity_with_validator( + headers, + engine.identity_config(), + engine.identity_validator(), + ) + .await + .map_err(|_| "unauthorized".to_owned())? + .into_parts() + .0 + } + }; + resolve_ws_identity(engine, headers, base, init).await +} + async fn graphql_handler( State(engine): State>, headers: axum::http::HeaderMap, @@ -283,17 +382,14 @@ async fn graphql_handler( ) -> Response { let request = req.into_inner(); if !crate::microsvc::lifecycle_mutations_open() - && matches!(websocket_operation_type(&request), Ok(OperationType::Mutation)) + && matches!( + websocket_operation_type(&request), + Ok(OperationType::Mutation) + ) { return lifecycle_reloading_response(); } - let identity = match resolve_identity_with_validator( - &headers, - engine.identity_config(), - engine.identity_validator(), - ) - .await - { + let identity = match resolve_http_identity(&engine, &headers, &request).await { Ok(identity) => identity, Err(AuthError::Unauthorized) => return unauthorized_response(), }; @@ -312,21 +408,21 @@ async fn graphql_handler_with_service( ) -> Response { let request = req.into_inner(); if !crate::microsvc::lifecycle_mutations_open() - && matches!(websocket_operation_type(&request), Ok(OperationType::Mutation)) + && matches!( + websocket_operation_type(&request), + Ok(OperationType::Mutation) + ) { return lifecycle_reloading_response(); } - let identity = match resolve_identity_with_validator( - &headers, - state.engine.identity_config(), - state.engine.identity_validator(), - ) - .await - { + let identity = match resolve_http_identity(&state.engine, &headers, &request).await { Ok(identity) => identity, Err(AuthError::Unauthorized) => return unauthorized_response(), }; let (session, principal) = identity.into_parts(); + if let Some(Err(error)) = state.filter.as_ref().map(|filter| filter(&request)) { + return GraphQLResponse::from(GqlResponse::from_errors(vec![error])).into_response(); + } let mut request = request_with_principal(request, principal); if let Some(host) = &state.host { request = request.data(Arc::clone(host)); @@ -343,20 +439,17 @@ pub async fn microsvc_graphql_handler( ) -> Response { let request = req.into_inner(); if !crate::microsvc::lifecycle_mutations_open() - && matches!(websocket_operation_type(&request), Ok(OperationType::Mutation)) + && matches!( + websocket_operation_type(&request), + Ok(OperationType::Mutation) + ) { return lifecycle_reloading_response(); } let engine = service .graphql_engine() .expect("graphql route mounted without engine"); - let identity = match resolve_identity_with_validator( - &headers, - engine.identity_config(), - engine.identity_validator(), - ) - .await - { + let identity = match resolve_http_identity(&engine, &headers, &request).await { Ok(identity) => identity, Err(AuthError::Unauthorized) => return unauthorized_response(), }; @@ -395,6 +488,7 @@ pub async fn microsvc_graphql_ws( headers: HeaderMap, uri: axum::http::Uri, protocol: GraphQLProtocol, + guard: Option>>, upgrade: WebSocketUpgrade, ) -> Response { let engine = match service.graphql_engine() { @@ -402,7 +496,19 @@ pub async fn microsvc_graphql_ws( 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 + graphql_ws_upgrade( + engine, + Some(host), + GraphqlWsOptions { + filter: None, + guard: guard.map(|guard| guard.0), + }, + headers, + uri, + protocol, + upgrade, + ) + .await } async fn graphql_ws_with_host( @@ -410,14 +516,28 @@ async fn graphql_ws_with_host( headers: HeaderMap, uri: axum::http::Uri, protocol: GraphQLProtocol, + guard: Option>>, upgrade: WebSocketUpgrade, ) -> Response { - graphql_ws_upgrade(state.engine, state.host, headers, uri, protocol, upgrade).await + graphql_ws_upgrade( + state.engine, + state.host, + GraphqlWsOptions { + filter: state.filter, + guard: guard.map(|guard| guard.0), + }, + headers, + uri, + protocol, + upgrade, + ) + .await } async fn graphql_ws_upgrade( engine: Arc, host: Option, + options: GraphqlWsOptions, headers: HeaderMap, uri: axum::http::Uri, protocol: GraphQLProtocol, @@ -450,6 +570,7 @@ async fn graphql_ws_upgrade( upgrade_session.clone(), upgrade_principal, host, + options.filter, ); let engine_for_init = Arc::clone(&engine); upgrade @@ -457,7 +578,7 @@ async fn graphql_ws_upgrade( .on_upgrade(move |socket| { let base = upgrade_session; let upgrade_headers = upgrade_headers; - GraphQLWebSocket::new(socket, executor, protocol) + let serve = GraphQLWebSocket::new(socket, executor, protocol) .on_connection_init(move |payload| { let base = base.clone(); let engine = engine_for_init; @@ -481,7 +602,15 @@ async fn graphql_ws_upgrade( Ok(data) } }) - .serve() + .serve(); + async move { + if let Some(guard) = options.guard { + let _ = tokio::time::timeout(guard.lifetime, serve).await; + drop(guard); + } else { + serve.await; + } + } }) .into_response() } diff --git a/src/graphql/identity/gateway.rs b/src/graphql/identity/gateway.rs new file mode 100644 index 000000000..6e762214f --- /dev/null +++ b/src/graphql/identity/gateway.rs @@ -0,0 +1,68 @@ +//! Gateway provider using the existing OIDC validator and role mapping. +use super::{OidcConfig, OidcValidator}; +use crate::gateway::{ + AuthError, AuthProvider, BackendCredential, Credentials, Identity, RequestContext, +}; + +/// Strict bearer provider for public gateways. Ambient identity headers and +/// cookies cannot enter this adapter; use an explicit session provider for UI. +pub struct OidcGatewayProvider { + validator: OidcValidator, + revision: String, +} + +impl OidcGatewayProvider { + /// Reuse one validator/JWKS cache across all routes. The revision identifies + /// this provider configuration and must change when its policy changes. + pub fn new(config: OidcConfig, revision: impl Into) -> Self { + Self { + validator: OidcValidator::new(config), + revision: revision.into(), + } + } +} + +impl AuthProvider for OidcGatewayProvider { + async fn authenticate(&self, credentials: &Credentials) -> Result { + let Some(authorization) = &credentials.authorization else { + return RequestContext::from_provider(None, &self.revision, BackendCredential::None) + .map_err(|_| AuthError::Unavailable); + }; + let (scheme, token) = authorization + .trim() + .split_once(' ') + .ok_or(AuthError::Unauthorized)?; + let token = token.trim(); + if !scheme.eq_ignore_ascii_case("bearer") || token.is_empty() { + return Err(AuthError::Unauthorized); + } + let session = self + .validator + .validate_and_map_async(token) + .await + .map_err(|_| AuthError::Unauthorized)?; + // Use only verified claims for the lease; decoding an unverified JWT is + // never sufficient to certify subject, scope or expiry. The second + // validation reads the same cached keys, without another network fetch. + let claims = self + .validator + .validate_token(token) + .map_err(|_| AuthError::Unauthorized)?; + let issuer = claims["iss"].as_str().ok_or(AuthError::Unauthorized)?; + let subject = claims["sub"].as_str().ok_or(AuthError::Unauthorized)?; + let expires = claims["exp"].as_u64().ok_or(AuthError::Unauthorized)?; + let identity = Identity::verified( + issuer, + subject, + session.roles().into_iter().map(str::to_owned).collect(), + expires, + ) + .map_err(|_| AuthError::Unauthorized)?; + RequestContext::from_provider( + Some(identity), + &self.revision, + BackendCredential::Bearer(token.into()), + ) + .map_err(|_| AuthError::Unavailable) + } +} diff --git a/src/graphql/identity/mod.rs b/src/graphql/identity/mod.rs index 8d023a9ac..a53e18485 100644 --- a/src/graphql/identity/mod.rs +++ b/src/graphql/identity/mod.rs @@ -31,3 +31,8 @@ pub fn session_from_all_headers(headers: &HeaderMap) -> Session { } Session::from_map(vars) } + +#[cfg(feature = "gateway")] +mod gateway; +#[cfg(feature = "gateway")] +pub use gateway::OidcGatewayProvider; diff --git a/src/graphql/identity/oidc.rs b/src/graphql/identity/oidc.rs index 284567a2e..54a2bb02a 100644 --- a/src/graphql/identity/oidc.rs +++ b/src/graphql/identity/oidc.rs @@ -123,6 +123,7 @@ pub struct VerifiedPrincipal { subject: String, audiences: Vec, tenant_partitions: Vec<(String, Value)>, + expires_at: Option, } impl VerifiedPrincipal { @@ -162,6 +163,7 @@ impl VerifiedPrincipal { }) .collect(), tenant_partitions: Vec::new(), + expires_at: None, } } @@ -175,6 +177,12 @@ impl VerifiedPrincipal { &self.subject } + /// Credential expiry used to bound admitted delivery work. Trusted local + /// principals without an expiring credential return None. + pub fn expires_at(&self) -> Option { + self.expires_at + } + pub(crate) fn subject_matches(&self, subject: &str) -> bool { self.subject == subject } @@ -736,6 +744,7 @@ fn verified_principal( subject, audiences, tenant_partitions, + expires_at: claims.get("exp").and_then(Value::as_u64), }) } diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 0710e8568..46565a410 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -1,4 +1,4 @@ -//! Auto-generated read-only GraphQL over relational read models. +//! GraphQL gateway for read-model queries, subscriptions, and core commands. //! //! `naming` and `sdl` always compile (zero deps beyond the rest of the crate) //! so `distributed schema --format graphql` works without enabling the `graphql` @@ -6,7 +6,6 @@ //! `feature = "graphql"`. pub mod client_manifest; -pub(crate) mod command_contract; pub mod naming; pub mod projection_delta; pub mod sdl; @@ -19,31 +18,8 @@ mod commands; mod complexity_contract; mod filter; mod permissions; -mod types; pub use client_manifest::*; -#[doc(hidden)] -pub use command_contract::{ - __command_input_defaults, __effect_key, __effect_key_assignment, __effect_key_field, - __effect_relationship, __input_default_ulid, __input_default_uuid_v7, CombineEffectNullability, - CompiledEffectKeyField, CompiledInputDefault, EffectInputDescendableKind, - EffectInputFieldMarker, EffectInputObjectKind, EffectInputPath, EffectInputPathKind, - EffectInputTerminalKind, EffectModelFieldMarker, EffectNullable, EffectPathNullability, - EffectRelationshipMarker, EffectRequired, EffectWireBigInt, EffectWireBoolean, EffectWireBytea, - EffectWireChecked, EffectWireCompatible, EffectWireFloat, EffectWireJson, EffectWireList, - EffectWireLiteral, EffectWireObject, EffectWireString, EffectWireTimestamp, - EffectWireUnsupported, -}; -pub use command_contract::{ - __command_projection_event_descriptor, __command_projection_event_preview, - __command_projection_events, __command_projection_preview_constant, - __command_projection_state_known_values, __command_projection_state_preview, - command_transition, typed_command, Atomic, CommandConsistency, CommandEventSet, CommandOutcome, - CommandProjectionEventSet, CommandProjectionPreview, CommandProjectionPreviewSource, - CommandProjectionPureArg, CommandProjectionPureReduce, CompiledDirectProjectionTarget, - CompiledInputDefaults, Eventual, PrepareCommandError, PreparedCommand, Succeeded, TypedCommand, - TypedEffectExpression, TypedEffectKey, TypedEffectRelationship, -}; 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, @@ -68,9 +44,7 @@ pub use permissions::{ read, role_grant_from_read_permission, role_grants_from_model_role_perms, ModelPermissions, ReadPermission, }; -pub use types::{GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField}; -pub(crate) mod command_input; #[cfg(feature = "graphql")] mod compile; #[cfg(feature = "graphql")] @@ -100,8 +74,8 @@ pub use engine::{ }; #[cfg(feature = "graphql")] pub use http::{ - graphiql_page, graphql_router, graphql_router_with_dispatcher, graphql_router_with_host, - graphql_router_with_service, + graphiql_page, graphql_router, graphql_router_composed, graphql_router_with_dispatcher, + graphql_router_with_host, graphql_router_with_service, GraphqlOperationFilter, }; #[cfg(feature = "graphql")] pub use identity::{ @@ -115,3 +89,9 @@ pub use identity::{ pub use read_store::{CellByKeyGetter, HttpCellByKey, MapCellByKey, ReadStore}; #[cfg(feature = "graphql")] pub use subscribe::ChangeHub; + +#[cfg(all(feature = "graphql", feature = "gateway-delivery"))] +pub use engine::ReadRouting; + +#[cfg(all(feature = "graphql", feature = "gateway-delivery"))] +pub mod delivery; diff --git a/src/graphql/naming.rs b/src/graphql/naming.rs index 39f07fa2f..834f7f1a1 100644 --- a/src/graphql/naming.rs +++ b/src/graphql/naming.rs @@ -88,16 +88,7 @@ pub fn max_fields_type_name(schema: &TableSchema) -> String { /// Map a column type to its GraphQL scalar type name (without nullability). pub fn scalar_type_name(column_type: &ColumnType) -> Option<&'static str> { - match column_type { - ColumnType::Text => Some("String"), - ColumnType::Boolean => Some("Boolean"), - ColumnType::Integer | ColumnType::UnsignedInteger => Some("BigInt"), - ColumnType::Float => Some("Float"), - ColumnType::Json => Some("JSON"), - ColumnType::Timestamp => Some("Timestamptz"), - ColumnType::Bytes => Some("Bytea"), - ColumnType::Unsupported(_) => None, - } + crate::command::scalar_type_name(column_type) } pub fn comparison_exp_name(scalar: &str) -> String { diff --git a/src/graphql/projection_delta/tests.rs b/src/graphql/projection_delta/tests.rs index fddf3dad9..132ed426b 100644 --- a/src/graphql/projection_delta/tests.rs +++ b/src/graphql/projection_delta/tests.rs @@ -17,6 +17,7 @@ use super::types::{ ProjectionMutationSource, }; use super::*; + use crate::graphql::{ build_surface, surface_for_role, DistributedClientSurfaceExport, RoleGrant, SurfaceDirectProjection, SurfaceOptions, SurfaceProjector, @@ -50,12 +51,12 @@ const FOREIGN_CAUSATION_ID: &str = "0190a000-0000-7000-8000-000000000018"; const TEST_COMMAND_NAME: &str = "todo.modeled"; #[allow(dead_code)] -#[derive(Deserialize, crate::GraphqlInput)] +#[derive(Deserialize, crate::CommandInput)] struct ModeledCommandInput { todo_id: String, } -#[derive(Serialize, crate::GraphqlOutput)] +#[derive(Serialize, crate::CommandOutput)] struct ModeledCommandOutput { accepted: bool, } @@ -687,7 +688,7 @@ fn zero_obligation_modeled_metadata_is_revalidated_on_every_receipt_emission() { command_id: "0190a000-0000-7000-8000-000000000011".into(), command_name: Some(TEST_COMMAND_NAME.into()), causation_id: Some(TEST_CAUSATION_ID.into()), - consistency: Some(crate::graphql::command_contract::CommandConsistency::Eventual), + consistency: Some(crate::command::CommandConsistency::Eventual), outcome: None, obligations: Vec::new(), projection_metadata: Some(metadata), @@ -702,7 +703,7 @@ fn zero_obligation_modeled_metadata_is_revalidated_on_every_receipt_emission() { command_id: "modeled-status-command".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Eventual, + consistency: crate::command::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::SucceededPendingProjection, outcome: serde_json::json!({"ok": true}), obligations: Vec::new(), @@ -880,7 +881,7 @@ fn zero_occurrence_metadata_is_classified_from_the_current_causal_command_contra command_id: "0190a000-0000-7000-8000-000000000012".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Eventual, + consistency: crate::command::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::Succeeded, outcome: serde_json::json!({"accepted": true}), obligations: Vec::new(), @@ -1424,7 +1425,7 @@ fn active_metadata_revalidates_but_remains_queryable_while_projection_is_drainin command_id: "0190a000-0000-7000-8000-000000000018".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Eventual, + consistency: crate::command::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::Atomic, outcome: serde_json::json!({"id": "todo-draining-status"}), obligations: Vec::new(), @@ -1485,7 +1486,7 @@ fn active_metadata_revalidates_but_remains_queryable_while_projection_is_drainin command_id: "0190a000-0000-7000-8000-000000000019".into(), command_name: Some(TEST_COMMAND_NAME.into()), causation_id: Some(TEST_CAUSATION_ID.into()), - consistency: Some(crate::graphql::command_contract::CommandConsistency::Eventual), + consistency: Some(crate::command::CommandConsistency::Eventual), outcome: None, obligations: Vec::new(), projection_metadata: Some(metadata.clone()), @@ -1523,7 +1524,7 @@ fn active_metadata_revalidates_but_remains_queryable_while_projection_is_drainin command_id: "0190a000-0000-7000-8000-000000000019".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Eventual, + consistency: crate::command::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::SucceededPendingProjection, outcome: serde_json::json!({"id": "todo-draining-status"}), obligations: Vec::new(), @@ -1591,7 +1592,7 @@ fn lifecycle_status_rejects_changed_deployment_identity_and_mixed_fanout_tamperi command_id: "0190a000-0000-7000-8000-000000000020".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Eventual, + consistency: crate::command::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::SucceededPendingProjection, outcome: serde_json::json!({"id": "todo-lifecycle-hostile"}), obligations: Vec::new(), @@ -3527,16 +3528,16 @@ fn selected_export_join(surface: &crate::graphql::Surface) -> DistributedClientS } fn surface_with_modeled_command(surface: &crate::graphql::Surface) -> crate::graphql::Surface { - let contract = crate::graphql::typed_command::< + let contract = crate::command::typed_command::< ModeledCommandInput, - crate::graphql::Eventual, + crate::command::Eventual, >(TEST_COMMAND_NAME) .roles(["delta-user"]) - .emits(crate::graphql::__command_projection_events([Ok( + .emits(crate::command::__command_projection_events([Ok( event_descriptor(), )])) .into_contract(); - let binding = crate::graphql::command_contract::TypedServiceCommandBinding::from_contracts( + let binding = crate::command::TypedServiceCommandBinding::from_contracts( "delta-service", std::slice::from_ref(&contract), ) @@ -3674,6 +3675,7 @@ fn todos() -> TableSchema { foreign_keys: vec![], indexes: vec![], relationships: vec![RelationshipDef { + references: None, field_name: "owner".into(), kind: RelationshipKind::BelongsTo, target_model: "UserView".into(), diff --git a/src/graphql/projection_delta/types.rs b/src/graphql/projection_delta/types.rs index 8af91baa0..c2d3b623b 100644 --- a/src/graphql/projection_delta/types.rs +++ b/src/graphql/projection_delta/types.rs @@ -99,7 +99,9 @@ pub struct ProjectionDeltaIdentity { #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ProjectionDeltaSurfaceIdentity { - Role { name: String }, + Role { + name: String, + }, Application { name: String, eligible_roles: Vec, @@ -461,13 +463,11 @@ impl From<&crate::graphql::client_manifest::ClientSurfaceIdentity> name, eligible_roles, schema_roles, - } => { - Self::Application { - name: name.clone(), - eligible_roles: eligible_roles.clone(), - schema_roles: schema_roles.clone(), - } - } + } => Self::Application { + name: name.clone(), + eligible_roles: eligible_roles.clone(), + schema_roles: schema_roles.clone(), + }, } } } diff --git a/src/graphql/protocol/accumulator.rs b/src/graphql/protocol/accumulator.rs index aff28148b..d258f9a3e 100644 --- a/src/graphql/protocol/accumulator.rs +++ b/src/graphql/protocol/accumulator.rs @@ -13,8 +13,8 @@ use super::{ OpaqueProtocolToken, ProtocolTokenCodec, ProtocolTokenError, ProtocolTokenPurpose, RequestedLiveResume, }; +use crate::command::CommandConsistency; use crate::command_ledger::CommandLedgerState; -use crate::graphql::command_contract::CommandConsistency; use crate::graphql::projection_delta::runtime::ModeledProjectionStatusDisposition; use crate::microsvc::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, @@ -95,6 +95,8 @@ struct ProtocolResponseState { stream_frames: Mutex>>, dispatch_claimed: Mutex, codec: ProtocolTokenCodec, + #[cfg(feature = "gateway-delivery")] + gateway: Mutex>, } impl ProtocolResponseAccumulator { @@ -108,6 +110,8 @@ impl ProtocolResponseAccumulator { stream_frames: Mutex::new(None), dispatch_claimed: Mutex::new(false), codec, + #[cfg(feature = "gateway-delivery")] + gateway: Mutex::new(None), }), } } @@ -974,6 +978,43 @@ impl ProtocolResponseAccumulator { .map_err(|_| ProtocolAccumulatorError::Poisoned) } + #[cfg(feature = "gateway-delivery")] + pub(crate) fn enable_gateway( + &self, + capture: crate::graphql::delivery::GatewayCapture, + ) -> Result<(), ProtocolAccumulatorError> { + *self + .inner + .gateway + .lock() + .map_err(|_| ProtocolAccumulatorError::Poisoned)? = Some(capture); + Ok(()) + } + #[cfg(feature = "gateway-delivery")] + pub(crate) fn gateway_enabled(&self) -> bool { + self.inner.gateway.lock().is_ok_and(|state| state.is_some()) + } + #[cfg(feature = "gateway-delivery")] + pub(crate) fn record_gateway_versions( + &self, + versions: &crate::graphql::delivery::VersionVector, + ) -> Result<(), String> { + let mut state = self + .inner + .gateway + .lock() + .map_err(|_| "gateway capture unavailable")?; + if let Some(state) = state.as_mut() { + state.validator = Some(crate::graphql::delivery::validator( + &self.inner.codec, + &state.identity, + &state.key, + versions, + )?); + } + Ok(()) + } + pub(crate) fn attach(&self, response: &mut Response) -> Result<(), ProtocolAccumulatorError> { if response.extensions.contains_key("distributed") { return Err(ProtocolAccumulatorError::ExtensionCollision); @@ -998,6 +1039,21 @@ impl ProtocolResponseAccumulator { serde_json::to_value(envelope).map_err(|_| ProtocolAccumulatorError::Encoding)?; let value = Value::from_json(json).map_err(|_| ProtocolAccumulatorError::Encoding)?; response.extensions.insert("distributed".into(), value); + #[cfg(feature = "gateway-delivery")] + if response.errors.is_empty() { + let state = self + .inner + .gateway + .lock() + .map_err(|_| ProtocolAccumulatorError::Poisoned)?; + if let Some(validator) = state.as_ref().and_then(|state| state.validator.as_ref()) { + response.extensions.insert( + "gatewayDelivery".into(), + Value::from_json(serde_json::json!({"validator":validator})) + .map_err(|_| ProtocolAccumulatorError::Encoding)?, + ); + } + } Ok(()) } } diff --git a/src/graphql/protocol/mod.rs b/src/graphql/protocol/mod.rs index d70b8325d..21d99c9e0 100644 --- a/src/graphql/protocol/mod.rs +++ b/src/graphql/protocol/mod.rs @@ -12,8 +12,8 @@ mod tests; mod token; mod types; -pub use accumulator::ProtocolResponseAccumulator; pub(crate) use accumulator::issue_projection_obligation_token; +pub use accumulator::ProtocolResponseAccumulator; pub(crate) use projection_metadata::{ CommandProjectionLifecycleProofV1, CommandProjectionMetadataError, CommandProjectionMetadataV1, CommandProjectionObligationV1, MAX_COMMAND_PROJECTION_OBLIGATIONS, diff --git a/src/graphql/protocol/tests.rs b/src/graphql/protocol/tests.rs index e2fc204b7..abef4469f 100644 --- a/src/graphql/protocol/tests.rs +++ b/src/graphql/protocol/tests.rs @@ -1,7 +1,7 @@ use super::accumulator::ProtocolAccumulatorError; use super::*; +use crate::command::CommandConsistency; use crate::command_ledger::CommandLedgerState; -use crate::graphql::command_contract::CommandConsistency; use crate::microsvc::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, CausalCommandReceiptSource, CausalProjectionEvidenceState, @@ -146,6 +146,7 @@ fn direct_projected_receipt() -> CausalCommandReceiptSource { receipt.obligations.clear(); receipt.direct_projection = Some(SameTransactionProjectionEvidence { records: vec![ProjectionRecordMetadata { + source_snapshot: None, revision: revision.clone(), tombstone: false, change: change.clone(), diff --git a/src/graphql/protocol/types.rs b/src/graphql/protocol/types.rs index 46e8c522b..64f4b7291 100644 --- a/src/graphql/protocol/types.rs +++ b/src/graphql/protocol/types.rs @@ -259,6 +259,17 @@ pub(crate) struct DistributedGenerationEnvelope { impl DistributedGenerationEnvelope { fn from_environment() -> Option { + if let Some(member) = crate::microsvc::lifecycle::membership_from_environment() { + let active = member.generation; + return Some(Self { + version: 1, + generation_id: active.generation_id, + release_id: active.release_id, + topology_id: Some(active.topology_id), + compatibility_id: Some(active.compatibility_id), + member_id: optional_environment_identity("DISTRIBUTED_MEMBER_ID"), + }); + } let generation_id = std::env::var("DISTRIBUTED_GENERATION_ID").ok()?; let release_id = std::env::var("DISTRIBUTED_RELEASE_ID").ok()?; if !bounded_identity(&generation_id) || !bounded_identity(&release_id) { diff --git a/src/graphql/query_protocol.rs b/src/graphql/query_protocol.rs index 3b4412a30..1ca252fd3 100644 --- a/src/graphql/query_protocol.rs +++ b/src/graphql/query_protocol.rs @@ -14,7 +14,6 @@ use sqlx::{Encode, Executor, IntoArguments, Type}; use super::compile::{ExtractedQueryEvidence, QueryResponsePathSegment, SqlPlan}; use super::engine::EngineInner; -#[cfg(any(feature = "sqlite", feature = "postgres"))] use super::engine::GraphqlPool; #[cfg(any(feature = "sqlite", feature = "postgres"))] use super::execute; @@ -535,6 +534,8 @@ struct PreparedLiveMetadata { #[cfg(any(feature = "sqlite", feature = "postgres"))] pub(crate) async fn execute_query_with_protocol( inner: &EngineInner, + pool: &GraphqlPool, + primary: bool, role_surface: Arc, accumulator: ProtocolResponseAccumulator, plan: &SqlPlan, @@ -566,10 +567,13 @@ pub(crate) async fn execute_query_with_protocol( // The snapshot helper accepts an HRTB closure whose returned future may // borrow only its connection. Keep every other input owned by the closure // so plan/runtime references cannot escape into that future. + let _ = primary; let plan = plan.clone(); let runtime = inner.query_protocol.clone(); let statement_timeout = inner.statement_timeout; - match &inner.pool { + #[cfg(feature = "gateway-delivery")] + let gateway_versions = inner.gateway_versions.clone(); + match pool { #[cfg(feature = "sqlite")] GraphqlPool::Sqlite(pool) => { let run = with_projection_read_snapshot(pool, move |connection| { @@ -578,11 +582,17 @@ pub(crate) async fn execute_query_with_protocol( let plan = plan.clone(); let runtime = runtime.clone(); let live_resume = live_resume.clone(); + #[cfg(feature = "gateway-delivery")] + let gateway_versions = gateway_versions.clone(); Box::pin(async move { + #[cfg(feature = "gateway-delivery")] + if let Some(store) = &gateway_versions { + store.record_result(); + } let executed = execute::execute_sqlite_in_connection(connection, &plan) .await .map_err(query_execution_error)?; - finish_protocol_query::( + let result = finish_protocol_query::( connection, &runtime, &role_surface, @@ -591,7 +601,22 @@ pub(crate) async fn execute_query_with_protocol( executed, live_resume, ) - .await + .await?; + #[cfg(feature = "gateway-delivery")] + if accumulator.gateway_enabled() { + if let Some(store) = &gateway_versions { + if store.covers(&plan.tables_touched) { + let versions = store + .sqlite(connection, &plan.tables_touched) + .await + .map_err(query_execution_error)?; + accumulator + .record_gateway_versions(&versions) + .map_err(query_execution_error)?; + } + } + } + Ok(result) }) }); execute::apply_statement_timeout(statement_timeout, async { @@ -607,6 +632,8 @@ pub(crate) async fn execute_query_with_protocol( let plan = plan.clone(); let runtime = runtime.clone(); let live_resume = live_resume.clone(); + #[cfg(feature = "gateway-delivery")] + let gateway_versions = gateway_versions.clone(); Box::pin(async move { let timeout_ms = i64::try_from(statement_timeout.as_millis()).unwrap_or(i64::MAX); @@ -618,10 +645,17 @@ pub(crate) async fn execute_query_with_protocol( .map_err(|error| { query_execution_error(format!("statement_timeout: {error}")) })?; + execute::ensure_primary_backend(connection, primary) + .await + .map_err(query_execution_error)?; + #[cfg(feature = "gateway-delivery")] + if let Some(store) = &gateway_versions { + store.record_result(); + } let executed = execute::execute_postgres_in_connection(connection, &plan) .await .map_err(query_execution_error)?; - finish_protocol_query::( + let result = finish_protocol_query::( connection, &runtime, &role_surface, @@ -630,7 +664,22 @@ pub(crate) async fn execute_query_with_protocol( executed, live_resume, ) - .await + .await?; + #[cfg(feature = "gateway-delivery")] + if accumulator.gateway_enabled() { + if let Some(store) = &gateway_versions { + if store.covers(&plan.tables_touched) { + let versions = store + .postgres(connection, &plan.tables_touched) + .await + .map_err(query_execution_error)?; + accumulator + .record_gateway_versions(&versions) + .map_err(query_execution_error)?; + } + } + } + Ok(result) }) }); execute::apply_statement_timeout(statement_timeout, async { @@ -646,6 +695,8 @@ pub(crate) async fn execute_query_with_protocol( #[cfg(not(any(feature = "sqlite", feature = "postgres")))] pub(crate) async fn execute_query_with_protocol( _inner: &EngineInner, + _pool: &GraphqlPool, + _primary: bool, _role_surface: Arc, _accumulator: ProtocolResponseAccumulator, _plan: &SqlPlan, diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index 6e012a4f2..090bbd2e2 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -270,6 +270,8 @@ pub fn build_role_schema( .unwrap_or_else(Session::new); let authority = ctx.data_opt::().cloned(); let protocol = ctx.data_opt::().cloned(); + #[cfg(feature = "gateway-delivery")] + let captured = ctx.data_opt::().cloned(); let selection = compile::selection_from_field(ctx.field()); SubscriptionFieldFuture::new(async move { let inner = inner.ok_or_else(|| { @@ -280,6 +282,31 @@ pub fn build_role_schema( &session, &inner.anonymous_role, ); + #[cfg(feature = "gateway-delivery")] + if let Some(captured) = captured { + let plan = compile::compile_query( + &inner, + &session, + &role, + &model, + compile::RootKind::List, + &selection, + ) + .map_err(async_graphql::Error::new)?; + if let compile::QueryPlan::Sql(plan) = plan { + captured + .0 + .lock() + .map_err(|_| { + async_graphql::Error::new("plan capture unavailable") + })? + .push(plan); + return Err(async_graphql::Error::new(super::delivery::CAPTURED)); + } + return Err(async_graphql::Error::new( + "query is ineligible for delivery reuse", + )); + } let stream = super::subscribe::live_query_stream( inner, session, role, model, selection, protocol, ) @@ -756,6 +783,20 @@ async fn resolve_root( let selection = compile::selection_from_field(ctx.field()); let plan = compile::compile_query(&inner, &session, &role, model, kind, &selection) .map_err(|e| client_error("BAD_REQUEST", sanitize_compile_error(&e)))?; + #[cfg(feature = "gateway-delivery")] + if let Some(captured) = ctx.data_opt::() { + if let QueryPlan::Sql(plan) = plan { + let mut plans = captured + .0 + .lock() + .map_err(|_| client_error("INTERNAL", "plan capture unavailable"))?; + plans.push(plan); + return Err(async_graphql::Error::new(super::delivery::CAPTURED)); + } + return Err(async_graphql::Error::new( + "query is ineligible for delivery reuse", + )); + } let value = match plan { QueryPlan::CellByKey { model, @@ -765,12 +806,19 @@ async fn resolve_root( .await .map_err(|_| client_error("INTERNAL", "cell read dependency failed"))?, QueryPlan::Sql(plan) => { + let (pool, primary) = (&inner.pool, true); + #[cfg(feature = "gateway-delivery")] + let (pool, primary) = ctx + .data_opt::() + .map_or((pool, primary), |read| read.pool(&inner, &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, + pool, + primary, role_surface, protocol.clone(), &plan, @@ -783,7 +831,7 @@ async fn resolve_root( .map_err(|_| client_error("INTERNAL", "query evidence encoding failed"))?; executed.value } else { - super::engine::execute_plan(&inner, &plan) + super::execute::execute_sql_on_pool(&inner, pool, primary, &plan) .await .map_err(|e| client_error_for_execute_err(&e))? } @@ -1139,8 +1187,8 @@ mod causal_command_schema_tests { use std::sync::Arc; use super::*; + use crate::command::{CommandConsistency, CommandEffects}; use crate::command_dispatch::{LocalCommandHost, SharedCommandHost}; - use crate::graphql::command_contract::{CommandConsistency, CommandEffects}; use crate::graphql::protocol::{ DistributedEnvelopeV1, ProtocolResponseAccumulator, ProtocolTokenCodec, ProtocolTokenPurpose, diff --git a/src/graphql/sdl.rs b/src/graphql/sdl.rs index 1721edef2..8af2e41fd 100644 --- a/src/graphql/sdl.rs +++ b/src/graphql/sdl.rs @@ -588,7 +588,7 @@ pub fn graphql_sdl_from_schemas( #[cfg(test)] mod causal_command_sdl_tests { use super::*; - use crate::graphql::command_contract::{CommandConsistency, CommandEffects}; + use crate::command::{CommandConsistency, CommandEffects}; use crate::graphql::surface::{SurfaceCommandShape, SurfaceTypeDef}; fn command_surface() -> crate::graphql::surface::Surface { diff --git a/src/graphql/subscribe.rs b/src/graphql/subscribe.rs index 4661084ab..e87a71b20 100644 --- a/src/graphql/subscribe.rs +++ b/src/graphql/subscribe.rs @@ -35,6 +35,11 @@ impl ChangeHub { Self { tx } } + /// Active origin live readers, excluding the external invalidation forwarder. + pub fn subscriber_count(&self) -> usize { + self.tx.receiver_count() + } + pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } @@ -126,15 +131,11 @@ pub(crate) async fn live_query_stream( tokio::spawn(async move { // 1) Initial execution + yield - let mut initial = match execute_list( - &inner, - &role, - &plan, - protocol.as_ref(), - requested_live_resume, - ) - .await - { + let initial_result = tokio::select! { + _ = tx.closed() => return, + result = execute_list(&inner, &role, &plan, protocol.as_ref(), requested_live_resume) => result, + }; + let mut initial = match initial_result { Ok(executed) => executed, Err(e) => { let _ = tx.send(Err(async_graphql::Error::new(e))).await; @@ -153,7 +154,10 @@ pub(crate) async fn live_query_stream( // 2) Change loop: dirty → debounce → re-exec → hash-gate → yield loop { - let change = match change_rx.recv().await { + let change = match tokio::select! { + _ = tx.closed() => break, + change = change_rx.recv() => change, + } { Ok(c) => c, Err(broadcast::error::RecvError::Lagged(_)) => ReadModelChange { tables: BTreeSet::new(), @@ -166,7 +170,10 @@ pub(crate) async fn live_query_stream( } // Debounce / coalesce - tokio::time::sleep(debounce).await; + tokio::select! { + _ = tx.closed() => break, + _ = tokio::time::sleep(debounce) => {}, + } loop { match change_rx.try_recv() { Ok(more) => { @@ -179,15 +186,11 @@ pub(crate) async fn live_query_stream( } } - match execute_list( - &inner, - &role, - &plan, - protocol.as_ref(), - next_live_resume.clone(), - ) - .await - { + let refreshed = tokio::select! { + _ = tx.closed() => break, + result = execute_list(&inner, &role, &plan, protocol.as_ref(), next_live_resume.clone()) => result, + }; + match refreshed { Ok(mut executed) => { // Advance the private replay cursor even when a redundant // execution is hash-gated. Protocol frame metadata is @@ -275,6 +278,8 @@ async fn execute_list( .ok_or_else(|| "authorized GraphQL role surface is unavailable".to_string())?; let executed = super::query_protocol::execute_query_with_protocol( inner, + &inner.pool, + true, role_surface, protocol.clone(), plan, diff --git a/src/graphql/surface/mod.rs b/src/graphql/surface/mod.rs index 63a0e94d0..2bc19501a 100644 --- a/src/graphql/surface/mod.rs +++ b/src/graphql/surface/mod.rs @@ -12,14 +12,14 @@ use std::ops::Deref; use sha2::{Digest, Sha256}; -use super::command_contract::{ +use super::filter::{validate_row_policy_operand_literal, FilterExpr, Operand}; +use crate::command::{ compiled_direct_projection_target, validate_projection_confirmation_count, CommandConsistency, CommandDirectProjectionTarget, CommandEffect, CommandEffects, CommandInputDefault, CommandProjectedModel, CommandProjectionConfirmation, CommandProjectionEvents, CommandProjectionPreviewSource, CompiledDirectProjectionTarget, EffectExpression, EffectFieldValue, EffectKey, EffectRelationship, }; -use super::filter::{validate_row_policy_operand_literal, FilterExpr, Operand}; use crate::projection_protocol::ProjectionModelOwnership; use crate::projection_protocol::ProjectionPartitionSpec; use crate::table::{ diff --git a/src/graphql/surface/projections.rs b/src/graphql/surface/projections.rs index b1b53fd0e..0e2e96577 100644 --- a/src/graphql/surface/projections.rs +++ b/src/graphql/surface/projections.rs @@ -1075,6 +1075,7 @@ mod tests { "todos", &["todo_id", "owner_id", "title"], vec![RelationshipDef { + references: None, field_name: "owner".into(), kind: RelationshipKind::BelongsTo, target_model: "UserView".into(), diff --git a/src/graphql/surface/tests.rs b/src/graphql/surface/tests.rs index 4d2bc2ab8..5506b20c8 100644 --- a/src/graphql/surface/tests.rs +++ b/src/graphql/surface/tests.rs @@ -1,9 +1,10 @@ use std::any::TypeId; use super::*; -use crate::graphql::command_contract::{CommandEffects, TypedCommandContract}; +use crate::command::{CommandEffects, TypedCommandContract}; use crate::graphql::commands::TypedCommandInventory; -use crate::graphql::{GraphqlTypeDef, GraphqlTypeField}; + +use crate::command::{CommandTypeDef, CommandTypeField}; use crate::table::{ ColumnType, PrimaryKey, RelationshipDef, RelationshipKind, TableColumn, TableKind, }; @@ -224,7 +225,7 @@ fn modeled_direct_projection( fn test_command( command_name: &str, field_name: &str, - output: GraphqlTypeDef, + output: CommandTypeDef, ) -> TypedCommandContract { let input_type_id = TypeId::of::(); let output_type_id = TypeId::of::<()>(); @@ -232,9 +233,9 @@ fn test_command( name: command_name.into(), field_name: field_name.into(), roles: Vec::new(), - input: GraphqlTypeDef::new( + input: CommandTypeDef::new( "TestCommandInput", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -266,9 +267,9 @@ fn test_inventory( #[test] fn causal_surface_commands_accept_modeled_event_selectors_but_not_empty_authority() { let output = || { - GraphqlTypeDef::new( + CommandTypeDef::new( "CausalPayload", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -779,8 +780,14 @@ fn selected_surfaces_reject_command_and_projector_reattachment() { .contains("before authorization selection")); let grants_by_role = BTreeMap::from([("user".into(), grants)]); - let application = - surface_for_application(&full, "web", &["user".into()], &["user".into()], &grants_by_role).unwrap(); + let application = surface_for_application( + &full, + "web", + &["user".into()], + &["user".into()], + &grants_by_role, + ) + .unwrap(); assert!(application .clone() .with_typed_commands(&TypedCommandInventory::empty()) @@ -825,9 +832,9 @@ fn role_policy_rejects_non_finite_and_hides_js_unsafe_integers() { #[test] fn command_surface_rejects_duplicate_mutation_field_ids() { - let output = GraphqlTypeDef::new( + let output = CommandTypeDef::new( "TestCommandPayload", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -852,7 +859,7 @@ fn command_surface_rejects_empty_nested_and_surface_colliding_types() { let empty = test_inventory([test_command( "order.empty", "order_empty", - GraphqlTypeDef::new("EmptyPayload", Vec::new()), + CommandTypeDef::new("EmptyPayload", Vec::new()), )]); let error = build_surface(&[orders()], &SurfaceOptions::sqlite()) .unwrap() @@ -863,15 +870,15 @@ fn command_surface_rejects_empty_nested_and_surface_colliding_types() { let nested = test_inventory([test_command( "order.nested_empty", "order_nested_empty", - GraphqlTypeDef::new( + CommandTypeDef::new( "OuterPayload", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "inner".into(), type_name: "InnerPayload".into(), nullable: false, list: false, item_nullable: false, - nested: Some(Box::new(GraphqlTypeDef::new("InnerPayload", Vec::new()))), + nested: Some(Box::new(CommandTypeDef::new("InnerPayload", Vec::new()))), }], ), )]); @@ -884,9 +891,9 @@ fn command_surface_rejects_empty_nested_and_surface_colliding_types() { let collision = test_inventory([test_command( "order.collision", "order_collision", - GraphqlTypeDef::new( + CommandTypeDef::new( "OrderView", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "order_id".into(), type_name: "String".into(), nullable: false, @@ -1303,6 +1310,7 @@ fn relationship_only_when_target_on_surface() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), @@ -1381,6 +1389,7 @@ fn surface_rejects_relationship_and_generated_aggregate_field_collisions() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "CollisionChild".into(), @@ -1425,6 +1434,7 @@ fn relationship_keys_canonicalize_rust_field_names_to_graphql_columns() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "account".into(), kind: RelationshipKind::BelongsTo, target_model: "AccountView".into(), @@ -1482,6 +1492,7 @@ fn pool_free_surface_rejects_a_partial_composite_belongs_to_key() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "composite".into(), kind: RelationshipKind::BelongsTo, target_model: "CompositeView".into(), @@ -1518,6 +1529,7 @@ fn row_policy_rejects_a_partial_composite_m2m_mapping() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "labels".into(), kind: RelationshipKind::ManyToMany, target_model: "OperationalLabel".into(), @@ -1622,6 +1634,7 @@ fn belongs_to_onto_composite_identity_is_selected_when_keys_are_paired() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "composite".into(), kind: RelationshipKind::BelongsTo, target_model: "CompositeView".into(), @@ -1673,6 +1686,7 @@ fn has_many_onto_composite_child_is_selected_on_the_parent() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "projects".into(), kind: RelationshipKind::HasMany, target_model: "ProjectView".into(), @@ -1748,6 +1762,7 @@ fn has_many_from_composite_parent_is_selected() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "files".into(), kind: RelationshipKind::HasMany, target_model: "ProjectFileView".into(), diff --git a/src/graphql/surface/types.rs b/src/graphql/surface/types.rs index 6b485d14f..628622cae 100644 --- a/src/graphql/surface/types.rs +++ b/src/graphql/surface/types.rs @@ -540,8 +540,7 @@ pub struct Surface { pub(crate) projectors_attached: bool, /// Non-serializable provenance proving typed commands came from one /// executable Service inventory rather than a lookalike command list. - pub(crate) service_binding: - Option, + pub(crate) service_binding: Option, } /// Debug output is intentionally limited to already-authorized public IDs. @@ -564,7 +563,9 @@ impl std::fmt::Debug for Surface { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum SurfaceSelection { Catalog, - Role { name: String }, + Role { + name: String, + }, Application { name: String, eligible_roles: Vec, @@ -640,7 +641,8 @@ impl Surface { }) }) .collect::>(); - relationships.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); + relationships + .sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); serde_json::json!({ "model_name": model.model_name, "table_name": model.table_name, @@ -658,7 +660,11 @@ impl Surface { .query_fields .iter() .map(|root| root_value("query", root)) - .chain(self.subscription_fields.iter().map(|root| root_value("subscription", root))) + .chain( + self.subscription_fields + .iter() + .map(|root| root_value("subscription", root)), + ) .collect::>(); roots.sort_by(|left, right| { (left["operation"].as_str(), left["name"].as_str()) @@ -685,7 +691,11 @@ impl Surface { }) }) .collect::>(); - commands.sort_by(|left, right| left["command_name"].as_str().cmp(&right["command_name"].as_str())); + commands.sort_by(|left, right| { + left["command_name"] + .as_str() + .cmp(&right["command_name"].as_str()) + }); let mut projectors = self .projectors .iter() @@ -805,10 +815,7 @@ impl Surface { /// Bind several explicit logical modules before role/application /// authorization selection. - pub fn with_modules<'a, I>( - mut self, - modules: I, - ) -> Result + pub fn with_modules<'a, I>(mut self, modules: I) -> Result where I: IntoIterator, { @@ -825,7 +832,8 @@ impl Surface { for module in modules { contracts.extend(module.typed_command_contracts()?); } - let inventory = crate::graphql::commands::TypedCommandInventory::from_contracts(&contracts)?; + let inventory = + crate::graphql::commands::TypedCommandInventory::from_contracts(&contracts)?; self = self.with_typed_commands(&inventory)?; Ok(self) } @@ -856,7 +864,7 @@ impl Surface { #[cfg(any(test, feature = "graphql"))] pub(crate) fn with_service_binding( mut self, - binding: Option, + binding: Option, ) -> Self { self.service_binding = binding; self diff --git a/src/in_memory_repo/projection_protocol/direct_projection.rs b/src/in_memory_repo/projection_protocol/direct_projection.rs index d41f32e58..da90e0e8c 100644 --- a/src/in_memory_repo/projection_protocol/direct_projection.rs +++ b/src/in_memory_repo/projection_protocol/direct_projection.rs @@ -31,6 +31,10 @@ pub(in crate::in_memory_repo) fn stage_same_transaction_projection( protocol.register_same_transaction_ownership(&partition_key, batch)?; let mutation = &batch.mutations[0]; + crate::projection_protocol::validate_snapshot_write( + protocol.records.get(&mutation.scope), + None, + )?; let lock_key = mutation.mutation.lock_key(); let row_exists = staged_rows.contains_key(&lock_key); let revision = match protocol.records.get(&mutation.scope) { @@ -69,6 +73,7 @@ pub(in crate::in_memory_repo) fn stage_same_transaction_projection( }, )?; let metadata = ProjectionRecordMetadata { + source_snapshot: None, revision: revision.clone(), tombstone: false, change: change.cursor.clone(), diff --git a/src/in_memory_repo/projection_protocol/mod.rs b/src/in_memory_repo/projection_protocol/mod.rs index d4eb1c95b..a1a8c04cd 100644 --- a/src/in_memory_repo/projection_protocol/mod.rs +++ b/src/in_memory_repo/projection_protocol/mod.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use super::InMemoryRepository; use crate::projection_protocol::{ change_kind_for_mutation, checked_next, checked_projection_graph_materialization, - failure_matches_batch, projection_has_many_columns, table_model_name, + failure_matches_batch, projection_relationship_values, table_model_name, validate_projection_graph_snapshot_request, ProjectionCausationEvidenceBatch, ProjectionCausationEvidenceRequest, ProjectionChange, ProjectionChangeCursor, ProjectionChangeKind, ProjectionChangeRead, ProjectionChangeRetention, ProjectionCheckpoint, @@ -36,12 +36,13 @@ use crate::read_model::in_memory::{ }; use crate::repository::RepositoryError; use crate::table::{ - column_name_for, key_from_row, RelationshipKind, RowKey, RowValues, TableMutation, TableSchema, - TableStoreError, TableWritePlan, + key_from_row, RowKey, RowValues, TableMutation, TableSchema, TableStoreError, TableWritePlan, }; mod direct_projection; mod read_helpers; +#[cfg(feature = "graphql")] +mod rebuild; mod state; mod state_impl; mod store_impl; diff --git a/src/in_memory_repo/projection_protocol/read_helpers.rs b/src/in_memory_repo/projection_protocol/read_helpers.rs index e95f184e6..c1fb0339d 100644 --- a/src/in_memory_repo/projection_protocol/read_helpers.rs +++ b/src/in_memory_repo/projection_protocol/read_helpers.rs @@ -122,60 +122,23 @@ fn relationship_keys_from_state( target_schema: &TableSchema, max_unique: usize, ) -> Result, ProjectionProtocolError> { - let foreign_key = relationship.foreign_key.as_deref().ok_or_else(|| { - ProjectionProtocolError::InvalidBatch(format!( - "projection graph relationship `{}` has no foreign key", - relationship.field_name - )) - })?; - let (target_column, value) = match relationship.kind { - RelationshipKind::HasMany => { - let (target_column, root_column) = - projection_has_many_columns(root_schema, relationship, target_schema)?; - let value = root_row.get(&root_column).cloned().ok_or_else(|| { - ProjectionProtocolError::InvalidBatch(format!( - "projection graph root `{}` is missing relationship key `{root_column}`", - root_schema.model_name - )) - })?; - (target_column, value) - } - RelationshipKind::BelongsTo => { - let source_column = column_name_for(root_schema, foreign_key).ok_or_else(|| { - ProjectionProtocolError::InvalidBatch(format!( - "projection graph relationship `{}` foreign key `{foreign_key}` is not a source column", - relationship.field_name - )) - })?; - let [target_column] = target_schema.primary_key.columns.as_slice() else { - return Err(ProjectionProtocolError::InvalidBatch(format!( - "projection graph belongs-to target `{}` must have one primary-key column", - target_schema.model_name - ))); - }; - let value = root_row.get(&source_column).cloned().ok_or_else(|| { - ProjectionProtocolError::InvalidBatch(format!( - "projection graph root `{}` is missing relationship key `{source_column}`", - root_schema.model_name - )) - })?; - (target_column.clone(), value) - } - RelationshipKind::ManyToMany => { - return Err(ProjectionProtocolError::InvalidBatch(format!( - "projection graph relationship `{}` is many-to-many; project an explicit join read model instead", - relationship.field_name - ))); - } - }; - if value == crate::table::RowValue::Null { + let values = + projection_relationship_values(root_schema, root_row, relationship, target_schema)?; + if values + .iter() + .any(|(_, value)| *value == crate::table::RowValue::Null) + { return Ok(Vec::new()); } let prefix = format!("{}:", target_schema.table_name); let mut keys = Vec::new(); for (storage_key, stored) in rows { - if storage_key.starts_with(&prefix) && stored.values.get(&target_column) == Some(&value) { + if storage_key.starts_with(&prefix) + && values + .iter() + .all(|(column, value)| stored.values.get(column) == Some(value)) + { if keys.len() == max_unique { return Err(graph_budget_error_from_parts( root_schema, diff --git a/src/in_memory_repo/projection_protocol/rebuild.rs b/src/in_memory_repo/projection_protocol/rebuild.rs new file mode 100644 index 000000000..cde839eab --- /dev/null +++ b/src/in_memory_repo/projection_protocol/rebuild.rs @@ -0,0 +1,111 @@ +use super::*; +use crate::projection::rebuild::{ + invalid, RebuildContext, SnapshotProjectionRebuildPlan, MAX_REBUILD_RECORDS, +}; + +fn inventory( + protocol: &InMemoryProjectionProtocolState, + context: &RebuildContext, +) -> Result, ProjectionProtocolError> { + protocol.require_registered_topology(context.compiled.topology())?; + let partition = context.partition()?; + let mut records = Vec::new(); + for (scope, record) in &protocol.records { + if scope.topology() == context.compiled.topology() { + if scope.projection_partition() != &partition || record.change.epoch() != &context.epoch + { + return Err(invalid( + "snapshot rebuild requires the registered unit partition and epoch", + )); + } + records.push(record.clone()); + if records.len() > MAX_REBUILD_RECORDS { + return Err(invalid("snapshot rebuild exceeds 10000 records")); + } + } + } + Ok(records) +} + +impl InMemoryRepository { + pub(super) async fn snapshot_rebuild_records( + &self, + context: &RebuildContext, + ) -> Result, ProjectionProtocolError> { + let protocol = self + .projection_protocol + .read() + .map_err(|_| invalid("projection rebuild inventory lock poisoned"))?; + inventory(&protocol, context) + } + + pub(super) async fn apply_snapshot_rebuild( + &self, + plan: SnapshotProjectionRebuildPlan, + ) -> Result { + // Same lock order as ordinary commits. Stage both stores before publication. + let mut rows = self + .model_store + .relational_rows + .write() + .map_err(|_| invalid("projection rebuild rows lock poisoned"))?; + let mut protocol = self + .projection_protocol + .write() + .map_err(|_| invalid("projection rebuild protocol lock poisoned"))?; + plan.verify_inventory(&inventory(&protocol, &plan.context)?)?; + let mut staged = protocol.clone(); + let mut staged_rows = rows.clone(); + let partition = + PartitionKey::new(plan.context.compiled.topology(), &plan.context.partition()?); + staged.ensure_partition(&partition, &plan.context.epoch)?; + for owner in plan.context.compiled.ownership() { + let registered = RegisteredModelKey { + topology: partition.topology.clone(), + model: owner.model.clone(), + }; + if staged.registered_models.get(®istered) != Some(&owner.table) + || staged.authoritative_table_owners.get(&owner.table) != Some(®istered) + { + return Err(invalid("snapshot rebuild does not own the target table")); + } + staged.ownership.insert( + OwnershipKey { + partition: partition.clone(), + model: owner.model.clone(), + }, + owner.table.clone(), + ); + } + for row in &plan.rows { + let current = staged.records.get(&row.scope); + row.verify_physical(current, staged_rows.contains_key(&row.mutation.lock_key()))?; + let (kind, expectation) = row.transition(current); + let (revision, tombstone) = staged.next_record(&row.scope, &expectation, kind, true)?; + let change = staged.append_change( + &partition, + PendingChange { + kind: change_kind_for_mutation(kind), + causation_id: "distributed:snapshot-rebuild".into(), + observation_kind: None, + scope: Some(row.scope.clone()), + revision: Some(revision.clone()), + failure_id: None, + }, + )?; + let record = ProjectionRecordMetadata { + revision, + tombstone, + change: change.cursor, + source_snapshot: Some(row.source.clone()), + }; + staged.ensure_live_record_identity_available(&record)?; + staged.records.insert(row.scope.clone(), record); + } + apply_read_model_write_plan(plan.write_plan(), &mut staged_rows)?; + staged.retain_change_suffix(&partition, self.projection_change_retention)?; + *rows = staged_rows; + *protocol = staged; + Ok(plan.rows.len()) + } +} diff --git a/src/in_memory_repo/projection_protocol/state_impl.rs b/src/in_memory_repo/projection_protocol/state_impl.rs index 3161b9bef..fbd37abcf 100644 --- a/src/in_memory_repo/projection_protocol/state_impl.rs +++ b/src/in_memory_repo/projection_protocol/state_impl.rs @@ -537,12 +537,16 @@ impl InMemoryProjectionProtocolState { scope: &ProjectionRecordScope, expectation: &ProjectionRecordExpectation, kind: ProjectionMutationKind, + source_snapshot: bool, ) -> Result<(RecordRevision, bool), ProjectionProtocolError> { let current = self.records.get(scope); match (expectation, current, kind) { (ProjectionRecordExpectation::Missing, None, ProjectionMutationKind::Upsert) => { Ok((RecordRevision::new(scope.clone(), 1, 1)?, false)) } + (ProjectionRecordExpectation::Missing, None, ProjectionMutationKind::Delete) => { + Ok((RecordRevision::new(scope.clone(), 1, 1)?, true)) + } (ProjectionRecordExpectation::Missing, Some(metadata), _) if metadata.tombstone => { Err(ProjectionProtocolError::RecordTombstoned { model: scope.model().to_string(), @@ -582,7 +586,7 @@ impl InMemoryProjectionProtocolState { )?, false, )), - ProjectionMutationKind::Delete if metadata.tombstone => { + ProjectionMutationKind::Delete if metadata.tombstone && !source_snapshot => { Err(ProjectionProtocolError::RecordTombstoned { model: scope.model().to_string(), }) @@ -610,11 +614,9 @@ impl InMemoryProjectionProtocolState { )), } } - (_, _, ProjectionMutationKind::Delete | ProjectionMutationKind::Recreate) => { - Err(ProjectionProtocolError::InvalidBatch( - "delete/recreate requires an exact record expectation".into(), - )) - } + (_, _, ProjectionMutationKind::Recreate) => Err(ProjectionProtocolError::InvalidBatch( + "delete/recreate requires an exact record expectation".into(), + )), } } @@ -626,12 +628,16 @@ impl InMemoryProjectionProtocolState { row_exists: bool, ) -> Result<(), ProjectionProtocolError> { let should_exist = match (expectation, kind) { - (ProjectionRecordExpectation::Missing, ProjectionMutationKind::Upsert) => false, - (ProjectionRecordExpectation::Exact(_), ProjectionMutationKind::Recreate) => false, ( - ProjectionRecordExpectation::Exact(_), + ProjectionRecordExpectation::Missing, ProjectionMutationKind::Upsert | ProjectionMutationKind::Delete, - ) => true, + ) => false, + (ProjectionRecordExpectation::Exact(_), ProjectionMutationKind::Recreate) => false, + (ProjectionRecordExpectation::Exact(_), ProjectionMutationKind::Delete) => !self + .records + .get(scope) + .is_some_and(|record| record.tombstone), + (ProjectionRecordExpectation::Exact(_), ProjectionMutationKind::Upsert) => true, _ => { return Err(ProjectionProtocolError::InvalidBatch( "projection mutation has no valid physical-row expectation".into(), diff --git a/src/in_memory_repo/projection_protocol/store_impl.rs b/src/in_memory_repo/projection_protocol/store_impl.rs index 3dd70847d..655ef9bff 100644 --- a/src/in_memory_repo/projection_protocol/store_impl.rs +++ b/src/in_memory_repo/projection_protocol/store_impl.rs @@ -1,6 +1,22 @@ use super::*; impl ProjectionProtocolStore for InMemoryRepository { + #[cfg(feature = "graphql")] + async fn projection_rebuild_records( + &self, + context: &crate::projection::rebuild::RebuildContext, + ) -> Result, ProjectionProtocolError> { + self.snapshot_rebuild_records(context).await + } + + #[cfg(feature = "graphql")] + async fn commit_projection_rebuild( + &self, + plan: crate::projection::rebuild::SnapshotProjectionRebuildPlan, + ) -> Result { + self.apply_snapshot_rebuild(plan).await + } + fn register_projection_models<'a>( &'a self, topology: &'a ProjectorTopologyId, @@ -179,6 +195,11 @@ impl ProjectionProtocolStore for InMemoryRepository { &mutation.scope, &mutation.expectation, mutation.kind, + mutation.source_snapshot.is_some(), + )?; + crate::projection_protocol::validate_snapshot_write( + staged_protocol.records.get(&mutation.scope), + mutation.source_snapshot.as_ref(), )?; staged_protocol.validate_physical_record( &mutation.scope, @@ -201,6 +222,7 @@ impl ProjectionProtocolStore for InMemoryRepository { revision, tombstone, change: change.cursor.clone(), + source_snapshot: mutation.source_snapshot.clone(), }; staged_protocol.ensure_live_record_identity_available(&metadata)?; staged_protocol diff --git a/src/in_memory_repo/projection_protocol/tests.rs b/src/in_memory_repo/projection_protocol/tests.rs index 5546a105e..e2f28c16b 100644 --- a/src/in_memory_repo/projection_protocol/tests.rs +++ b/src/in_memory_repo/projection_protocol/tests.rs @@ -116,6 +116,7 @@ fn graph_parent_schema() -> &'static TableSchema { indexes: Vec::new(), relationships: vec![ RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "GraphChildView".into(), @@ -124,6 +125,7 @@ fn graph_parent_schema() -> &'static TableSchema { target_foreign_key: None, }, RelationshipDef { + references: None, field_name: "featured_children".into(), kind: RelationshipKind::HasMany, target_model: "GraphChildView".into(), @@ -255,6 +257,146 @@ fn graph_snapshot_request(max_unique: usize) -> ProjectionGraphSnapshotRequest { .unwrap() } +#[tokio::test] +async fn graph_snapshots_follow_composite_candidate_keys_and_keep_pk_scopes() { + let mut parent = graph_parent_schema().clone(); + parent.relationships.truncate(1); + parent.relationships[0].foreign_key = Some("tenant,key".into()); + parent.relationships[0].references = Some("tenant,key".into()); + let mut child = graph_child_schema().clone(); + child.relationships.push(RelationshipDef { + field_name: "parent".into(), + kind: RelationshipKind::BelongsTo, + target_model: parent.model_name.clone(), + foreign_key: Some("tenant,key".into()), + references: Some("tenant,key".into()), + through: None, + target_foreign_key: None, + }); + for schema in [&mut parent, &mut child] { + schema + .columns + .push(TableColumn::new("tenant", "tenant", ColumnType::Text)); + schema.columns.push(TableColumn { + nullable: true, + ..TableColumn::new("key", "key", ColumnType::Text) + }); + } + parent.indexes.push(crate::table::TableIndex { + name: None, + columns: vec!["tenant".into(), "key".into()], + unique: true, + }); + let parent: &'static TableSchema = Box::leak(Box::new(parent)); + let child: &'static TableSchema = Box::leak(Box::new(child)); + let codec = ProjectionScopeCodec::with_models( + topology(), + [ + (parent.model_name.as_str(), parent), + (child.model_name.as_str(), child), + ], + ) + .unwrap(); + let key = |id: &str| RowKey::new([("id", RowValue::String(id.into()))]); + let mutation = + |schema: &'static TableSchema, id: &str, tenant: &str, candidate: Option<&str>| { + let row_key = key(id); + let mut values = RowValues::new(); + values.insert("id", RowValue::String(id.into())); + values.insert("parent_id", RowValue::String("unused".into())); + values.insert("tenant", RowValue::String(tenant.into())); + values.insert( + "key", + candidate + .map(|v| RowValue::String(v.into())) + .unwrap_or(RowValue::Null), + ); + ProjectionRecordMutation::new( + codec + .encode_row_scope_in_partition(&schema.model_name, partition(), &row_key) + .unwrap(), + TableMutation::UpsertRow(TableRowMutation { + schema, + key: row_key, + values, + expected_version: ExpectedVersion::Any, + mode: RowWriteMode::Upsert, + }), + ProjectionRecordExpectation::Missing, + ProjectionMutationKind::Upsert, + ) + .unwrap() + }; + let repository = InMemoryRepository::new(); + repository + .register_projection_models(&topology(), &graph_ownership()) + .await + .unwrap(); + repository + .commit_projection(ProjectionCommitBatch { + input: input( + 1, + b"candidate-graph", + "candidate-message", + "candidate-cause", + ProjectionGeneration::initial(), + ), + change_epoch: change_epoch(), + ownership: graph_ownership(), + observations: vec![], + mutations: vec![ + mutation(parent, "opaque-a", "a", Some("same")), + mutation(parent, "opaque-b", "b", Some("same")), + mutation(parent, "null-target", "a", None), + mutation(child, "source", "a", Some("same")), + mutation(child, "null-source", "a", None), + ], + }) + .await + .unwrap(); + for (schema, id, field, target, expected) in [ + (child, "source", "parent", parent, Some("opaque-a")), + (parent, "opaque-a", "children", child, Some("source")), + (parent, "opaque-b", "children", child, None), + (child, "null-source", "parent", parent, None), + (parent, "null-target", "children", child, None), + ] { + let root = ProjectionQuerySnapshotRequest::new( + &codec, + Some(&serde_json::json!("tenant-a")), + &schema.model_name, + key(id), + vec![], + ) + .unwrap(); + let request = ProjectionGraphSnapshotRequest::new( + root, + [(field.into(), Arc::new(target.clone()))], + 2, + ) + .unwrap(); + let snapshot = repository + .projection_graph_snapshot(&request) + .await + .unwrap(); + let rows = &snapshot.includes[field].rows; + assert_eq!(rows.len(), usize::from(expected.is_some()), "{id}/{field}"); + if let Some(expected) = expected { + assert_eq!( + rows[0].row.as_ref().unwrap().get("id"), + Some(&RowValue::String(expected.into())) + ); + assert_eq!( + rows[0].scope, + codec + .encode_row_scope_in_partition(&target.model_name, partition(), &key(expected)) + .unwrap() + ); + assert!(rows[0].record.is_some()); + } + } +} + fn fanout_schemas() -> &'static [TableSchema] { static SCHEMAS: LazyLock> = LazyLock::new(|| { [ diff --git a/src/in_memory_repo/repository.rs b/src/in_memory_repo/repository.rs index defa9a17f..69f90d742 100644 --- a/src/in_memory_repo/repository.rs +++ b/src/in_memory_repo/repository.rs @@ -588,6 +588,14 @@ impl GetStream for InMemoryRepository { } impl CausalGetStream for InMemoryRepository { + fn get_causal_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl std::future::Future, RepositoryError>> + Send + 'a + { + GetStream::get_stream_tail(self, identity, after_version) + } fn get_causal_stream<'a>( &'a self, identity: &'a StreamIdentity, diff --git a/src/lib.rs b/src/lib.rs index a11837770..c2f4fcb21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,8 +23,9 @@ mod time; pub mod aggregate; pub mod application; -pub mod command_dispatch; pub mod bus; +pub mod command; +pub mod command_dispatch; pub mod domain_event; pub mod entity; pub mod repository; @@ -33,6 +34,8 @@ pub(crate) mod command_ledger; mod commit_builder; #[cfg(feature = "emitter")] pub mod emitter; +#[cfg(feature = "gateway")] +pub mod gateway; pub mod graphql; mod in_memory_repo; pub mod lock; @@ -96,13 +99,13 @@ pub use domain_event::{ // Logical projection contracts. Physical read-model lowering deliberately lives // behind adapters and is not part of this semantic surface. -pub use projection::{LocalProjectionMounts, LocalProjectionMountsBuilder, - ProjectionArm, ProjectionAssignment, ProjectionEnvelopeField, ProjectionEventSelector, - ProjectionEventSet, ProjectionExpression, ProjectionField, ProjectionInvalidation, - ProjectionKeyField, ProjectionMutationKind, ProjectionMutationProvenance, - ProjectionObjectValueField, ProjectionOccurrenceProvenance, ProjectionOperation, - ProjectionPartition, ProjectionPlanTemplate, ProjectionProgram, ProjectionProgramError, - ProjectionProgramId, ProjectionProgramLimits, ProjectionRelationship, +pub use projection::{ + LocalProjectionMounts, LocalProjectionMountsBuilder, ProjectionArm, ProjectionAssignment, + ProjectionEnvelopeField, ProjectionEventSelector, ProjectionEventSet, ProjectionExpression, + ProjectionField, ProjectionInvalidation, ProjectionKeyField, ProjectionMutationKind, + ProjectionMutationProvenance, ProjectionObjectValueField, ProjectionOccurrenceProvenance, + ProjectionOperation, ProjectionPartition, ProjectionPlanTemplate, ProjectionProgram, + ProjectionProgramError, ProjectionProgramId, ProjectionProgramLimits, ProjectionRelationship, ProjectionRelationshipEffect, ProjectionRelationshipEffectKind, ProjectionScalarTransform, ProjectionTarget, ProjectionValue, ProjectionValueRef, ProjectionValueType, ResolvedProjectionKey, ResolvedProjectionMutation, ResolvedProjectionMutationScope, @@ -169,6 +172,7 @@ macro_rules! projection { version: $version:expr, epoch: $epoch:literal, model: $model:ty, + $(source: $source:ident,)? $( on { events: [ $($event_ty:ty),+ $(,)? ], @@ -206,12 +210,14 @@ macro_rules! projection { )+ } )+ - $crate::compile_projection( + let program = $crate::compile_projection( $name, $version, $crate::ProjectionPartition::Unit, handlers, - ) + )?; + $(let program = $crate::__projection_source_policy!(program, $source)?;)? + Ok(program) } fn __resolve( occurrence: &$crate::DomainEventOccurrence, @@ -300,6 +306,15 @@ macro_rules! projection { }; } +/// Declaration helper for the closed source-ordering policy. +#[doc(hidden)] +#[macro_export] +macro_rules! __projection_source_policy { + ($program:expr, aggregate_snapshot) => { + $program.with_source_snapshots() + }; +} + /// Map `input: { key: body | aggregate_id }` keywords for [`projection!`]. #[macro_export] #[doc(hidden)] @@ -356,10 +371,6 @@ pub use outbox::{ // `DEFAULT_OUTBOX_SOURCE_BATCH`, `DEFAULT_OUTBOX_SOURCE_LEASE`) stay reachable // under `distributed::outbox_worker::*` and are intentionally NOT re-exported // at the crate root. -pub use outbox_worker::{ - BusOutboxPublishHook, BusPublisher, ClaimOutboxMessages, OutboxClaimRef, OutboxDispatchOutcome, - OutboxDispatcher, OutboxPublishFailureAction, OutboxSource, OutboxStore, ReceivedOutboxMessage, -}; #[cfg(any( feature = "http", feature = "grpc", @@ -374,6 +385,10 @@ pub use outbox_worker::{ drain_worker_id, OutboxDrainHandle, OutboxDrainRunner, OutboxPublishMailbox, DEFAULT_OUTBOX_HINT_CAPACITY, }; +pub use outbox_worker::{ + BusOutboxPublishHook, BusPublisher, ClaimOutboxMessages, OutboxClaimRef, OutboxDispatchOutcome, + OutboxDispatcher, OutboxPublishFailureAction, OutboxSource, OutboxStore, ReceivedOutboxMessage, +}; pub use queued_repo::{ // WithOpts + unlock traits for the queued repository variant. @@ -406,12 +421,12 @@ pub use read_model::{ // `distributed::table::*`. pub use table::{ ColumnType, DeleteTableRowMutation, ExpectedVersion, ForeignKey, PatchMode, - PatchTableRowMutation, PrimaryKey, RelationshipDef, RelationshipKind, RowKey, RowPatch, - RowValue, RowValues, RowWriteMode, TableAdapterCapabilities, TableColumn, TableCommitOutcome, - TableIndex, TableKind, TableMigrationArtifact, TableModel, TableMutation, TableRowMutation, - TableSchema, TableSchemaAdapter, TableSchemaAdapterCapabilities, TableSchemaBootstrap, - TableSchemaIssue, TableSchemaIssueKind, TableSchemaRegistry, TableSchemaRegistryExt, - ReadModelCatalog, TableSchemaVerification, TableStoreError, TableWritePlan, + PatchTableRowMutation, PrimaryKey, ReadModelCatalog, RelationshipDef, RelationshipKind, RowKey, + RowPatch, RowValue, RowValues, RowWriteMode, TableAdapterCapabilities, TableColumn, + TableCommitOutcome, TableIndex, TableKind, TableMigrationArtifact, TableModel, TableMutation, + TableRowMutation, TableSchema, TableSchemaAdapter, TableSchemaAdapterCapabilities, + TableSchemaBootstrap, TableSchemaIssue, TableSchemaIssueKind, TableSchemaRegistry, + TableSchemaRegistryExt, TableSchemaVerification, TableStoreError, TableWritePlan, DEFAULT_TABLE_VERSION_COLUMN, }; pub use trace_context::{ @@ -445,9 +460,9 @@ macro_rules! graphql_models { // Session convenience re-exports used by GraphQL permission filters. pub use microsvc::{ - MessageEndpointDescriptor, MetricsEndpointDescriptor, ROLE_KEY, ServiceDescriptor, + MessageEndpointDescriptor, MetricsEndpointDescriptor, ServiceDescriptor, ServiceObservabilityDescriptor, TraceExportMode, TracePropagationMode, TracingDescriptor, - TransportDescriptor, USER_ID_KEY, + TransportDescriptor, ROLE_KEY, USER_ID_KEY, }; // Re-export proc macros. The old event-owning projection proc-macro and @@ -456,8 +471,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, portable_command, sourced, DomainEvent, DomainState, GraphqlInput, - GraphqlOutput, ReadModel, Snapshot, + mutation_file, portable_command, sourced, CommandInput, CommandOutput, DomainEvent, + DomainState, ReadModel, Snapshot, }; // Re-export enqueue macro (requires "emitter" feature) diff --git a/src/microsvc/causal.rs b/src/microsvc/causal.rs index 3ba2e54b8..966db41dd 100644 --- a/src/microsvc/causal.rs +++ b/src/microsvc/causal.rs @@ -16,14 +16,14 @@ use std::sync::Mutex; use serde::Serialize; -use crate::aggregate::{hydrate, Aggregate, AggregateRepository}; +use crate::aggregate::{Aggregate, AggregateRepository}; +use crate::command::{ + validate_resolved_direct_plan, Atomic, CommandCommitProofError, CommandOutcome, + PrepareCommandError, PreparedCommand, ProjectionCommitProof, ResolvedDirectProjectionTarget, + TypedCommandContract, +}; use crate::command_ledger::CausalGetStream; use crate::domain_event::{DomainEventCaptureError, DomainEventCommitGuardError}; -use crate::graphql::command_contract::{ - validate_resolved_direct_plan, CommandCommitProofError, CommandOutcome, ProjectionCommitProof, - ResolvedDirectProjectionTarget, TypedCommandContract, -}; -use crate::graphql::{Atomic, PrepareCommandError, PreparedCommand}; use crate::outbox::{OutboxMessage, PreparedDomainEvent}; use crate::projection::lower::{ DirectCandidate, LoweredProjectionPlan, ProjectionDescriptor, @@ -58,12 +58,7 @@ where A: Aggregate + Send + Sync + 'static, { fn load<'a>(&'a self, identity: &'a StreamIdentity) -> LoadAggregateFuture<'a, A> { - Box::pin(async move { - let Some(entity) = self.repository.repo().get_causal_stream(identity).await? else { - return Ok(None); - }; - hydrate::(entity).map(Some) - }) + Box::pin(async move { self.repository.get_causal(identity).await }) } fn snapshot_writes( @@ -816,8 +811,8 @@ mod tests { use super::*; use std::sync::Arc; + use crate::command::CommandTypeDef; use crate::entity::{Entity, EventRecord}; - use crate::graphql::GraphqlTypeDef; use crate::table::{ ColumnType, PrimaryKey, RowKey, RowValue, RowValues, TableColumn, TableKind, TableMutation, TableSchema, @@ -1005,6 +1000,13 @@ mod tests { } impl CausalGetStream for TestRepo { + async fn get_causal_stream_tail( + &self, + _identity: &StreamIdentity, + _after_version: u64, + ) -> Result, RepositoryError> { + panic!("this fixture does not configure snapshots") + } async fn get_causal_stream<'a>( &'a self, identity: &'a StreamIdentity, @@ -1067,9 +1069,9 @@ mod tests { #[derive(serde::Deserialize)] struct TestInput {} - impl crate::graphql::GraphqlInputType for TestInput { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new("TestInput", Vec::new()) + impl crate::command::CommandInputType for TestInput { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new("TestInput", Vec::new()) .with_type_id(std::any::TypeId::of::()) } } @@ -1292,7 +1294,7 @@ mod tests { workspace.stage(aggregate).unwrap(); let mut parts = workspace.into_parts().unwrap(); - let contract = crate::graphql::typed_command::>("test.project") + let contract = crate::command::typed_command::>("test.project") .into_contract(); parts.validate_prepared(&contract, &mut prepared).unwrap(); } @@ -1308,7 +1310,7 @@ mod tests { }) .unwrap(); let mut parts = workspace.into_parts().unwrap(); - let contract = crate::graphql::typed_command::>("test.project") + let contract = crate::command::typed_command::>("test.project") .into_contract(); assert!(matches!( @@ -1339,7 +1341,7 @@ mod tests { .unwrap(); workspace.stage_read_models(conflicting).unwrap(); let mut parts = workspace.into_parts().unwrap(); - let contract = crate::graphql::typed_command::>("test.project") + let contract = crate::command::typed_command::>("test.project") .into_contract(); assert!(matches!( @@ -1368,7 +1370,7 @@ mod tests { mutation .values .insert("title", RowValue::String("different".into())); - let contract = crate::graphql::typed_command::>("test.project") + let contract = crate::command::typed_command::>("test.project") .into_contract(); assert!(matches!( @@ -1384,7 +1386,7 @@ mod tests { } fn modeled_direct_contract() -> TypedCommandContract { - crate::graphql::typed_command::>("test.modeled-direct") + crate::command::typed_command::>("test.modeled-direct") .into_contract() } diff --git a/src/microsvc/cell_host/causal.rs b/src/microsvc/cell_host/causal.rs index a27a3ecd4..df11ae6f7 100644 --- a/src/microsvc/cell_host/causal.rs +++ b/src/microsvc/cell_host/causal.rs @@ -7,8 +7,20 @@ use std::time::Duration; +use serde::{Deserialize, Serialize}; use serde_json::Value; +use super::CellProjectionEventWireItem; + +/// Bounded retry material, owned by the command ledger and expired with its +/// replay retention. It is not an outbox record or an event history archive. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct CellCommandReplay { + pub payload: Value, + pub events: Vec, +} + use crate::command_ledger::{ AttemptFence, CausalCommitBatch, CausalTransactionalCommit, CommandAttempt, CommandId, CommandLedgerError, CommandLedgerKey, CommandLedgerState, CommandLedgerStore, CommandLookup, @@ -68,6 +80,7 @@ impl CellCommandIdentity { #[derive(Clone, Debug, PartialEq)] pub struct CellDispatchResult { payload: Value, + events: Vec, command_id: String, causation_id: String, state: String, @@ -75,6 +88,11 @@ pub struct CellDispatchResult { } impl CellDispatchResult { + /// Exact confirmation evidence retained with this command's retry receipt. + /// Delivery may have already removed all of the command's outbox rows. + pub fn projection_events(&self) -> &[CellProjectionEventWireItem] { + &self.events + } pub fn payload(&self) -> &Value { &self.payload } @@ -189,13 +207,23 @@ pub(crate) fn replay_result( 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::ProjectionFailed => { + let receipt: CellCommandReplay = + serde_json::from_value(replay.outcome).map_err(|error| { + CellDispatchError::Internal(format!("invalid cell command replay: {error}")) + })?; + // Validate persisted data just as strictly as the initial response. + super::parse_cell_projection_events(&serde_json::json!({ "events": receipt.events })) + .map_err(CellDispatchError::Internal)?; + Ok(CellDispatchResult { + payload: receipt.payload, + events: receipt.events, + 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 diff --git a/src/microsvc/cell_host/cell.rs b/src/microsvc/cell_host/cell.rs index 687fc11f2..b74904a13 100644 --- a/src/microsvc/cell_host/cell.rs +++ b/src/microsvc/cell_host/cell.rs @@ -7,9 +7,10 @@ use std::time::Duration; use serde_json::Value; use super::causal::{CellCommandIdentity, CellDispatchError, CellDispatchResult}; +use super::store::CellStreamStore; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] use super::store::{ - CellStreamStore, DurableAggregateCellState, DurableCellCommand, DurableCellEvents, - DurableCellSnapshot, + DurableAggregateCellState, DurableCellCommand, DurableCellEvents, DurableCellSnapshot, }; use crate::aggregate::{Aggregate, AggregateRepository}; use crate::microsvc::error::HandlerError; @@ -18,7 +19,7 @@ use crate::microsvc::session::Session; use crate::microsvc::HasOutboxStore; use crate::repository::{RepositoryError, SnapshotStore, StreamIdentity}; use crate::snapshot::{SnapshotRecord, Snapshottable}; -use crate::{InMemoryOutboxStore, OutboxDispatcher}; +use crate::OutboxDispatcher; /// Cell class for aggregate `A`. Equivalent to /// `#[distributed::cell(aggregate = A)]`: mount the same domain @@ -52,6 +53,10 @@ where routes: Routes>, #[cfg(feature = "workers-rs")] celld_outbox: Option, + #[cfg(all(feature = "workers-rs", any(target_arch = "wasm32", test)))] + activity: super::celld_outbox::CommandActivity, + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + storage: worker::send::SendWrapper, } impl AggregateCell @@ -59,6 +64,7 @@ where A: Aggregate + Send + Sync + 'static, { /// Open a cell instance addressed as `{aggregate_type}:{shard_id}`. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] 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()); @@ -67,6 +73,27 @@ where routes: Routes::from_dependencies(AggregateRepository::new(store)), #[cfg(feature = "workers-rs")] celld_outbox: None, + #[cfg(all(feature = "workers-rs", any(target_arch = "wasm32", test)))] + activity: Default::default(), + }) + } + + /// Open the named runtime-owned SQLite cell. Mount commands and configure + /// its Queue binding before dispatch; no in-memory persistence path exists. + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + pub fn from_state(state: worker::State) -> Result { + let name = state.id().name().ok_or_else(|| { + RepositoryError::Model("aggregate cells require a named Durable Object".into()) + })?; + let shard = StreamIdentity::new(A::aggregate_type(), name)?; + let storage = worker::send::SendWrapper::new(state.storage()); + let store = CellStreamStore::from_state(state, shard.clone())?; + Ok(Self { + shard, + routes: Routes::from_dependencies(AggregateRepository::new(store)), + celld_outbox: None, + activity: Default::default(), + storage, }) } @@ -111,6 +138,11 @@ where input: Value, session: Session, ) -> Result { + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + let _command = self + .begin_command() + .await + .map_err(|error| HandlerError::Other(Box::new(error)))?; self.routes .dispatch_cell_command(command, input, session, &self.shard) .await @@ -128,6 +160,11 @@ where input: Value, session: Session, ) -> Result { + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + let _command = self + .begin_command() + .await + .map_err(|error| CellDispatchError::Internal(error.to_string()))?; self.routes .dispatch_cell_causal(command, identity, input, session, &self.shard) .await @@ -142,11 +179,13 @@ where } /// Event log for Durable Object SQLite persistence. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_events(&self) -> Result, RepositoryError> { self.routes.repo().repo().durable_events() } /// Restore the working event log from Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_events( &self, events: Vec, @@ -155,6 +194,7 @@ where } /// Outbox rows committed with the aggregate (same cell SQLite). + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_outbox(&self) -> Result, RepositoryError> { self.routes.repo().repo().durable_outbox() } @@ -169,7 +209,7 @@ where worker_id: impl Into, lease: Duration, max_attempts: u32, - ) -> OutboxDispatcher + ) -> OutboxDispatcher<::OutboxStore, P> where P: crate::bus::MessagePublisher, { @@ -189,31 +229,52 @@ where self } - /// Persist this cell's complete state, durably arm its Queue watchdog, - /// dispatch pending outbox rows, persist settlements, and rearm or clear - /// the watchdog according to the remaining backlog. Once the initial state - /// and watchdog are durable, later failures are returned as deferred drain - /// diagnostics rather than as a rejection of the committed command. - #[cfg(feature = "workers-rs")] - pub async fn persist_and_drain_outbox( + /// Drain committed rows to the configured Queue. Call from the alarm handler + /// and optionally after dispatch for low latency. No persistence callback is + /// needed. A post-commit drain error must not reject an accepted command. + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + pub async fn drain_outbox( &self, env: &worker::Env, - storage: &worker::Storage, - persist: F, - ) -> Result - where - F: Fn(&DurableAggregateCellState) -> Result<(), E>, - E: std::fmt::Display, - { - let outbox = self.celld_outbox.as_ref().ok_or_else(|| { + ) -> Result { + self.outbox_config()?.drain(self, env, &self.storage).await + } + + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + fn outbox_config( + &self, + ) -> Result<&super::celld_outbox::CelldOutbox, crate::bus::TransportError> { + self.celld_outbox.as_ref().ok_or_else(|| { crate::bus::TransportError::permanent( "aggregate cell has no celld outbox binding configured", ) - })?; - outbox.persist_and_drain(self, env, storage, persist).await + }) + } + + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + async fn begin_command( + &self, + ) -> Result { + self.outbox_config()? + .begin_command( + &self.activity, + &super::celld_outbox::WorkerAlarm(&self.storage), + ) + .await + } + + #[cfg(all(feature = "workers-rs", any(target_arch = "wasm32", test)))] + pub(super) fn command_activity(&self) -> &super::celld_outbox::CommandActivity { + &self.activity + } + + #[cfg(all(feature = "workers-rs", any(target_arch = "wasm32", test)))] + pub(super) fn outbox_store(&self) -> ::OutboxStore { + self.routes.repo().repo().outbox_store() } /// Restore outbox rows from Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_outbox( &self, messages: Vec, @@ -222,11 +283,13 @@ where } /// Snapshot cache for Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_snapshots(&self) -> Result, RepositoryError> { self.routes.repo().repo().durable_snapshots() } /// Restore the working snapshot cache from Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_snapshots( &self, snapshots: Vec, @@ -238,11 +301,13 @@ where } /// Command-ledger rows for Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_commands(&self) -> Result, RepositoryError> { self.routes.repo().repo().durable_commands() } /// Restore command-ledger rows before accepting another wait-path request. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_commands( &self, commands: Vec, @@ -252,11 +317,13 @@ where /// Export events, snapshots, command ledger, outbox, and sealed row as one /// versioned value suitable for a single durable storage write. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_state(&self) -> Result { self.routes.repo().repo().durable_state() } /// Restore the complete working copy from one durable storage value. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_state( &self, state: DurableAggregateCellState, @@ -270,11 +337,13 @@ where } /// Sealed read-model JSON for GET on this instance. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn sealed_row(&self) -> Result, RepositoryError> { self.routes.repo().repo().sealed_row() } /// Persist the sealed read-model row next to events/snapshots. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn replace_sealed_row(&self, row: Value) -> Result<(), RepositoryError> { self.routes.repo().repo().replace_sealed_row(row) } @@ -284,7 +353,31 @@ impl AggregateCell where A: Aggregate + Snapshottable + Send + Sync + 'static, { + /// Open a SQL cell with the ordinary repository snapshot policy. + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + pub fn from_state_with_snapshots( + state: worker::State, + frequency: u64, + ) -> Result { + let name = state.id().name().ok_or_else(|| { + RepositoryError::Model("aggregate cells require a named Durable Object".into()) + })?; + let shard = StreamIdentity::new(A::aggregate_type(), name)?; + let storage = worker::send::SendWrapper::new(state.storage()); + let store = CellStreamStore::from_state(state, shard.clone())?; + Ok(Self { + shard, + routes: Routes::from_dependencies( + AggregateRepository::new(store).with_snapshots(frequency), + ), + celld_outbox: None, + activity: Default::default(), + storage, + }) + } + /// Open a cell with repository snapshot caching (`with_snapshots`). + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn new_with_snapshots( shard_id: impl Into, frequency: u64, @@ -298,6 +391,8 @@ where ), #[cfg(feature = "workers-rs")] celld_outbox: None, + #[cfg(all(feature = "workers-rs", any(target_arch = "wasm32", test)))] + activity: Default::default(), }) } } @@ -345,6 +440,7 @@ where } /// Create or return the cell for `shard_id`. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn get_or_create( &mut self, shard_id: &str, diff --git a/src/microsvc/cell_host/celld_outbox.rs b/src/microsvc/cell_host/celld_outbox.rs index 06a5bb90d..691a7c674 100644 --- a/src/microsvc/cell_host/celld_outbox.rs +++ b/src/microsvc/cell_host/celld_outbox.rs @@ -1,15 +1,23 @@ //! High-level celld Queue drain lifecycle for aggregate-cell outboxes. -use std::fmt; +#[cfg(any(target_arch = "wasm32", test))] +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; use std::time::Duration; -use worker::{Env, Storage}; +use worker::Env; +#[cfg(target_arch = "wasm32")] +use worker::Storage; +#[cfg(any(target_arch = "wasm32", test))] use super::cell::AggregateCell; -use super::store::DurableAggregateCellState; +#[cfg(any(target_arch = "wasm32", test))] use crate::bus::MessagePublisher; use crate::bus::{CelldQueuePublisher, TransportError}; use crate::outbox_worker::OutboxDispatchOutcome; +#[cfg(any(target_arch = "wasm32", test))] use crate::Aggregate; /// Conventional celld Queue producer binding for aggregate outboxes. @@ -25,8 +33,7 @@ pub const CELLD_OUTBOX_DEFAULT_LEASE: Duration = Duration::from_secs(30); /// Default publish-failure ceiling before an outbox row is terminal. pub const CELLD_OUTBOX_DEFAULT_MAX_ATTEMPTS: u32 = 10_000; -/// Result of persisting an aggregate-cell commit and attempting its immediate -/// celld Queue drain. +/// Result of attempting the Queue drain after an aggregate-cell commit. /// /// Once the aggregate state, outbox rows, and watchdog alarm are durable, the /// command is accepted. Failures after that boundary are reported in @@ -52,8 +59,8 @@ impl CelldOutboxDrainOutcome { /// celld Queue binding and drain policy attached to an [`AggregateCell`]. /// /// Domain aggregates remain transport-independent. The infrastructure-facing -/// cell host selects this binding once, then [`AggregateCell::persist_and_drain_outbox`] -/// owns the persist → watchdog → publish → settle → persist lifecycle. +/// cell host selects this binding once. Dispatch arms the watchdog before +/// invoking a command; draining publishes and deletes leased SQLite rows. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CelldOutbox { binding: String, @@ -160,67 +167,70 @@ impl CelldOutbox { self.max_attempts } - pub(crate) async fn persist_and_drain( + #[cfg(target_arch = "wasm32")] + pub(crate) async fn drain( &self, cell: &AggregateCell, env: &Env, storage: &Storage, - persist: F, ) -> Result where A: Aggregate + Send + Sync + 'static, - F: Fn(&DurableAggregateCellState) -> Result<(), E>, - E: fmt::Display, { - let alarm = WorkerAlarm(storage); - self.persist_and_drain_with( + self.drain_with( cell, || CelldQueuePublisher::from_env(env, &self.binding), - &alarm, - persist, + &WorkerAlarm(storage), ) .await } - async fn persist_and_drain_with( + #[cfg(any(target_arch = "wasm32", test))] + pub(super) async fn begin_command( + &self, + activity: &CommandActivity, + alarm: &W, + ) -> Result { + // Count the command before yielding to setAlarm. An alarm firing while + // this command is suspended must keep the next wake scheduled. + let guard = activity.enter()?; + alarm.arm(self.drain_interval).await?; + Ok(guard) + } + + #[cfg(any(target_arch = "wasm32", test))] + async fn drain_with( &self, cell: &AggregateCell, publisher: PF, alarm: &W, - persist: F, ) -> Result where A: Aggregate + Send + Sync + 'static, P: MessagePublisher, PF: FnOnce() -> Result, - F: Fn(&DurableAggregateCellState) -> Result<(), E>, - E: fmt::Display, W: CelldOutboxAlarm, { - persist_current_state(cell, &persist)?; - - if !has_pending(cell)? { - let deferred = alarm.clear().await.err().into_iter().collect(); - return Ok(CelldOutboxDrainOutcome { - dispatch: OutboxDispatchOutcome::default(), - deferred, - }); + let pending = has_pending(cell).await?; + if !pending && !cell.command_activity().is_active() { + // Never delete an alarm: doing so can erase a concurrent command's + // prearmed wake. The last scheduled alarm simply finds no work. + return Ok(CelldOutboxDrainOutcome::default()); } - // The watchdog is durable before Queue egress. If Queue accepts but - // settlement persistence is interrupted, the stable id is retried. + // Arm before Queue I/O, including during an alarm invocation. A crash + // after Queue acceptance but before deletion retries the stable event ID. alarm.arm(self.drain_interval).await?; - - // Binding resolution is deliberately after the state commit and armed - // watchdog. A missing/misconfigured Queue cannot make the aggregate - // mutation disappear; the alarm retries after the deployment is fixed. + if !pending { + return Ok(CelldOutboxDrainOutcome::default()); + } let publisher = match publisher() { Ok(publisher) => publisher, Err(error) => { return Ok(CelldOutboxDrainOutcome { dispatch: OutboxDispatchOutcome::default(), deferred: vec![error], - }); + }) } }; let dispatcher = cell.outbox_dispatcher( @@ -229,47 +239,58 @@ impl CelldOutbox { self.lease, self.max_attempts, ); - let (dispatch, mut deferred) = match dispatcher.dispatch_batch(self.batch_size).await { - Ok(dispatch) => (dispatch, Vec::new()), - Err(error) => (OutboxDispatchOutcome::default(), vec![error]), - }; - - // Persist even when the dispatcher reports a store error: earlier rows - // in the pass may already have settled. The armed alarm remains the - // recovery path if exporting or persisting this state fails. - if let Err(error) = persist_current_state(cell, &persist) { - deferred.push(error); - return Ok(CelldOutboxDrainOutcome { dispatch, deferred }); + match dispatcher.dispatch_batch(self.batch_size).await { + Ok(dispatch) => Ok(CelldOutboxDrainOutcome { + dispatch, + deferred: Vec::new(), + }), + Err(error) => Ok(CelldOutboxDrainOutcome { + dispatch: OutboxDispatchOutcome::default(), + deferred: vec![error], + }), } + } +} - let pending = match has_pending(cell) { - Ok(pending) => pending, - Err(error) => { - deferred.push(error); - return Ok(CelldOutboxDrainOutcome { dispatch, deferred }); - } - }; - let watchdog = if pending { - alarm.arm(self.drain_interval).await - } else { - alarm.clear().await - }; - if let Err(error) = watchdog { - deferred.push(error); - } +/// Tracks only currently executing commands, not durable data. The prearmed +/// alarm and pending SQL rows carry recovery across process loss. +#[cfg(any(target_arch = "wasm32", test))] +#[derive(Default)] +pub(super) struct CommandActivity(Arc); + +#[cfg(any(target_arch = "wasm32", test))] +impl CommandActivity { + fn enter(&self) -> Result { + self.0 + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| { + count.checked_add(1) + }) + .map_err(|_| TransportError::retryable("cell command concurrency limit exceeded"))?; + Ok(CommandGuard(Arc::clone(&self.0))) + } + fn is_active(&self) -> bool { + self.0.load(Ordering::SeqCst) != 0 + } +} - Ok(CelldOutboxDrainOutcome { dispatch, deferred }) +#[cfg(any(target_arch = "wasm32", test))] +pub(super) struct CommandGuard(Arc); +#[cfg(any(target_arch = "wasm32", test))] +impl Drop for CommandGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); } } +#[cfg(any(target_arch = "wasm32", test))] #[async_trait::async_trait(?Send)] -trait CelldOutboxAlarm { +pub(super) trait CelldOutboxAlarm { async fn arm(&self, interval: Duration) -> Result<(), TransportError>; - async fn clear(&self) -> Result<(), TransportError>; } -struct WorkerAlarm<'a>(&'a Storage); - +#[cfg(target_arch = "wasm32")] +pub(super) struct WorkerAlarm<'a>(pub &'a Storage); +#[cfg(target_arch = "wasm32")] #[async_trait::async_trait(?Send)] impl CelldOutboxAlarm for WorkerAlarm<'_> { async fn arm(&self, interval: Duration) -> Result<(), TransportError> { @@ -277,151 +298,112 @@ impl CelldOutboxAlarm for WorkerAlarm<'_> { TransportError::retryable(format!("cannot arm celld outbox watchdog: {error}")) }) } - - async fn clear(&self) -> Result<(), TransportError> { - self.0.delete_alarm().await.map_err(|error| { - TransportError::retryable(format!("cannot clear celld outbox watchdog: {error}")) - }) - } } -fn persist_current_state( - cell: &AggregateCell, - persist: &F, -) -> Result<(), TransportError> +#[cfg(any(target_arch = "wasm32", test))] +async fn has_pending(cell: &AggregateCell) -> Result where A: Aggregate + Send + Sync + 'static, - F: Fn(&DurableAggregateCellState) -> Result<(), E>, - E: fmt::Display, { - let state = cell.durable_state().map_err(|error| { - TransportError::permanent(format!("cannot export aggregate cell state: {error}")) - })?; - persist(&state).map_err(|error| { - TransportError::retryable(format!("cannot persist aggregate cell state: {error}")) - }) -} - -fn has_pending(cell: &AggregateCell) -> Result -where - A: Aggregate + Send + Sync + 'static, -{ - cell.durable_outbox() - .map(|rows| { - rows.iter() - .any(|row| !row.is_published() && !row.is_failed()) - }) - .map_err(|error| { - TransportError::permanent(format!("cannot inspect aggregate cell outbox: {error}")) - }) + use crate::outbox_worker::OutboxStore; + use crate::OutboxMessageStatus; + let store = cell.outbox_store(); + for status in [OutboxMessageStatus::Pending, OutboxMessageStatus::InFlight] { + if !store + .messages_by_status(status, 1) + .await + .map_err(|error| { + TransportError::retryable(format!("cannot inspect aggregate cell outbox: {error}")) + })? + .is_empty() + { + return Ok(true); + } + } + Ok(false) } #[cfg(test)] mod tests { use super::*; use crate::entity::{Entity, EventRecord}; - use crate::outbox_worker::testing::block_on; + use crate::outbox_worker::{testing::block_on, OutboxStore}; use crate::{OutboxMessage, OutboxMessageStatus}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{Arc, Mutex}; + use std::sync::Mutex; #[derive(Default)] struct TestAggregate { entity: Entity, } - impl Aggregate for TestAggregate { type ReplayError = String; - fn aggregate_type() -> &'static str { "celld_outbox_test" } - 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> { + fn replay_event(&mut self, _: &EventRecord) -> Result<(), String> { Err("test aggregate has no replay events".into()) } } - #[derive(Clone)] struct RecordingPublisher { log: Arc>>, fail: bool, } - impl MessagePublisher for RecordingPublisher { - fn publish( - &self, - _message: crate::bus::Message, - ) -> impl std::future::Future> + Send + '_ { - let log = Arc::clone(&self.log); - let fail = self.fail; - async move { - log.lock().unwrap().push("publish"); - if fail { - Err(TransportError::retryable("Queue unavailable")) - } else { - Ok(()) - } + async fn publish(&self, _: crate::bus::Message) -> Result<(), TransportError> { + self.log.lock().unwrap().push("publish"); + if self.fail { + Err(TransportError::retryable("Queue unavailable")) + } else { + Ok(()) } } } - struct RecordingAlarm { log: Arc>>, + fail: bool, } - #[async_trait::async_trait(?Send)] impl CelldOutboxAlarm for RecordingAlarm { - async fn arm(&self, _interval: Duration) -> Result<(), TransportError> { + async fn arm(&self, _: Duration) -> Result<(), TransportError> { self.log.lock().unwrap().push("arm"); - Ok(()) - } - - async fn clear(&self) -> Result<(), TransportError> { - self.log.lock().unwrap().push("clear"); - Ok(()) + if self.fail { + Err(TransportError::retryable("alarm unavailable")) + } else { + Ok(()) + } } } - - struct FailingArmAlarm { - log: Arc>>, - } - - #[async_trait::async_trait(?Send)] - impl CelldOutboxAlarm for FailingArmAlarm { - async fn arm(&self, _interval: Duration) -> Result<(), TransportError> { - self.log.lock().unwrap().push("arm"); - Err(TransportError::retryable("alarm unavailable")) - } - - async fn clear(&self) -> Result<(), TransportError> { - self.log.lock().unwrap().push("clear"); - Ok(()) + fn cell(pending: bool) -> AggregateCell { + let cell = AggregateCell::new("aggregate-1").unwrap(); + if pending { + cell.restore_durable_outbox(vec![OutboxMessage::create( + "evt-1", + "test.created", + vec![1], + ) + .unwrap()]) + .unwrap(); } - } - - fn pending_cell() -> AggregateCell { - let cell = AggregateCell::::new("aggregate-1").unwrap(); - let message = OutboxMessage::create("evt-1", "test.created", vec![1]).unwrap(); - cell.restore_durable_state(DurableAggregateCellState { - version: super::super::store::DURABLE_AGGREGATE_CELL_STATE_VERSION, - events: Vec::new(), - snapshots: Vec::new(), - commands: Vec::new(), - outbox: vec![message], - sealed_row: None, - }) - .unwrap(); cell } + fn fixtures() -> (CelldOutbox, RecordingAlarm, RecordingPublisher) { + let log = Arc::new(Mutex::new(Vec::new())); + ( + CelldOutbox::new("OUTBOX").unwrap(), + RecordingAlarm { + log: log.clone(), + fail: false, + }, + RecordingPublisher { log, fail: false }, + ) + } #[test] fn defaults_are_safe_and_conventional() { @@ -435,7 +417,6 @@ mod tests { assert_eq!(outbox.batch_size(), CELLD_OUTBOX_DEFAULT_BATCH_SIZE); assert_eq!(outbox.max_attempts(), CELLD_OUTBOX_DEFAULT_MAX_ATTEMPTS); } - #[test] fn invalid_policy_values_are_rejected() { assert!(CelldOutbox::new(" ").is_err()); @@ -448,248 +429,161 @@ mod tests { assert!(base.clone().with_batch_size(0).is_err()); assert!(base.with_max_attempts(0).is_err()); } - #[test] - fn persists_and_arms_before_publish_then_persists_and_clears() { + fn arms_before_dispatch_and_publish_then_deletes_delivered_rows() { block_on(async { - let cell = pending_cell(); - let outbox = CelldOutbox::new("OUTBOX").unwrap(); - let log = Arc::new(Mutex::new(Vec::new())); - let states = Arc::new(Mutex::new(Vec::new())); - let publisher = RecordingPublisher { - log: Arc::clone(&log), - fail: false, - }; - let alarm = RecordingAlarm { - log: Arc::clone(&log), - }; - let persist_log = Arc::clone(&log); - let persisted_states = Arc::clone(&states); - - let outcome = outbox - .persist_and_drain_with( - &cell, - || Ok(publisher), - &alarm, - move |state| { - persist_log.lock().unwrap().push("persist"); - persisted_states.lock().unwrap().push(state.clone()); - Ok::<_, String>(()) - }, - ) + let cell = cell(true); + let (outbox, alarm, publisher) = fixtures(); + let guard = outbox + .begin_command(cell.command_activity(), &alarm) .await .unwrap(); - - assert_eq!(outcome.dispatch.published, 1); - assert!(!outcome.is_deferred()); + alarm.log.lock().unwrap().push("command"); + drop(guard); + let result = outbox + .drain_with(&cell, || Ok(publisher), &alarm) + .await + .unwrap(); + assert_eq!(result.dispatch.published, 1); + assert!(!result.is_deferred()); + assert!(cell.durable_outbox().unwrap().is_empty()); assert_eq!( - *log.lock().unwrap(), - ["persist", "arm", "publish", "persist", "clear"] + *alarm.log.lock().unwrap(), + ["arm", "command", "arm", "publish"] + ); + let (_, _, publisher) = fixtures(); + assert!(!outbox + .drain_with(&cell, || Ok(publisher), &alarm) + .await + .unwrap() + .is_deferred()); + assert_eq!( + alarm.log.lock().unwrap().len(), + 4, + "idle wake neither rearms nor deletes another wake" ); - let states = states.lock().unwrap(); - assert_eq!(states[0].outbox[0].status, OutboxMessageStatus::Pending); - assert_eq!(states[1].outbox[0].status, OutboxMessageStatus::Published); }); } - #[test] - fn retryable_publish_stays_pending_and_rearms_without_error() { + fn retryable_publish_keeps_pending_work_and_already_armed_wake() { block_on(async { - let cell = pending_cell(); - let outbox = CelldOutbox::new("OUTBOX").unwrap(); - let log = Arc::new(Mutex::new(Vec::new())); - let states = Arc::new(Mutex::new(Vec::new())); - let publisher = RecordingPublisher { - log: Arc::clone(&log), - fail: true, - }; - let alarm = RecordingAlarm { - log: Arc::clone(&log), - }; - let persist_log = Arc::clone(&log); - let persisted_states = Arc::clone(&states); - - let outcome = outbox - .persist_and_drain_with( - &cell, - || Ok(publisher), - &alarm, - move |state| { - persist_log.lock().unwrap().push("persist"); - persisted_states.lock().unwrap().push(state.clone()); - Ok::<_, String>(()) - }, - ) + let cell = cell(true); + let (outbox, alarm, mut publisher) = fixtures(); + publisher.fail = true; + let result = outbox + .drain_with(&cell, || Ok(publisher), &alarm) .await .unwrap(); - - assert_eq!(outcome.dispatch.released, 1); - assert!(!outcome.is_deferred()); - assert_eq!( - *log.lock().unwrap(), - ["persist", "arm", "publish", "persist", "arm"] - ); - let states = states.lock().unwrap(); - assert_eq!(states[1].outbox[0].status, OutboxMessageStatus::Pending); - assert_eq!(states[1].outbox[0].attempts, 1); + assert_eq!(result.dispatch.released, 1); + let rows = cell.durable_outbox().unwrap(); + assert_eq!(rows[0].status, OutboxMessageStatus::Pending); + assert_eq!(rows[0].attempts, 1); + assert_eq!(*alarm.log.lock().unwrap(), ["arm", "publish"]); }); } - #[test] - fn persistence_failure_prevents_alarm_and_publish() { + fn failed_prearm_does_not_admit_command_and_releases_activity_guard() { block_on(async { - let cell = pending_cell(); - let outbox = CelldOutbox::new("OUTBOX").unwrap(); - let log = Arc::new(Mutex::new(Vec::new())); - let alarm = RecordingAlarm { - log: Arc::clone(&log), - }; - let resolve_log = Arc::clone(&log); - let persist_log = Arc::clone(&log); - - let error = outbox - .persist_and_drain_with( - &cell, - move || { - resolve_log.lock().unwrap().push("resolve"); - Ok(RecordingPublisher { - log: Arc::clone(&resolve_log), - fail: false, - }) - }, - &alarm, - move |_state| { - persist_log.lock().unwrap().push("persist"); - Err::<(), _>("storage unavailable") - }, - ) + let cell = cell(false); + let (outbox, mut alarm, _) = fixtures(); + alarm.fail = true; + assert!(outbox + .begin_command(cell.command_activity(), &alarm) .await - .unwrap_err(); - - assert!(error.is_retryable()); - assert_eq!(*log.lock().unwrap(), ["persist"]); + .is_err()); + assert!(!cell.command_activity().is_active()); + assert_eq!(*alarm.log.lock().unwrap(), ["arm"]); }); } - #[test] - fn watchdog_failure_before_durable_acceptance_prevents_publish() { + fn alarm_keeps_waking_while_a_command_is_suspended_before_commit() { block_on(async { - let cell = pending_cell(); - let outbox = CelldOutbox::new("OUTBOX").unwrap(); - let log = Arc::new(Mutex::new(Vec::new())); - let alarm = FailingArmAlarm { - log: Arc::clone(&log), - }; - let resolve_log = Arc::clone(&log); - let persist_log = Arc::clone(&log); - - let error = outbox - .persist_and_drain_with( - &cell, - move || { - resolve_log.lock().unwrap().push("resolve"); - Ok(RecordingPublisher { - log: Arc::clone(&resolve_log), - fail: false, - }) - }, - &alarm, - move |_state| { - persist_log.lock().unwrap().push("persist"); - Ok::<_, String>(()) - }, - ) + let cell = cell(false); + let (outbox, alarm, publisher) = fixtures(); + let first = outbox + .begin_command(cell.command_activity(), &alarm) .await - .unwrap_err(); - - assert!(error.is_retryable()); - assert_eq!(*log.lock().unwrap(), ["persist", "arm"]); + .unwrap(); + assert!(!outbox + .drain_with(&cell, || Ok(publisher.clone()), &alarm) + .await + .unwrap() + .is_deferred()); + let second = outbox + .begin_command(cell.command_activity(), &alarm) + .await + .unwrap(); + drop(first); + assert!(!outbox + .drain_with(&cell, || Ok(publisher.clone()), &alarm) + .await + .unwrap() + .is_deferred()); + assert_eq!(*alarm.log.lock().unwrap(), ["arm", "arm", "arm", "arm"]); + drop(second); + assert!(!outbox + .drain_with(&cell, || Ok(publisher), &alarm) + .await + .unwrap() + .is_deferred()); + assert_eq!(alarm.log.lock().unwrap().len(), 4); }); } - #[test] - fn binding_failure_is_deferred_after_persistence_and_watchdog() { + fn binding_failure_is_deferred_with_work_and_wake_retained() { block_on(async { - let cell = pending_cell(); - let outbox = CelldOutbox::new("OUTBOX").unwrap(); - let log = Arc::new(Mutex::new(Vec::new())); - let alarm = RecordingAlarm { - log: Arc::clone(&log), - }; - let resolve_log = Arc::clone(&log); - let persist_log = Arc::clone(&log); - - let outcome = outbox - .persist_and_drain_with( + let cell = cell(true); + let (outbox, alarm, _) = fixtures(); + let result = outbox + .drain_with( &cell, - move || { - resolve_log.lock().unwrap().push("resolve"); - Err::(TransportError::permanent( - "binding unavailable", - )) - }, + || Err::(TransportError::permanent("missing Queue")), &alarm, - move |_state| { - persist_log.lock().unwrap().push("persist"); - Ok::<_, String>(()) - }, ) .await .unwrap(); - - assert_eq!(outcome.deferred.len(), 1); - assert!(outcome.deferred[0].is_permanent()); - assert_eq!(*log.lock().unwrap(), ["persist", "arm", "resolve"]); + assert_eq!(result.deferred.len(), 1); + assert_eq!(cell.durable_outbox().unwrap().len(), 1); + assert_eq!(*alarm.log.lock().unwrap(), ["arm"]); }); } - #[test] - fn settlement_persistence_failure_is_deferred_after_durable_acceptance() { + fn alarm_failure_prevents_egress_without_losing_committed_rows() { block_on(async { - let cell = pending_cell(); - let outbox = CelldOutbox::new("OUTBOX").unwrap(); - let log = Arc::new(Mutex::new(Vec::new())); - let persisted_states = Arc::new(Mutex::new(Vec::new())); - let persist_calls = Arc::new(AtomicUsize::new(0)); - let publisher = RecordingPublisher { - log: Arc::clone(&log), - fail: false, - }; - let alarm = RecordingAlarm { - log: Arc::clone(&log), - }; - let persist_log = Arc::clone(&log); - let states = Arc::clone(&persisted_states); - let calls = Arc::clone(&persist_calls); - - let outcome = outbox - .persist_and_drain_with( - &cell, - || Ok(publisher), - &alarm, - move |state| { - persist_log.lock().unwrap().push("persist"); - if calls.fetch_add(1, Ordering::SeqCst) == 0 { - states.lock().unwrap().push(state.clone()); - Ok(()) - } else { - Err("storage unavailable") - } - }, - ) + let cell = cell(true); + let (outbox, mut alarm, publisher) = fixtures(); + alarm.fail = true; + assert!(outbox + .drain_with(&cell, || Ok(publisher), &alarm) + .await + .is_err()); + assert_eq!(cell.durable_outbox().unwrap().len(), 1); + assert_eq!(*alarm.log.lock().unwrap(), ["arm"]); + }); + } + #[test] + fn live_claim_rearms_without_publishing_until_lease_expires() { + block_on(async { + let cell = cell(true); + let (outbox, alarm, publisher) = fixtures(); + cell.outbox_store() + .claim(crate::outbox_worker::ClaimOutboxMessages::new( + "busy", + 1, + Duration::from_secs(60), + )) .await .unwrap(); - - assert_eq!(outcome.dispatch.published, 1); - assert_eq!(outcome.deferred.len(), 1); - assert!(outcome.deferred[0].is_retryable()); + let result = outbox + .drain_with(&cell, || Ok(publisher), &alarm) + .await + .unwrap(); + assert_eq!(result.dispatch.published, 0); + assert_eq!(*alarm.log.lock().unwrap(), ["arm"]); assert_eq!( - *log.lock().unwrap(), - ["persist", "arm", "publish", "persist"] + cell.durable_outbox().unwrap()[0].status, + OutboxMessageStatus::InFlight ); - let states = persisted_states.lock().unwrap(); - assert_eq!(states.len(), 1); - assert_eq!(states[0].outbox[0].status, OutboxMessageStatus::Pending); }); } } diff --git a/src/microsvc/cell_host/mod.rs b/src/microsvc/cell_host/mod.rs index f906f3186..51f94eb70 100644 --- a/src/microsvc/cell_host/mod.rs +++ b/src/microsvc/cell_host/mod.rs @@ -20,6 +20,10 @@ mod celld_outbox; #[cfg(feature = "graphql")] mod command; mod internal_auth; +#[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] +mod sql_executor; +#[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] +mod sql_store; mod store; mod wire; @@ -39,9 +43,11 @@ pub use command::{CelldCommandHost, CelldRoute}; pub use internal_auth::{ InternalHttpSecret, CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, }; +pub use store::CellStreamStore; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub use store::{ - CellStreamStore, DurableAggregateCellState, DurableCellCommand, DurableCellEvents, - DurableCellSnapshot, DURABLE_AGGREGATE_CELL_STATE_VERSION, + DurableAggregateCellState, DurableCellCommand, DurableCellEvents, DurableCellSnapshot, + DURABLE_AGGREGATE_CELL_STATE_VERSION, }; pub(crate) use wire::validate_cell_projection_events; pub use wire::{ diff --git a/src/microsvc/cell_host/sql_executor.rs b/src/microsvc/cell_host/sql_executor.rs new file mode 100644 index 000000000..9c394e7a5 --- /dev/null +++ b/src/microsvc/cell_host/sql_executor.rs @@ -0,0 +1,269 @@ +//! Durable Object execution of the shared SQL repository operations. +//! +//! No event serialization, replay, or snapshot policy lives in this adapter. +//! The callback is synchronous and bounded by storage.transactionSync, so a +//! commit cannot yield between its domain and delivery/receipt participants. + +use std::collections::HashMap; +use std::future::Future; +use std::task::{Context, Poll, Waker}; +use std::time::SystemTime; + +use worker::js_sys::{Function, Reflect}; +use worker::send::SendWrapper; +use worker::wasm_bindgen::{closure::ScopedClosure, JsCast, JsValue}; +use worker::{SqlStorage, SqlStorageValue, State}; + +use crate::repository::sql::{SqlBind, SqlExecutor, SqlPart, SqlRow, Statement}; +use crate::repository::{sqlite_codec, RepositoryError}; + +#[derive(Clone)] +pub(crate) struct CellSqlConnection { + sql: SqlStorage, + storage: SendWrapper, + transaction_sync: SendWrapper, +} + +impl CellSqlConnection { + /// Takes the runtime-owned state, not a caller-supplied database URL. + pub fn from_state(state: State) -> Result { + let sql = state.storage().sql(); + let storage: JsValue = state._inner().storage().map_err(js_error)?.into(); + let transaction_sync = Reflect::get(&storage, &JsValue::from_str("transactionSync")) + .map_err(js_error)? + .dyn_into::() + .map_err(|_| { + RepositoryError::Model("cell storage.transactionSync is required".into()) + })?; + Ok(Self { + sql, + storage: SendWrapper::new(storage), + transaction_sync: SendWrapper::new(transaction_sync), + }) + } + + pub fn executor(&self) -> CellSqlExecutor { + CellSqlExecutor(self.sql.clone()) + } + + pub fn transaction>( + &self, + operation: impl FnOnce(&mut CellSqlExecutor) -> Result, + ) -> Result { + let mut operation = Some(operation); + let mut result = None; + let mut callback = || -> Result<(), JsValue> { + let Some(operation) = operation.take() else { + return Err(JsValue::from_str("cell transaction callback invoked twice")); + }; + result = Some(operation(&mut self.executor())); + match result.as_ref() { + Some(Ok(_)) => Ok(()), + _ => Err(JsValue::from_str("cell SQL transaction failed")), + } + }; + // Any Rust error is thrown through the JS callback, causing rollback. + // Nothing outside this callback is marked committed before call1 returns. + let closure = + ScopedClosure:: Result<(), JsValue>>::borrow_mut_assert_unwind_safe( + &mut callback, + ); + let committed = self.transaction_sync.call1(&self.storage, closure.as_ref()); + drop(closure); + match (committed, result) { + (_, Some(Err(error))) => Err(error), + (Err(error), _) => Err(js_error(error).into()), + (Ok(_), Some(Ok(value))) => Ok(value), + (Ok(_), None) => { + Err(RepositoryError::Model("cell transaction callback did not run".into()).into()) + } + } + } +} + +/// Shared SQL operations are async for SQLx, but every call through the cell +/// executor completes synchronously. Refuse accidental async work; never spin +/// or let a pending future outlive the transaction callback. +pub(crate) fn finish_sql>( + future: impl Future>, +) -> Result { + let mut future = std::pin::pin!(future); + match future + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + { + Poll::Ready(result) => result, + Poll::Pending => Err(RepositoryError::Model( + "cell SQL transaction attempted to await asynchronous work".into(), + ) + .into()), + } +} + +#[derive(Clone)] +pub(crate) struct CellSqlExecutor(SqlStorage); +pub(crate) struct CellSqlRow(HashMap); + +impl CellSqlRow { + fn value(&self, column: &str) -> Result<&SqlStorageValue, RepositoryError> { + self.0 + .get(column) + .ok_or_else(|| RepositoryError::Model(format!("cell SQL row is missing {column}"))) + } + fn invalid(column: &str) -> RepositoryError { + RepositoryError::Model(format!( + "cell SQL column {column} has the wrong storage type" + )) + } +} + +impl SqlRow for CellSqlRow { + fn text(&self, column: &'static str) -> Result { + match self.value(column)? { + SqlStorageValue::String(value) => Ok(value.clone()), + _ => Err(Self::invalid(column)), + } + } + fn integer(&self, column: &'static str) -> Result { + match self.value(column)? { + SqlStorageValue::Integer(value) => Ok(*value), + _ => Err(Self::invalid(column)), + } + } + fn optional_text(&self, column: &'static str) -> Result, RepositoryError> { + match self.value(column)? { + SqlStorageValue::Null => Ok(None), + _ => self.text(column).map(Some), + } + } + fn optional_timestamp( + &self, + column: &'static str, + ) -> Result, RepositoryError> { + match self.value(column)? { + SqlStorageValue::Null => Ok(None), + _ => self.timestamp(column).map(Some), + } + } + fn optional_integer(&self, column: &'static str) -> Result, RepositoryError> { + match self.value(column)? { + SqlStorageValue::Null => Ok(None), + _ => self.integer(column).map(Some), + } + } + fn bytes(&self, column: &'static str) -> Result, RepositoryError> { + match self.value(column)? { + SqlStorageValue::Blob(value) => Ok(value.clone()), + _ => Err(Self::invalid(column)), + } + } + fn timestamp(&self, column: &'static str) -> Result { + match self.value(column)? { + SqlStorageValue::String(value) => sqlite_codec::decode(value), + SqlStorageValue::Float(value) => sqlite_codec::decode_epoch(*value), + SqlStorageValue::Integer(value) => sqlite_codec::decode_epoch(*value as f64), + _ => Err(Self::invalid(column)), + } + } +} + +impl CellSqlExecutor { + fn run(&self, statement: Statement<'_>) -> Result { + let mut sql = String::new(); + let mut bindings = Vec::new(); + for part in statement.0 { + match part { + SqlPart::TimestampCompare { + column, + operator, + value, + } => { + sql.push_str(&format!( + "CAST({column} AS REAL) {operator} CAST(? AS REAL)" + )); + bindings.push(SqlStorageValue::String(sqlite_codec::encode(value)?)); + } + SqlPart::LedgerNow | SqlPart::LedgerNowEpoch => { + sql.push_str("unixepoch('now','subsec')") + } + SqlPart::LedgerDeadline(duration) => { + sql.push_str("(unixepoch('now','subsec') + ?)"); + bindings.push(SqlStorageValue::Float(duration.as_secs_f64())); + } + SqlPart::LedgerDeadlineIsLive(deadline) => { + sql.push_str("CAST(? AS REAL) > unixepoch('now','subsec')"); + bindings.push(SqlStorageValue::String(sqlite_codec::encode(deadline)?)); + } + SqlPart::LedgerJson(json) => { + sql.push('?'); + bindings.push(SqlStorageValue::String(json)); + } + SqlPart::Sql(text) => sql.push_str(&text), + SqlPart::Bind(value) => { + sql.push('?'); + bindings.push(match value { + SqlBind::Text(value) | SqlBind::Metadata(value) => { + SqlStorageValue::String(value) + } + SqlBind::Bytes(value) => SqlStorageValue::Blob(value.into_owned()), + // Reject values outside JS's exact range, never round a + // sequence/fence silently while crossing the binding. + SqlBind::Integer(value) => { + SqlStorageValue::try_from_i64(value).map_err(|error| { + RepositoryError::Model(format!( + "cell SQL integer is not exactly representable: {error}" + )) + })? + } + SqlBind::Timestamp(value) => { + SqlStorageValue::String(sqlite_codec::encode(value)?) + } + }); + } + } + } + self.0.exec(&sql, Some(bindings)).map_err(worker_error) + } +} + +impl SqlExecutor for CellSqlExecutor { + type Row = CellSqlRow; + const EVENT_SELECT: &'static str = sqlite_codec::EVENT_SELECT; + const SNAPSHOT_SELECT: &'static str = sqlite_codec::SNAPSHOT_SELECT; + const OUTBOX_SELECT: &'static str = sqlite_codec::OUTBOX_SELECT; + const NOW: &'static str = "CURRENT_TIMESTAMP"; + const COMMAND_LEDGER_SELECT: &'static str = sqlite_codec::COMMAND_LEDGER_SELECT; + const COMMAND_LEDGER_LOCK_SUFFIX: &'static str = ""; + const COMMAND_LEDGER_COMPACTION_LOCK_SUFFIX: &'static str = ""; + + async fn query(&mut self, statement: Statement<'_>) -> Result, RepositoryError> { + let cursor = self.run(statement)?; + let names = cursor.column_names(); + cursor + .raw() + .map(|row| { + let values = row.map_err(worker_error)?; + if values.len() != names.len() { + return Err(RepositoryError::Model( + "cell SQL row width differs from its columns".into(), + )); + } + Ok(CellSqlRow(names.iter().cloned().zip(values).collect())) + }) + .collect() + } + + async fn execute(&mut self, statement: Statement<'_>) -> Result { + Ok(self.run(statement)?.rows_written() as u64) + } +} + +fn js_error(error: JsValue) -> RepositoryError { + worker_error(worker::Error::from(error)) +} +fn worker_error(error: worker::Error) -> RepositoryError { + // The SDK does not expose structured SQLite error codes. Unknown runtime + // failures must not permanently discard a command or pending delivery. + // Deterministic binding/row validation errors are classified above instead. + RepositoryError::retryable_storage("cell SQL", error) +} diff --git a/src/microsvc/cell_host/sql_store.rs b/src/microsvc/cell_host/sql_store.rs new file mode 100644 index 000000000..11ac0a053 --- /dev/null +++ b/src/microsvc/cell_host/sql_store.rs @@ -0,0 +1,344 @@ +//! Cell-local execution of the ordinary SQL event/snapshot/receipt repository. +//! No in-memory working copy and no whole-cell export are used here. + +use sha2::{Digest, Sha256}; +use worker::State; + +use super::sql_executor::{finish_sql, CellSqlConnection}; +use crate::command_ledger::{ + AttemptFence, CausalCommitBatch, CausalGetStream, CausalRepositoryIdentity, + CausalStorageIdentity, CausalTransactionalCommit, CommandCompletion, CommandLedgerError, + CommandLedgerKey, CommandLedgerStore, CommandLookup, CommandLookupScope, CommandReservation, + ReservationOutcome, +}; +use crate::entity::Entity; +use crate::microsvc::HasOutboxStore; +use crate::outbox::{OutboxMessage, OutboxMessageStatus}; +use crate::outbox_worker::{ClaimOutboxMessages, OutboxBacklogStats, OutboxClaimRef, OutboxStore}; +use crate::repository::sql::{self, ledger, outbox, SqlExecutor, SqlRow, Statement}; +use crate::repository::{ + validate_commit_batch, CommitBatch, GetStream, RepositoryError, SnapshotStore, SnapshotWrite, + StreamIdentity, TransactionalCommit, +}; +use crate::snapshot::SnapshotRecord; + +// Durable Object SQL limits bindings per statement, not per transaction. +// Both shared insert planners split large commands within the same transaction. +const MAX_SQL_BIND_PARAMS: usize = 100; + +#[derive(Clone)] +pub(super) struct CellSqlRepository { + connection: CellSqlConnection, + identity: CausalStorageIdentity, +} + +impl CellSqlRepository { + pub fn from_state(state: State) -> Result { + let connection = CellSqlConnection::from_state(state)?; + connection.transaction(|executor| finish_sql(async { + let tables = executor.query(Statement::new("SELECT name FROM sqlite_master WHERE type = 'table'")).await?; + let tables = tables.iter().map(|row| row.text("name")).collect::, _>>()?; + if tables.iter().any(|name| name == "cell_state") { + return Err(RepositoryError::Model("whole-state cell storage requires an explicit migration before opening this version".into())); + } + let registered = tables.iter().any(|name| name == "__distributed_cell_migrations"); + if !registered && tables.iter().any(|name| matches!(name.as_str(), "aggregate_events" | "aggregate_snapshots" | "command_ledger" | "outbox_messages")) { + return Err(RepositoryError::Model("unregistered cell SQL schema requires an explicit migration".into())); + } + executor.execute(Statement::new("CREATE TABLE IF NOT EXISTS __distributed_cell_migrations (version INTEGER PRIMARY KEY, checksum TEXT NOT NULL)")).await?; + let applied = executor.query(Statement::new("SELECT version, checksum FROM __distributed_cell_migrations ORDER BY version")).await?; + let migrations = crate::repository::migrations::cell_migrations().collect::>(); + if applied.len() > migrations.len() { + return Err(RepositoryError::Model("cell SQL schema is newer than this runtime".into())); + } + for (row, migration) in applied.iter().zip(&migrations) { + let checksum = format!("{:x}", Sha256::digest(migration.sql.as_bytes())); + if row.integer("version")? != migration.version || row.text("checksum")? != checksum { + return Err(RepositoryError::Model("cell SQL migration history differs from this runtime".into())); + } + } + for migration in migrations.iter().skip(applied.len()) { + // Execute the original validated migration as a whole; never + // split SQL on semicolons or maintain a parallel schema copy. + executor.execute(Statement::new(migration.sql)).await?; + let checksum = format!("{:x}", Sha256::digest(migration.sql.as_bytes())); + executor.execute(Statement::new("INSERT INTO __distributed_cell_migrations (version, checksum) VALUES (") + .bind(migration.version.into()).sql(", ").bind(checksum.as_str().into()).sql(")")).await?; + } + Ok::<_, RepositoryError>(()) + }))?; + Ok(Self { + connection, + identity: CausalStorageIdentity::new(), + }) + } + + fn commit( + &self, + batch: CommitBatch<'_>, + completion: Option, + ) -> Result<(), CommandLedgerError> { + if !batch.read_model_plans.is_empty() || !batch.inbox_receipts.is_empty() { + return Err(CommandLedgerError::Invalid( + "aggregate cells accept command effects, not projection or consumer-inbox writes" + .into(), + )); + } + let prepared = validate_commit_batch(&batch)?; + self.connection.transaction(|executor| { + finish_sql(async { + if let Some(completion) = &completion { + ledger::preflight(executor, completion).await?; + } + for append in &prepared { + let actual = sql::stream_version(executor, &append.identity).await?; + if actual != append.expected_version { + return Err(RepositoryError::ConcurrentWrite { + id: append.identity.to_string(), + expected: append.expected_version, + actual, + } + .into()); + } + } + for insert in sql::event_inserts(&prepared, MAX_SQL_BIND_PARAMS)? { + executor.execute(insert.statement).await?; + } + // SQL binding errors in this runtime have no structured constraint + // code. Check identities within the same synchronous transaction, + // where another request cannot insert between this read and write. + for message in &batch.outbox_messages { + let existing = executor + .query( + Statement::new( + "SELECT message_id FROM outbox_messages WHERE message_id = ", + ) + .bind(message.id().into()), + ) + .await?; + if !existing.is_empty() { + return Err(RepositoryError::DuplicateOutboxMessageInBatch { + id: message.id().into(), + } + .into()); + } + } + for insert in outbox::inserts(&batch.outbox_messages, MAX_SQL_BIND_PARAMS)? { + executor.execute(insert.statement).await?; + } + for snapshot in &batch.snapshots { + let SnapshotWrite::Save { identity, record } = snapshot; + sql::save_snapshot(executor, identity, record).await?; + } + // Same final fenced write as SQLx. A lost lease rolls back every + // participant, and entities are marked committed only afterwards. + if let Some(completion) = &completion { + ledger::complete(executor, completion).await?; + } + Ok::<_, CommandLedgerError>(()) + }) + })?; + for stream in batch.streams { + stream.entity.mark_committed(); + } + Ok(()) + } +} + +impl GetStream for CellSqlRepository { + async fn get_stream( + &self, + identity: &StreamIdentity, + ) -> Result, RepositoryError> { + finish_sql(sql::load_stream( + &mut self.connection.executor(), + identity, + None, + )) + } + async fn get_stream_tail( + &self, + identity: &StreamIdentity, + after_version: u64, + ) -> Result, RepositoryError> { + finish_sql(sql::load_stream( + &mut self.connection.executor(), + identity, + Some(after_version), + )) + } +} +impl CausalGetStream for CellSqlRepository { + fn get_causal_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl std::future::Future, RepositoryError>> + Send + 'a + { + GetStream::get_stream_tail(self, identity, after_version) + } + async fn get_causal_stream( + &self, + identity: &StreamIdentity, + ) -> Result, RepositoryError> { + self.get_stream(identity).await + } +} +impl SnapshotStore for CellSqlRepository { + async fn get_snapshot( + &self, + identity: &StreamIdentity, + ) -> Result, RepositoryError> { + finish_sql(sql::load_snapshot( + &mut self.connection.executor(), + identity, + )) + } + async fn save_snapshot( + &self, + identity: &StreamIdentity, + record: SnapshotRecord, + ) -> Result<(), RepositoryError> { + self.connection + .transaction(|executor| finish_sql(sql::save_snapshot(executor, identity, &record))) + } + async fn delete_snapshot(&self, identity: &StreamIdentity) -> Result { + self.connection + .transaction(|executor| finish_sql(sql::delete_snapshot(executor, identity))) + } +} +impl CausalRepositoryIdentity for CellSqlRepository { + fn causal_storage_identity(&self) -> CausalStorageIdentity { + self.identity + } +} +impl CommandLedgerStore for CellSqlRepository { + async fn reserve_command( + &self, + reservation: CommandReservation, + ) -> Result { + self.connection + .transaction(|executor| finish_sql(ledger::reserve(executor, &reservation))) + } + async fn lookup_command( + &self, + key: &CommandLedgerKey, + scope: CommandLookupScope<'_>, + ) -> Result { + self.connection + .transaction(|executor| finish_sql(ledger::lookup(executor, key, scope))) + } + async fn mark_retryable_unknown( + &self, + attempt: AttemptFence, + ) -> Result<(), CommandLedgerError> { + self.connection + .transaction(|executor| finish_sql(ledger::mark_retryable(executor, &attempt))) + } + async fn compact_expired_commands(&self, limit: usize) -> Result { + self.connection + .transaction(|executor| finish_sql(ledger::compact(executor, limit))) + } +} +impl TransactionalCommit for CellSqlRepository { + async fn commit_batch(&self, batch: CommitBatch<'_>) -> Result<(), RepositoryError> { + self.commit(batch, None).map_err(|error| match error { + CommandLedgerError::Storage(error) => error, + error => RepositoryError::Model(error.to_string()), + }) + } +} +impl CausalTransactionalCommit for CellSqlRepository { + async fn commit_causal_batch( + &self, + batch: CausalCommitBatch<'_>, + ) -> Result<(), CommandLedgerError> { + if batch.direct_projection.is_some() { + return Err(CommandLedgerError::Invalid( + "aggregate cells do not execute read-model projections".into(), + )); + } + self.commit(batch.domain, Some(batch.completion)) + } +} + +#[derive(Clone)] +pub struct CellSqlOutboxStore { + connection: CellSqlConnection, +} + +impl HasOutboxStore for CellSqlRepository { + type OutboxStore = CellSqlOutboxStore; + fn outbox_store(&self) -> Self::OutboxStore { + CellSqlOutboxStore { + connection: self.connection.clone(), + } + } +} + +impl OutboxStore for CellSqlOutboxStore { + async fn messages_by_status( + &self, + status: OutboxMessageStatus, + limit: usize, + ) -> Result, RepositoryError> { + // Match the native no-practical-bound convention within JS's exact + // integer range; never round a bound crossing the runtime binding. + let limit = (limit as u64).min(9_007_199_254_740_991) as i64; + let rows = finish_sql( + self.connection.executor().query( + Statement::new("SELECT ") + .sql(crate::repository::sqlite_codec::OUTBOX_SELECT) + .sql(" FROM outbox_messages WHERE status = ") + .bind(status.as_str().into()) + .sql(" ORDER BY CAST(created_at AS REAL), message_id LIMIT ") + .bind(limit.into()), + ), + )?; + rows.into_iter().map(outbox::from_row).collect() + } + async fn backlog_stats(&self) -> Result { + let rows = finish_sql(self.connection.executor().query(Statement::new("SELECT COUNT(*) AS pending, MIN(CAST(created_at AS REAL)) AS oldest FROM outbox_messages WHERE status = 'pending'")))?; + let row = rows + .first() + .ok_or_else(|| RepositoryError::Model("outbox count returned no row".into()))?; + Ok(OutboxBacklogStats { + pending: usize::try_from(row.integer("pending")?) + .map_err(|_| RepositoryError::Model("outbox count is not representable".into()))?, + oldest_created_at: row.optional_timestamp("oldest")?, + }) + } + async fn claim( + &self, + request: ClaimOutboxMessages, + ) -> Result, RepositoryError> { + self.connection.transaction(|executor| { + finish_sql(outbox::claim_sqlite(executor, request, crate::time::now())) + }) + } + async fn complete(&self, claim: &OutboxClaimRef) -> Result<(), RepositoryError> { + self.settle(claim, outbox::Transition::Complete) + } + async fn release(&self, claim: &OutboxClaimRef, error: &str) -> Result<(), RepositoryError> { + self.settle(claim, outbox::Transition::Release(error)) + } + async fn fail(&self, claim: &OutboxClaimRef, error: &str) -> Result<(), RepositoryError> { + self.settle(claim, outbox::Transition::Fail(error)) + } +} +impl CellSqlOutboxStore { + fn settle( + &self, + claim: &OutboxClaimRef, + transition: outbox::Transition<'_>, + ) -> Result<(), RepositoryError> { + self.connection.transaction(|executor| { + finish_sql(outbox::transition( + executor, + claim, + transition, + crate::time::now(), + )) + }) + } +} diff --git a/src/microsvc/cell_host/store.rs b/src/microsvc/cell_host/store.rs index 1c1237c7f..af4b3b199 100644 --- a/src/microsvc/cell_host/store.rs +++ b/src/microsvc/cell_host/store.rs @@ -1,40 +1,39 @@ -//! 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. +//! One cell's private command-side repository, with enforced stream ownership. +//! Workers use runtime-owned SQLite; native tests use the in-process reference host. use std::future::Future; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] +use std::sync::Mutex; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] use serde_json::Value; +#[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] +use super::sql_store::CellSqlRepository; use crate::command_ledger::{ AttemptFence, CausalCommitBatch, CausalGetStream, CausalRepositoryIdentity, CausalStorageIdentity, CausalTransactionalCommit, CommandLedgerError, CommandLedgerKey, CommandLedgerStore, CommandLookup, CommandLookupScope, CommandReservation, ReservationOutcome, }; -use crate::entity::{Entity, EventRecord}; +use crate::entity::Entity; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] +use crate::entity::EventRecord; use crate::microsvc::HasOutboxStore; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] 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}; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] +use crate::InMemoryRepository; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] +type CellRepository = InMemoryRepository; +#[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] +type CellRepository = CellSqlRepository; +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] use serde::{Deserialize, Serialize}; #[derive(Clone)] @@ -49,31 +48,17 @@ enum CellOwnership { }, } -/// 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. +/// One stream's event records in a native restart-test export. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub struct DurableCellEvents { pub stream: String, pub events: Vec, } -/// Snapshot cache record for Durable Object SQLite persistence. +/// Snapshot cache record in a native restart-test export. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub struct DurableCellSnapshot { pub stream: String, pub aggregate_type: String, @@ -85,24 +70,23 @@ pub struct DurableCellSnapshot { pub payload: Vec, } -/// One versioned command-ledger row for Durable Object SQLite persistence. +/// One versioned command-ledger row in a native restart-test export. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub struct DurableCellCommand { pub id: String, pub body: String, } -/// Current persisted aggregate-cell state envelope version. +/// Current native restart-test export version. +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub const DURABLE_AGGREGATE_CELL_STATE_VERSION: u16 = 1; -/// Complete durable working copy for one aggregate cell. -/// -/// Hosts should serialize this value and persist it with one storage write. -/// That makes the event log, snapshot cache, command ledger, outbox, and sealed -/// row one commit even when a Worker SDK does not expose celld's -/// `transactionSync` API. +/// Export of the native in-process reference host for restart conformance tests. +/// This is not a production cell persistence API; Workers use SQLite rows. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub struct DurableAggregateCellState { pub version: u16, pub events: Vec, @@ -112,15 +96,61 @@ pub struct DurableAggregateCellState { pub sealed_row: Option, } +/// Private command-side repository for one cell instance (`{aggregate_type}:{shard}`). +/// +/// Exclusive cells reject any stream that is not this cell's shard. Parent +/// cells hold sibling streams and commit them in one [`CommitBatch`]. +/// +/// ```compile_fail +/// fn two_cell_transaction_does_not_exist( +/// left: &distributed::cell_host::CellStreamStore, +/// right: &distributed::cell_host::CellStreamStore, +/// batch: distributed::CommitBatch<'_>, +/// ) { +/// let _ = left.commit_across(right, batch); +/// } +/// ``` #[derive(Clone)] pub struct CellStreamStore { ownership: CellOwnership, - inner: InMemoryRepository, + inner: CellRepository, + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] sealed_row: Arc>>, } impl CellStreamStore { + /// Open this runtime-owned SQLite database; no storage export callback exists. + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + pub fn from_state( + state: worker::State, + identity: StreamIdentity, + ) -> Result { + Ok(Self { + ownership: CellOwnership::Exclusive(identity), + inner: CellSqlRepository::from_state(state)?, + }) + } + + /// Open a parent-shard SQL cell. All owned sibling streams share this one + /// database; the predicate never grants access to another cell's storage. + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + pub fn from_parent_state( + state: worker::State, + 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: CellSqlRepository::from_state(state)?, + }) + } + /// Bind a store to one exact stream identity. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn for_identity(identity: StreamIdentity) -> Self { Self { ownership: CellOwnership::Exclusive(identity), @@ -133,6 +163,7 @@ impl CellStreamStore { /// /// Child streams of any aggregate type live in this cell's SQLite. A /// transaction across two parent cells does not exist. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn for_parent_shard( parent_type: impl Into, parent_id: impl Into, @@ -149,6 +180,7 @@ impl CellStreamStore { } /// Named exclusive-cell constructor used by [`super::AggregateCell`]. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn new( aggregate_type: impl Into, shard_id: impl Into, @@ -190,6 +222,7 @@ impl CellStreamStore { } /// Sealed read-model row for GET on this cell instance. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn sealed_row(&self) -> Result, RepositoryError> { self.sealed_row .lock() @@ -198,6 +231,7 @@ impl CellStreamStore { } /// Replace the sealed read-model row (Atomic board / Todo view). + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn replace_sealed_row(&self, row: Value) -> Result<(), RepositoryError> { let mut guard = self .sealed_row @@ -208,6 +242,7 @@ impl CellStreamStore { } /// Event log for Durable Object SQLite. Memory remains the working copy. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_events(&self) -> Result, RepositoryError> { Ok(self .inner @@ -218,11 +253,13 @@ impl CellStreamStore { } /// Outbox rows committed with this cell's events (same private SQLite). + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_outbox(&self) -> Result, RepositoryError> { self.inner.clone_outbox() } /// Restore outbox rows from Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_outbox( &self, messages: Vec, @@ -231,6 +268,7 @@ impl CellStreamStore { } /// Replace the working event log from Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_events( &self, events: Vec, @@ -244,6 +282,7 @@ impl CellStreamStore { } /// Snapshot cache for Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_snapshots(&self) -> Result, RepositoryError> { Ok(self .inner @@ -263,6 +302,7 @@ impl CellStreamStore { } /// Replace the working snapshot cache from Durable Object SQLite. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_snapshots( &self, snapshots: Vec, @@ -291,6 +331,7 @@ impl CellStreamStore { } /// Fenced command rows committed with this cell's domain effects. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_commands(&self) -> Result, RepositoryError> { self.inner .clone_command_ledger()? @@ -306,6 +347,7 @@ impl CellStreamStore { } /// Restore the complete command ledger before accepting another request. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_commands( &self, commands: Vec, @@ -326,6 +368,7 @@ impl CellStreamStore { } /// Export every durable concern as one versioned persistence envelope. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn durable_state(&self) -> Result { Ok(DurableAggregateCellState { version: DURABLE_AGGREGATE_CELL_STATE_VERSION, @@ -338,6 +381,7 @@ impl CellStreamStore { } /// Replace the complete working copy from one persisted envelope. + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] pub fn restore_durable_state( &self, state: DurableAggregateCellState, @@ -377,6 +421,19 @@ impl CellStreamStore { } impl CausalGetStream for CellStreamStore { + fn get_causal_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl std::future::Future, RepositoryError>> + Send + 'a + { + async move { + self.ensure_identity(identity)?; + self.inner + .get_causal_stream_tail(identity, after_version) + .await + } + } fn get_causal_stream<'a>( &'a self, identity: &'a StreamIdentity, @@ -519,164 +576,27 @@ impl CausalTransactionalCommit for CellStreamStore { } impl HasOutboxStore for CellStreamStore { - type OutboxStore = InMemoryOutboxStore; + #[cfg(not(all(feature = "workers-rs", target_arch = "wasm32")))] + type OutboxStore = crate::InMemoryOutboxStore; + #[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] + type OutboxStore = super::sql_store::CellSqlOutboxStore; 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( +// A command-only cell cannot bootstrap same-transaction read-model projections. +impl crate::microsvc::dependencies::CausalHostProjections for CellStreamStore { + async fn __register_direct_projection_models( &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) + _topology: &crate::projection_protocol::ProjectorTopologyId, + _ownership: &[crate::projection_protocol::ProjectionModelOwnership], + ) -> Result<(), crate::projection_protocol::ProjectionProtocolError> { + Err( + crate::projection_protocol::ProjectionProtocolError::InvalidBatch( + "aggregate cells do not execute read-model projections".into(), + ), + ) } } diff --git a/src/microsvc/cell_host/tests.rs b/src/microsvc/cell_host/tests.rs index a6f536b5c..f93c682f4 100644 --- a/src/microsvc/cell_host/tests.rs +++ b/src/microsvc/cell_host/tests.rs @@ -3,8 +3,8 @@ use super::{ CellNamespace, CellStreamStore, DURABLE_AGGREGATE_CELL_STATE_VERSION, }; use crate::aggregate::{Aggregate, AggregateRepository}; +use crate::command::{typed_command, PreparedCommand, Succeeded}; 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; @@ -24,7 +24,7 @@ struct CellItem { done: bool, } -#[sourced(entity, aggregate_type = "CellItem")] +#[sourced(entity, aggregate_type = "CellItem", events = "CellItemEvent")] impl CellItem { #[event("cell_item.created", version = 1)] fn create(&mut self, id: String, title: String) { @@ -37,25 +37,31 @@ impl CellItem { fn complete(&mut self) { self.done = true; } + + #[event("cell_item.published", version = 1, domain = event)] + fn publish(&mut self, id: String, title: String) { + self.entity.set_id(id); + self.title = title; + } } -#[derive(Debug, Deserialize, crate::GraphqlInput)] +#[derive(Debug, Deserialize, crate::CommandInput)] struct CreateInput { id: String, title: String, } -#[derive(Debug, Serialize, crate::GraphqlOutput)] +#[derive(Debug, Serialize, crate::CommandOutput)] struct CreatePayload { id: String, } -#[derive(Debug, Deserialize, crate::GraphqlInput)] +#[derive(Debug, Deserialize, crate::CommandInput)] struct CompleteInput { id: String, } -#[derive(Debug, Serialize, crate::GraphqlOutput)] +#[derive(Debug, Serialize, crate::CommandOutput)] struct CompletePayload { id: String, done: bool, @@ -134,6 +140,91 @@ fn owner_session() -> Session { session } +struct Publish; + +impl PortableCommand for Publish +where + D: crate::microsvc::CausalRouteDependencies + Send + Sync + 'static, +{ + fn install(self, routes: Routes) -> Routes { + routes + .typed_command(typed_command::>( + "cell_item.publish", + )) + .guarded( + |ctx: &CausalCommandContext<'_, CellItem>| ctx.session().user_id().is_some(), + handle_publish, + ) + } +} + +async fn handle_publish( + ctx: &CausalCommandContext<'_, CellItem>, + input: CreateInput, +) -> Result>, HandlerError> { + let repo = ctx.repo(); + let mut item = repo.create(); + item.publish(input.id.clone(), input.title) + .map_err(|error| HandlerError::Rejected(error.to_string()))?; + repo.publish_events() + .commit(item)? + .succeeded(CreatePayload { id: input.id }) +} + +#[tokio::test] +async fn command_replay_retains_confirmation_evidence_without_outbox_history() { + let cell = AggregateCell::::new("published-item") + .unwrap() + .mount(Publish); + let identity = CellCommandIdentity::new( + "cell-test-service", + "principal-alice", + "0190a000-0000-7000-8000-000000000405", + ) + .unwrap(); + let input = json!({ "id": "published-item", "title": "once" }); + let first = cell + .dispatch_idempotent( + "cell_item.publish", + &identity, + input.clone(), + owner_session(), + ) + .await + .unwrap(); + assert_eq!(first.projection_events().len(), 1); + assert_eq!( + first.projection_events()[0].event_type, + "cell_item.published" + ); + assert_eq!(cell.durable_outbox().unwrap().len(), 1); + + // Restore only domain history and the receipt, as after a completed drain. + // The retry must not require a single delivery record to be retained. + let reopened = AggregateCell::::new("published-item") + .unwrap() + .mount(Publish); + reopened + .restore_durable_events(cell.durable_events().unwrap()) + .unwrap(); + reopened + .restore_durable_commands(cell.durable_commands().unwrap()) + .unwrap(); + assert!(reopened.durable_outbox().unwrap().is_empty()); + let replay = reopened + .dispatch_idempotent("cell_item.publish", &identity, input, owner_session()) + .await + .unwrap(); + assert!(replay.replayed()); + assert_eq!(replay.payload(), first.payload()); + assert_eq!(replay.projection_events(), first.projection_events()); + assert!( + reopened.durable_outbox().unwrap().is_empty(), + "replay must not republish" + ); + assert_eq!(reopened.durable_events().unwrap()[0].events.len(), 1); +} + fn fn_send_sync(_: &T) {} #[tokio::test] @@ -304,6 +395,7 @@ async fn cell_wait_path_replays_the_same_command_without_new_domain_effects() { assert!(replay.replayed()); assert_eq!(replay.payload(), first.payload()); assert_eq!(replay.causation_id(), first.causation_id()); + assert_eq!(replay.projection_events(), first.projection_events()); let events = cell.durable_events().unwrap(); assert_eq!( events @@ -341,6 +433,12 @@ async fn cell_wait_path_replays_the_same_command_without_new_domain_effects() { .expect("durable replay after restart"); assert!(replay_after_restart.replayed()); assert_eq!(replay_after_restart.causation_id(), first.causation_id()); + assert!(restored.durable_outbox().unwrap().is_empty()); + assert_eq!( + replay_after_restart.projection_events(), + first.projection_events(), + "confirmation evidence must survive restart with no outbox history" + ); assert_eq!( restored .durable_events() diff --git a/src/microsvc/cell_host/wire.rs b/src/microsvc/cell_host/wire.rs index 59d55c2c3..a81bb4f38 100644 --- a/src/microsvc/cell_host/wire.rs +++ b/src/microsvc/cell_host/wire.rs @@ -18,7 +18,7 @@ const MAX_METADATA_VALUE_BYTES: usize = 1024; /// /// This is not a delivery record. Delivery status, leases, attempts, and /// settlement belong exclusively to the cell's durable outbox and Queue. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CellProjectionEventWireItem { pub id: String, @@ -149,8 +149,8 @@ pub fn parse_cell_projection_events( } /// Select the exact events committed by one causal command and encode them as -/// delivery-neutral projection evidence. Published rows remain valid evidence; -/// Queue settlement must not erase the data needed by optimistic replicas. +/// delivery-neutral projection evidence before commit. Store the result with +/// the command receipt; never recover it by retaining delivered outbox rows. pub fn cell_projection_event_evidence( messages: &[OutboxMessage], causation_id: &str, diff --git a/src/microsvc/context.rs b/src/microsvc/context.rs index 4737390f4..c3ccefa96 100644 --- a/src/microsvc/context.rs +++ b/src/microsvc/context.rs @@ -11,6 +11,7 @@ use super::dependencies::{HasReadModelStore, HasRepo}; use super::error::HandlerError; use super::session::Session; use crate::bus::Message; +use crate::Aggregate; /// The context passed to every handler. /// @@ -82,6 +83,22 @@ impl<'a, D> Context<'a, D> { self.message } + /// Carry the current message's causal command identity into events emitted + /// by a downstream aggregate. + /// + /// Event-driven policies should call this before invoking aggregate + /// transitions. Captured and explicitly published domain events then retain + /// the same causal projection qualification as the event being handled. + pub fn inherit_causation(&self, aggregate: &mut A) -> Result<(), HandlerError> { + let causation_id = self.message.causation_id().ok_or_else(|| { + HandlerError::DecodeFailed( + "causal event handler input is missing a causation ID".into(), + ) + })?; + aggregate.entity_mut().set_causation_id(causation_id); + Ok(()) + } + /// Get the session. pub fn session(&self) -> &Session { &self.session @@ -130,3 +147,70 @@ impl<'a, D> Context<'a, D> { fields.iter().all(|f| self.has_field(f)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::bus::MessageKind; + use crate::trace_context::CAUSATION_ID; + use crate::{Entity, EventRecord}; + + #[derive(Default)] + struct DownstreamAggregate { + entity: Entity, + } + + impl Aggregate for DownstreamAggregate { + type ReplayError = String; + + 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(()) + } + } + + #[test] + fn handler_context_propagates_causation_to_new_events() { + let message = Message::new("source.event", MessageKind::Event, b"{}".to_vec()) + .with_metadata(CAUSATION_ID, "cause-1"); + let dependencies = (); + let context = Context::new( + &message, + Value::Object(Default::default()), + Session::new(), + &dependencies, + ); + let mut aggregate = DownstreamAggregate::default(); + + context.inherit_causation(&mut aggregate).unwrap(); + aggregate + .entity + .digest_empty("downstream.recorded") + .unwrap(); + + assert_eq!(aggregate.entity.events()[0].causation_id(), Some("cause-1")); + } + + #[test] + fn handler_context_rejects_missing_causation() { + let message = Message::new("source.event", MessageKind::Event, b"{}".to_vec()); + let dependencies = (); + let context = Context::new( + &message, + Value::Object(Default::default()), + Session::new(), + &dependencies, + ); + let mut aggregate = DownstreamAggregate::default(); + + assert!(context.inherit_causation(&mut aggregate).is_err()); + assert!(aggregate.entity.events().is_empty()); + } +} diff --git a/src/microsvc/dependencies.rs b/src/microsvc/dependencies.rs index d618e2d2a..e9870e831 100644 --- a/src/microsvc/dependencies.rs +++ b/src/microsvc/dependencies.rs @@ -28,7 +28,7 @@ pub trait CausalRepositoryBackend: + CommandLedgerStore + CausalTransactionalCommit + CausalRepositoryIdentity - + ProjectionProtocolStore + + CausalHostProjections + TransactionalCommit + Send + Sync @@ -41,7 +41,7 @@ impl CausalRepositoryBackend for T where + CommandLedgerStore + CausalTransactionalCommit + CausalRepositoryIdentity - + ProjectionProtocolStore + + CausalHostProjections + TransactionalCommit + Send + Sync @@ -49,6 +49,114 @@ impl CausalRepositoryBackend for T where { } +pub(crate) trait CausalHostProjections: Send + Sync { + #[cfg(feature = "graphql")] + fn command_obligation_evidence<'a>( + &'a self, + _request: &'a crate::projection_protocol::ProjectionObligationEvidenceBatchRequest, + ) -> impl std::future::Future< + Output = Result< + crate::projection_protocol::ProjectionObligationEvidenceBatch, + crate::projection_protocol::ProjectionProtocolError, + >, + > + Send + + 'a { + async { + Err( + crate::projection_protocol::ProjectionProtocolError::InvalidBatch( + "command-only cells do not query projection evidence".into(), + ), + ) + } + } + + #[cfg(feature = "graphql")] + fn command_causation_evidence<'a>( + &'a self, + _request: &'a crate::projection_protocol::ProjectionCausationEvidenceRequest, + ) -> impl std::future::Future< + Output = Result< + crate::projection_protocol::ProjectionCausationEvidenceBatch, + crate::projection_protocol::ProjectionProtocolError, + >, + > + Send + + 'a { + async { + Err( + crate::projection_protocol::ProjectionProtocolError::InvalidBatch( + "command-only cells do not query projection evidence".into(), + ), + ) + } + } + /// Bootstrap direct projections only on hosts that own a projection store. + /// Command-only hosts reject this operation without inventing query storage. + fn __register_direct_projection_models<'a>( + &'a self, + topology: &'a crate::projection_protocol::ProjectorTopologyId, + ownership: &'a [crate::projection_protocol::ProjectionModelOwnership], + ) -> impl std::future::Future< + Output = Result<(), crate::projection_protocol::ProjectionProtocolError>, + > + Send + + 'a; +} + +// Concrete query-owning adapters forward only the causal host operations. +// A queued command-only adapter forwards this smaller capability independently. +macro_rules! direct_causal_projection_methods { + () => { + #[cfg(feature = "graphql")] + async fn command_obligation_evidence( + &self, + request: &crate::projection_protocol::ProjectionObligationEvidenceBatchRequest, + ) -> Result< + crate::projection_protocol::ProjectionObligationEvidenceBatch, + crate::projection_protocol::ProjectionProtocolError, + > { + crate::projection_protocol::ProjectionProtocolStore::projection_obligation_evidence_batch( + self, request, + ).await + } + + #[cfg(feature = "graphql")] + async fn command_causation_evidence( + &self, + request: &crate::projection_protocol::ProjectionCausationEvidenceRequest, + ) -> Result< + crate::projection_protocol::ProjectionCausationEvidenceBatch, + crate::projection_protocol::ProjectionProtocolError, + > { + crate::projection_protocol::ProjectionProtocolStore::projection_causation_evidence( + self, request, + ).await + } + + async fn __register_direct_projection_models( + &self, + topology: &crate::projection_protocol::ProjectorTopologyId, + ownership: &[crate::projection_protocol::ProjectionModelOwnership], + ) -> Result<(), crate::projection_protocol::ProjectionProtocolError> { + crate::projection_protocol::ProjectionProtocolStore::register_projection_models( + self, topology, ownership, + ).await + } + }; +} +#[cfg(all(test, feature = "graphql"))] +pub(crate) use direct_causal_projection_methods; + +impl CausalHostProjections for crate::InMemoryRepository { + direct_causal_projection_methods!(); +} + +#[cfg(any(feature = "sqlite", feature = "postgres"))] +impl CausalHostProjections for crate::sqlx_repo::repo::SqlxRepository +where + Self: ProjectionProtocolStore, +{ + direct_causal_projection_methods!(); +} + /// Compile-time extraction of the one aggregate repository owned by a typed /// causal route bundle. /// @@ -346,6 +454,21 @@ mod tests { fn assert_has_outbox_store() {} + fn assert_causal_backend() {} + + // Keep this generic: a concrete SQL/memory instantiation would also satisfy + // ProjectionProtocolStore and conceal an accidental query-store bound. + fn assert_queued_causal_backend() { + assert_causal_backend::>(); + } + + #[test] + fn causal_capability_resolves_without_a_projection_store() { + assert_queued_causal_backend::( + ); + assert_queued_causal_backend::(); + } + #[test] fn has_outbox_store_resolves_through_repo_wrappers() { // The capability must resolve for the leaf repo and through the diff --git a/src/microsvc/lifecycle.rs b/src/microsvc/lifecycle.rs new file mode 100644 index 000000000..ef30522c8 --- /dev/null +++ b/src/microsvc/lifecycle.rs @@ -0,0 +1,169 @@ +//! Supervisor-owned membership, independent of a process's startup generation. +//! No compatibility-only shortcut: a retained process must be explicitly named +//! by its launch identity in the active cohort. This is dev coordination, not +//! a security boundary against an operator who can edit the lifecycle directory. +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ActiveGeneration { + pub generation_id: String, + pub release_id: String, + pub topology_id: String, + pub compatibility_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Member { + instance_id: String, + generation_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct State { + schema_version: u32, + phase: String, + active: ActiveGeneration, + members: BTreeMap, +} + +pub(crate) struct Membership { + pub generation: ActiveGeneration, + pub mutations_open: bool, +} + +fn identity(value: &str) -> bool { + !value.is_empty() + && value.len() <= 512 + && value == value.trim() + && !value.chars().any(char::is_control) +} + +fn resolve(source: &[u8], member_id: &str, instance_id: &str, startup: &str) -> Option { + if source.len() > 1024 * 1024 || ![member_id, instance_id, startup].into_iter().all(identity) { + return None; + } + let state: State = serde_json::from_slice(source).ok()?; + if state.schema_version != 1 + || !matches!(state.phase.as_str(), "active" | "preparing") + || state.members.len() > 64 + { + return None; + } + let member = state.members.get(member_id)?; + if member.instance_id != instance_id || member.generation_id != startup { + return None; + } + let generation = state.active; + if ![ + &generation.generation_id, + &generation.release_id, + &generation.topology_id, + &generation.compatibility_id, + ] + .into_iter() + .all(|value| identity(value)) + { + return None; + } + Some(Membership { + generation, + mutations_open: state.phase == "active", + }) +} + +pub(crate) fn membership_from_environment() -> Option { + let root = std::path::PathBuf::from(std::env::var_os("DISTRIBUTED_LIFECYCLE_DIR")?); + if !root.is_absolute() { + return None; + } + let state = root.join("dev.json"); + let metadata = std::fs::symlink_metadata(&state).ok()?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 1024 * 1024 { + return None; + } + // Bound the actual read too, even if the file changes after metadata lookup. + use std::io::Read; + let mut source = Vec::new(); + std::fs::File::open(state) + .ok()? + .take(1024 * 1024 + 1) + .read_to_end(&mut source) + .ok()?; + resolve( + &source, + &std::env::var("DISTRIBUTED_MEMBER_ID").ok()?, + &std::env::var("DISTRIBUTED_PROCESS_INSTANCE_ID").ok()?, + &std::env::var("DISTRIBUTED_GENERATION_ID").ok()?, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn state(phase: &str, active: &str, instance: &str, startup: &str) -> serde_json::Value { + json!({"schemaVersion":1,"phase":phase,"active":{ + "generationId":active,"releaseId":format!("release:{active}"), + "topologyId":"topology","compatibilityId":"compatibility"}, + "members":{"api":{"instanceId":instance,"generationId":startup}}}) + } + fn check(value: &serde_json::Value, instance: &str, startup: &str) -> Option { + resolve( + &serde_json::to_vec(value).unwrap(), + "api", + instance, + startup, + ) + } + #[test] + fn retained_member_follows_active_generation_without_restart() { + for active in ["one", "two", "three"] { + let resolved = + check(&state("active", active, "api-one", "one"), "api-one", "one").unwrap(); + assert!(resolved.mutations_open); + assert_eq!(resolved.generation.generation_id, active); + assert_eq!(resolved.generation.release_id, format!("release:{active}")); + } + } + #[test] + fn preparing_preserves_old_reader_identity_but_fences_all_writes() { + let value = state("preparing", "one", "api-one", "one"); + let old = check(&value, "api-one", "one").unwrap(); + assert!(!old.mutations_open); + assert_eq!(old.generation.generation_id, "one"); + assert!(check(&value, "candidate", "two").is_none()); + } + #[test] + fn replacement_and_rollback_retire_old_launches_even_with_identical_generations() { + for (active, instance, startup) in [("two", "api-two", "two"), ("one", "rollback", "one")] { + let value = state("active", active, instance, startup); + assert!(check(&value, instance, startup).unwrap().mutations_open); + assert!(check(&value, "api-one", "one").is_none()); + assert!(check(&value, "api-two", "one").is_none()); + } + } + #[test] + fn missing_malformed_unknown_and_retired_members_fail_closed() { + let original = state("active", "one", "api-one", "one"); + for key in ["schemaVersion", "phase", "active", "members"] { + let mut value = original.clone(); + value.as_object_mut().unwrap().remove(key); + assert!(check(&value, "api-one", "one").is_none()); + } + for phase in ["stopped", "failed", ""] { + assert!(check(&state(phase, "one", "api-one", "one"), "api-one", "one").is_none()); + } + let mut retired = original.clone(); + retired["members"] = json!({}); + assert!(check(&retired, "api-one", "one").is_none()); + let mut malformed = original; + malformed["active"]["releaseId"] = json!("\ninvalid"); + assert!(check(&malformed, "api-one", "one").is_none()); + assert!(resolve(b"not json", "api", "api-one", "one").is_none()); + } +} diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index e2baac522..2b38adc8f 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -58,9 +58,11 @@ mod causal; pub mod cell_host; mod context; -mod descriptor; mod dependencies; +pub(crate) use dependencies::CausalHostProjections; +mod descriptor; mod error; +pub(crate) mod lifecycle; mod message_router; mod projector; mod runtime; @@ -87,12 +89,12 @@ pub use dependencies::{ CausalRouteDependencies, ConfigurableOutboxPublisher, HasOutboxStore, HasReadModelStore, HasRepo, ReadModelStoreDependencies, RepoDependencies, RepoReadModelDependencies, }; -pub use error::HandlerError; pub use descriptor::{ MessageEndpointDescriptor, MetricsEndpointDescriptor, ServiceDescriptor, ServiceObservabilityDescriptor, TraceExportMode, TracePropagationMode, TracingDescriptor, TransportDescriptor, }; +pub use error::HandlerError; pub use projector::{ CausalProjectorContext, CausalProjectorRouteBuilder, LoadedProjection, ProjectionRepairHandle, ProjectionRepairHandleParseError, @@ -104,6 +106,20 @@ pub use runtime::{DEFAULT_MAX_PUBLISH_ATTEMPTS, DEFAULT_PUBLISH_LEASE}; pub(crate) use service::CausalCommandProjectionEvidence; #[cfg(feature = "graphql")] pub use service::GraphqlServiceBindError; +pub use service::{ + direct_read_model, invoke_transition, require_loaded, CausalCommandContext, + CausalCommitBuilder, CausalRepository, CommandRequest, CommandResponse, DeliveryKind, + DirectReadModelProjection, HandlerNames, HandlerSpec, PortableCommand, PreparedCausalCommit, + PreparedCommandHandler, RouteBuilder, Routes, Service, ThinCommandBuilder, ThinCommandInvoked, + ThinCommandLoaded, TypedRouteBuilder, +}; +#[cfg(feature = "graphql")] +pub(crate) use service::{ + CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandReceiptSource, + CausalProjectionEvidenceState, +}; +#[cfg(feature = "graphql")] +pub use service::{CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult}; #[cfg(any( feature = "http", feature = "grpc", @@ -113,23 +129,7 @@ pub use service::GraphqlServiceBindError; feature = "rabbitmq", feature = "kafka", ))] -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, 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, CausalCommandReceiptSource, - CausalProjectionEvidenceState, -}; +pub use workers::{spawn_outbox_publish_loop, spawn_service_consumer_loop, CONSUMER_IDLE_POLL}; #[cfg(feature = "graphql")] pub(crate) mod wait_path; pub use session::{Session, ROLE_KEY, USER_ID_KEY}; @@ -145,64 +145,14 @@ pub use session::{Session, ROLE_KEY, USER_ID_KEY}; #[cfg(feature = "http")] pub const MAX_HTTP_BODY_BYTES: usize = 1024 * 1024; -/// Fail closed for writes while a `distributed dev` process is not the active -/// application generation. Outside the dev supervisor there is no lifecycle +/// Fail closed for writes unless the supervisor admits this process instance +/// to the active application generation. Outside dev there is no lifecycle /// directory, so ordinary production/runtime embedding remains unchanged. pub(crate) fn lifecycle_mutations_open() -> bool { - let Some(root) = std::env::var_os("DISTRIBUTED_LIFECYCLE_DIR") else { + if std::env::var_os("DISTRIBUTED_LIFECYCLE_DIR").is_none() { return true; - }; - let Some(generation) = std::env::var_os("DISTRIBUTED_GENERATION_ID") else { - return false; - }; - let root = std::path::PathBuf::from(root); - let Some(generation) = generation.to_str() else { - return false; - }; - if !root.is_absolute() { - return false; - } - let state = root.join("dev.json"); - let Ok(metadata) = std::fs::symlink_metadata(&state) else { - return false; - }; - if metadata.file_type().is_symlink() - || !metadata.is_file() - || metadata.len() > 1024 * 1024 - { - return false; - } - std::fs::read(&state) - .ok() - .is_some_and(|source| lifecycle_state_allows_mutations(&source, generation)) -} - -fn lifecycle_state_allows_mutations(source: &[u8], generation: &str) -> bool { - let Ok(state) = serde_json::from_slice::(source) else { - return false; - }; - state.get("phase").and_then(serde_json::Value::as_str) == Some("active") - && state - .get("active") - .and_then(|active| active.get("generationId")) - .and_then(serde_json::Value::as_str) - == Some(generation) -} - -#[cfg(test)] -mod lifecycle_gate_tests { - use super::lifecycle_state_allows_mutations; - - #[test] - fn mutations_open_only_for_the_exact_active_generation() { - let active = br#"{"phase":"active","active":{"generationId":"sha256:one"}}"#; - let preparing = - br#"{"phase":"preparing","active":{"generationId":"sha256:one"}}"#; - assert!(lifecycle_state_allows_mutations(active, "sha256:one")); - assert!(!lifecycle_state_allows_mutations(active, "sha256:two")); - assert!(!lifecycle_state_allows_mutations(preparing, "sha256:one")); - assert!(!lifecycle_state_allows_mutations(b"not-json", "sha256:one")); } + lifecycle::membership_from_environment().is_some_and(|member| member.mutations_open) } // HTTP transport (requires "http" feature) diff --git a/src/microsvc/runtime.rs b/src/microsvc/runtime.rs index 25b77d9d3..330e778f9 100644 --- a/src/microsvc/runtime.rs +++ b/src/microsvc/runtime.rs @@ -166,23 +166,29 @@ mod tests { use crate::microsvc::{Context, HandlerError, Routes, Service, Session}; use crate::outbox_worker::OutboxStore; - async fn wait_until_published(store: &impl OutboxStore, count: usize) { + async fn assert_published_and_drained(store: &impl OutboxStore, bus: &InMemoryBus, id: &str) { tokio::time::timeout(std::time::Duration::from_secs(1), async { loop { - if store - .messages_by_status(OutboxMessageStatus::Published, usize::MAX) - .await - .unwrap() - .len() - >= count - { + // Delivery evidence is in the bus, not in deleted outbox rows. + let delivered = bus.published_ids().iter().any(|actual| actual == id); + let mut remaining = 0; + for status in [ + OutboxMessageStatus::Pending, + OutboxMessageStatus::InFlight, + OutboxMessageStatus::Failed, + OutboxMessageStatus::Published, + ] { + remaining += store.messages_by_status(status, 8).await.unwrap().len(); + } + if delivered && remaining == 0 { break; } tokio::task::yield_now().await; } }) .await - .expect("immediate publish should settle outbox rows"); + .expect("bus must receive the event and outbox must remove its row"); + assert_eq!(bus.published_ids(), vec![id.to_string()]); } use crate::{ sourced, AggregateBuilder, AggregateRepository, Entity, InMemoryRepository, OutboxMessage, @@ -221,6 +227,7 @@ mod tests { #[tokio::test] async fn with_bus_configures_outbox_for_all_eligible_route_bundles() { + let bus = InMemoryBus::new(); let repo_a = InMemoryRepository::new(); let store_a = repo_a.outbox_store(); let repo_b = InMemoryRepository::new(); @@ -238,7 +245,7 @@ mod tests { .command("dummy.touch.b") .handle(touch_and_publish), ) - .with_bus(InMemoryBus::new()); + .with_bus(bus.clone()); service .dispatch("dummy.touch.a", json!({}), Session::new()) @@ -249,32 +256,8 @@ 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 - .unwrap(); - assert_eq!( - published_a.len(), - 1, - "first route bundle should publish at commit time" - ); - assert_eq!(published_a[0].id(), "evt-1"); - assert!(store_a.pending(usize::MAX).await.unwrap().is_empty()); - - let published_b = store_b - .messages_by_status(OutboxMessageStatus::Published, usize::MAX) - .await - .unwrap(); - assert_eq!( - published_b.len(), - 1, - "second route bundle should publish at commit time" - ); - assert_eq!(published_b[0].id(), "evt-1"); - assert!(store_b.pending(usize::MAX).await.unwrap().is_empty()); + assert_published_and_drained(&store_a, &bus, "evt-1").await; + assert_published_and_drained(&store_b, &bus, "evt-1").await; } #[tokio::test] @@ -313,6 +296,7 @@ mod tests { #[tokio::test] async fn dispatch_through_a_handler_publishes_immediately() { + let bus = InMemoryBus::new(); let repo = InMemoryRepository::new(); let store = repo.outbox_store(); let service = Service::new() @@ -322,7 +306,7 @@ mod tests { .command("dummy.touch") .handle(touch_and_publish), ) - .with_bus(InMemoryBus::new()); + .with_bus(bus.clone()); // The handler runs `outbox().commit()`: pending row, then the // bounded worker publishes through the attached bus. @@ -331,14 +315,7 @@ mod tests { .await .unwrap(); - wait_until_published(&store, 1).await; - let published = store - .messages_by_status(OutboxMessageStatus::Published, usize::MAX) - .await - .unwrap(); - assert_eq!(published.len(), 1, "row should be published immediately"); - assert_eq!(published[0].id(), "evt-1"); - assert!(store.pending(usize::MAX).await.unwrap().is_empty()); + assert_published_and_drained(&store, &bus, "evt-1").await; } #[tokio::test] @@ -360,17 +337,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) - .await - .unwrap(); - assert_eq!( - published.len(), - 1, - "run() should consume the command and publish its outbox row" - ); + assert_published_and_drained(&store, &bus, "evt-1").await; } #[tokio::test] @@ -383,30 +350,15 @@ mod tests { .push(OutboxMessage::create("evt-left", "dummy.touched", b"{}".to_vec()).unwrap()); repo.commit_batch(batch).await.unwrap(); + let bus = InMemoryBus::new(); let handle = Service::outbox_drain( - store, - crate::BusPublisher::new(std::sync::Arc::new(InMemoryBus::new())), + store.clone(), + crate::BusPublisher::new(std::sync::Arc::new(bus.clone())), 5, ) .with_poll_interval(std::time::Duration::from_millis(5)) .spawn(); - tokio::time::timeout(std::time::Duration::from_secs(1), async { - loop { - if repo - .outbox_store() - .messages_by_status(OutboxMessageStatus::Published, 8) - .await - .unwrap() - .len() - == 1 - { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - }) - .await - .expect("Service::outbox_drain should publish leftover rows"); + assert_published_and_drained(&store, &bus, "evt-left").await; handle.stop().await.unwrap(); } @@ -438,6 +390,7 @@ mod tests { #[tokio::test] async fn outbox_commit_publishes_with_snapshot_backed_repo() { + let bus = InMemoryBus::new(); // `outbox().commit()` must work for a snapshot-backed repository too: the // outbox row and the snapshot commit together in one transaction, then // the row publishes immediately. @@ -450,23 +403,12 @@ mod tests { .command("snap.touch") .handle(touch_snap), ) - .with_bus(InMemoryBus::new()); + .with_bus(bus.clone()); service .dispatch("snap.touch", json!({}), Session::new()) .await .unwrap(); - wait_until_published(&store, 1).await; - - let published = store - .messages_by_status(OutboxMessageStatus::Published, usize::MAX) - .await - .unwrap(); - assert_eq!( - published.len(), - 1, - "snapshot-backed outbox commit should publish immediately" - ); - assert_eq!(published[0].id(), "evt-s1"); + assert_published_and_drained(&store, &bus, "evt-s1").await; } } diff --git a/src/microsvc/service/causal.rs b/src/microsvc/service/causal.rs index 4f0448ea6..fa4e3ec65 100644 --- a/src/microsvc/service/causal.rs +++ b/src/microsvc/service/causal.rs @@ -4,6 +4,8 @@ use std::time::Duration; #[cfg(feature = "graphql")] use serde_json::Value; +#[cfg(feature = "graphql")] +use crate::command::{CommandConsistency, TypedCommandContract}; #[cfg(feature = "graphql")] use crate::command_ledger::CausalTransactionalCommit; #[cfg(feature = "graphql")] @@ -13,8 +15,6 @@ use crate::command_ledger::{ TerminalCommandState, }; #[cfg(feature = "graphql")] -use crate::graphql::command_contract::{CommandConsistency, TypedCommandContract}; -#[cfg(feature = "graphql")] use crate::microsvc::error::HandlerError; #[cfg(feature = "graphql")] use crate::microsvc::session::Session; @@ -22,8 +22,7 @@ use crate::microsvc::session::Session; use crate::projection_protocol::{ ProjectionCausationEvidenceRequest, ProjectionObligationEvidence, ProjectionObligationEvidenceBatchRequest, ProjectionObligationEvidenceRequest, - ProjectionObservationKind, ProjectionProtocolStore, ProjectionRecordScope, - SameTransactionProjectionEvidence, + ProjectionObservationKind, ProjectionRecordScope, SameTransactionProjectionEvidence, }; #[cfg(feature = "graphql")] use crate::repository::CommitBatch; @@ -445,7 +444,7 @@ impl CausalDispatchResult { command_id: wire.receipt.command_id, command_name: String::new(), causation_id: wire.receipt.causation_id, - consistency: crate::graphql::CommandConsistency::Succeeded, + consistency: crate::command::CommandConsistency::Succeeded, state, outcome: Value::Null, obligations: Vec::new(), @@ -819,7 +818,7 @@ pub(super) async fn evaluate_causal_command_status( protocol: Option<&crate::graphql::protocol::ProtocolResponseAccumulator>, ) -> Result where - R: CommandLedgerStore + ProjectionProtocolStore + Send + Sync, + R: CommandLedgerStore + crate::microsvc::dependencies::CausalHostProjections + Send + Sync, { match lookup { CommandLookup::Unknown => Ok(CausalCommandPublicStatus::unknown(command_id.as_str())), @@ -953,7 +952,7 @@ pub(super) async fn evaluate_pending_projection_evidence( CausalDispatchError, > where - R: ProjectionProtocolStore + Send + Sync, + R: crate::microsvc::dependencies::CausalHostProjections + Send + Sync, { if let Some(metadata) = receipt.projection_metadata.as_ref() { return evaluate_pending_modeled_projection_evidence( @@ -996,7 +995,7 @@ where )) })?; let batch = repository - .projection_obligation_evidence_batch(&request) + .command_obligation_evidence(&request) .await .map_err(|error| { CausalDispatchError::Internal(format!( @@ -1089,7 +1088,7 @@ async fn evaluate_pending_modeled_projection_evidence( CausalDispatchError, > where - R: ProjectionProtocolStore + Send + Sync, + R: crate::microsvc::dependencies::CausalHostProjections + Send + Sync, { let request = ProjectionCausationEvidenceRequest::new( receipt.causation_id.clone(), @@ -1101,7 +1100,7 @@ where )) })?; let batch = repository - .projection_causation_evidence(&request) + .command_causation_evidence(&request) .await .map_err(|error| { CausalDispatchError::Internal(format!( diff --git a/src/microsvc/service/defaults.rs b/src/microsvc/service/defaults.rs index c8a172bf4..6c0e86123 100644 --- a/src/microsvc/service/defaults.rs +++ b/src/microsvc/service/defaults.rs @@ -25,10 +25,7 @@ impl Routes<()> { locks: L, read_models: S, ) -> Routes< - RepoReadModelDependencies< - crate::AggregateRepository, A>, - S, - >, + RepoReadModelDependencies, A>, S>, > where R: crate::GetStream + crate::TransactionalCommit + Clone + Send + Sync + 'static, diff --git a/src/microsvc/service/handlers.rs b/src/microsvc/service/handlers.rs index b64fa38e6..c7d5a891f 100644 --- a/src/microsvc/service/handlers.rs +++ b/src/microsvc/service/handlers.rs @@ -8,9 +8,11 @@ use serde_json::Value; use crate::aggregate::Aggregate; use crate::bus::Message; +use crate::command::{ + Atomic, CommandOutcome, CommandOutputType, Eventual, PreparedCommand, Succeeded, +}; use crate::domain_event::DomainEvent; -use crate::graphql::command_contract::CommandOutcome; -use crate::graphql::{Atomic, Eventual, GraphqlOutputType, PreparedCommand, Succeeded}; + use crate::microsvc::causal::{AggregatePublication, CausalWorkspace, CausalWorkspaceError}; use crate::microsvc::context::Context; use crate::microsvc::error::HandlerError; @@ -431,7 +433,7 @@ where /// Prepare a successful command result with no causal visibility promise. pub fn succeeded(self, payload: T) -> Result>, HandlerError> where - T: GraphqlOutputType + Serialize + Send + Sync + 'static, + T: CommandOutputType + Serialize + Send + Sync + 'static, { let _ = self.projection; PreparedCommand::prepare(payload).map_err(|error| HandlerError::Other(Box::new(error))) @@ -446,7 +448,7 @@ where /// leg; the dispatcher additionally proves actual durable outbox coverage. pub fn eventual(self, payload: T) -> Result>, HandlerError> where - T: GraphqlOutputType + Serialize + Send + Sync + 'static, + T: CommandOutputType + Serialize + Send + Sync + 'static, { let _ = self.projection; PreparedCommand::prepare(payload).map_err(|error| HandlerError::Other(Box::new(error))) diff --git a/src/microsvc/service/mod.rs b/src/microsvc/service/mod.rs index 034e187fd..c5cb368c8 100644 --- a/src/microsvc/service/mod.rs +++ b/src/microsvc/service/mod.rs @@ -30,8 +30,8 @@ mod causal; mod defaults; mod handlers; -mod invoke; mod helpers; +mod invoke; mod request; mod routes; mod runtime; @@ -41,12 +41,12 @@ 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, CausalCommandReceiptSource, CausalProjectionEvidenceState, }; +#[cfg(feature = "graphql")] +pub use causal::{CausalCommandPublicStatus, CausalDispatchError, CausalDispatchResult}; #[allow(unused_imports)] // public API surface for handler-owned projected commits pub use handlers::StagedProjectedRow; pub use handlers::{ diff --git a/src/microsvc/service/routes.rs b/src/microsvc/service/routes.rs index 420dd0d5b..3dc6022c4 100644 --- a/src/microsvc/service/routes.rs +++ b/src/microsvc/service/routes.rs @@ -23,6 +23,11 @@ use super::handlers::{ use crate::aggregate::Aggregate; use crate::application::{CommandMount, CommandMountRegistrar, CommandSpec}; use crate::bus::{Bus, Message, MessageKind, MessagePublisher, OrderedDelivery, TransportError}; +use crate::command::input::canonicalize_command_input; +use crate::command::{ + command_transition, CommandConsistency, CommandEventSet, CommandInputType, CommandOutcome, + CompiledInputDefaults, TypedCommand, TypedCommandContract, +}; use crate::command_ledger::{ CanonicalInputHash, CausalCommitBatch, CausalTransactionalCommit, CommandContractFingerprint, CommandLedgerStore, CommandReservation, ReservationOutcome, TerminalCommandState, @@ -32,17 +37,14 @@ 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, -}; -use crate::graphql::command_input::canonicalize_command_input; #[cfg(feature = "graphql")] use crate::graphql::identity::VerifiedPrincipal; -use crate::graphql::{command_transition, GraphqlInputType, SurfaceProjector, TypedCommand}; +use crate::graphql::SurfaceProjector; use crate::microsvc::causal::CausalWorkspace; use crate::microsvc::cell_host::{CellCommandIdentity, CellDispatchError, CellDispatchResult}; use crate::microsvc::context::Context; +#[cfg(feature = "graphql")] +use crate::microsvc::dependencies::CausalHostProjections; use crate::microsvc::dependencies::{ CausalProjectionRouteDependencies, CausalRouteDependencies, ConfigurableOutboxPublisher, HasOutboxStore, HasReadModelStore, HasRepo, @@ -72,8 +74,6 @@ use crate::outbox_worker::{ 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; @@ -597,9 +597,9 @@ where E: crate::domain_event::DomainEventBodyContract, S: crate::DomainState + crate::projection::lower::ProjectionBodyMetadata, { - let values = crate::graphql::__command_projection_state_known_values::(vec![( + let values = crate::command::__command_projection_state_known_values::(vec![( rust_field, - crate::graphql::CommandProjectionPreviewSource::trusted("x-user-id", "string"), + crate::command::CommandProjectionPreviewSource::trusted("x-user-id", "string"), )]); self.contract .projections @@ -611,7 +611,7 @@ where #[must_use] pub fn preview_reduce_known_record( mut self, - reduce: crate::graphql::CommandProjectionPureReduce, + reduce: crate::command::CommandProjectionPureReduce, ) -> Self { self.contract.projections.add_pure_reduce(reduce); self @@ -750,12 +750,12 @@ where } } -impl ThinCommandBuilder, ThinCommandInvoked> +impl ThinCommandBuilder, ThinCommandInvoked> where D: CausalRouteDependencies + Send + Sync + 'static, D::Aggregate: Aggregate + Send + Sync + 'static, I: serde::de::DeserializeOwned + Send + Sync + 'static, - T: crate::graphql::GraphqlOutputType + serde::Serialize + Send + Sync + 'static, + T: crate::command::CommandOutputType + serde::Serialize + Send + Sync + 'static, { /// Publish captured events, commit, and return an Eventual payload. /// @@ -782,12 +782,12 @@ where } } -impl ThinCommandBuilder, ThinCommandInvoked> +impl ThinCommandBuilder, ThinCommandInvoked> where D: CausalRouteDependencies + Send + Sync + 'static, D::Aggregate: Aggregate + Send + Sync + 'static, I: serde::de::DeserializeOwned + Send + Sync + 'static, - T: crate::graphql::GraphqlOutputType + serde::Serialize + Send + Sync + 'static, + T: crate::command::CommandOutputType + serde::Serialize + Send + Sync + 'static, { /// Commit and return a Succeeded payload. Used when the command is not Eventual. pub fn succeeded(self, payload: F) -> Routes @@ -826,18 +826,18 @@ where roles: Vec, } -impl<'a, A, I, T> PreparedCommandHandler<'a, A, I, crate::graphql::Eventual> +impl<'a, A, I, T> PreparedCommandHandler<'a, A, I, crate::command::Eventual> for ThinEventualHandler where A: Aggregate + Send + Sync + 'static, I: serde::de::DeserializeOwned + Send + Sync + 'static, - T: crate::graphql::GraphqlOutputType + serde::Serialize + Send + Sync + 'static, + T: crate::command::CommandOutputType + serde::Serialize + Send + Sync + 'static, { type Future = Pin< Box< dyn Future< Output = Result< - crate::graphql::PreparedCommand>, + crate::command::PreparedCommand>, HandlerError, >, > + Send @@ -887,18 +887,18 @@ where roles: Vec, } -impl<'a, A, I, T> PreparedCommandHandler<'a, A, I, crate::graphql::Succeeded> +impl<'a, A, I, T> PreparedCommandHandler<'a, A, I, crate::command::Succeeded> for ThinSucceededHandler where A: Aggregate + Send + Sync + 'static, I: serde::de::DeserializeOwned + Send + Sync + 'static, - T: crate::graphql::GraphqlOutputType + serde::Serialize + Send + Sync + 'static, + T: crate::command::CommandOutputType + serde::Serialize + Send + Sync + 'static, { type Future = Pin< Box< dyn Future< Output = Result< - crate::graphql::PreparedCommand>, + crate::command::PreparedCommand>, HandlerError, >, > + Send @@ -1097,7 +1097,7 @@ impl Routes { pub fn command_transition(self, name: &'static str) -> TypedRouteBuilder where S: CommandEventSet, - I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + I: CommandInputType + serde::de::DeserializeOwned + Send + 'static, K: CommandOutcome, { self.typed_command(command_transition::(name)) @@ -1722,10 +1722,23 @@ where } let fence = attempt.fence(); + let replay = crate::microsvc::cell_host::causal::CellCommandReplay { + payload: replay_payload.clone(), + events: batch + .outbox_messages + .iter() + .map(crate::microsvc::cell_host::CellProjectionEventWireItem::from_message) + .collect(), + }; + let replay = serde_json::to_value(replay).map_err(|error| { + CellDispatchError::Internal(format!( + "cell command replay could not be encoded: {error}" + )) + })?; let completion = attempt .complete( TerminalCommandState::Succeeded, - replay_payload.clone(), + replay, policy.replay_retention, ) .map_err(crate::microsvc::cell_host::causal::internal_ledger_error)?; @@ -1829,7 +1842,7 @@ where self.direct_projection_bootstrap .get_or_try_init(|| async { repository - .register_projection_models(topology, ownership) + .__register_direct_projection_models(topology, ownership) .await .map_err(|error| { CausalDispatchError::Internal(format!( diff --git a/src/microsvc/service/runtime.rs b/src/microsvc/service/runtime.rs index 441066dba..bc680536f 100644 --- a/src/microsvc/service/runtime.rs +++ b/src/microsvc/service/runtime.rs @@ -18,13 +18,16 @@ use super::helpers::{ use super::helpers::{microsvc_dispatch_span, microsvc_handler_span}; use super::request::{CommandRequest, CommandResponse}; use super::routes::{CausalCommandPolicy, DynBusPublisher, ErasedRoutes, HandlerSpec, Routes}; -use crate::application::{CommandMount, CommandMountRegistrar, CommandSpec}; +use crate::application::{ + Application, ApplicationError, ApplicationResult, CommandDefinition, CommandMount, + CommandMountRegistrar, CommandSpec, Module, SurfaceSpec, +}; use crate::bus::{ Message, MessageKind, OrderedDelivery, RunOptions, SubscriptionPlan, TransportError, }; +use crate::command::{TypedCommandContract, TypedServiceCommandBinding}; #[cfg(feature = "graphql")] use crate::command_ledger::{CommandId, CommandLookup, PrincipalPartitionId}; -use crate::graphql::command_contract::{TypedCommandContract, TypedServiceCommandBinding}; #[cfg(feature = "graphql")] use crate::graphql::identity::VerifiedPrincipal; use crate::microsvc::error::HandlerError; @@ -322,10 +325,7 @@ impl Service { /// Register one explicit command mount against the already-installed /// typed route inventory. The route's canonical command spec is the only /// authority; a stale or lookalike mount is rejected before dispatch. - pub fn register_command_mount( - &mut self, - mount: CommandMount, - ) -> Result<(), HandlerError> { + pub fn register_command_mount(&mut self, mount: CommandMount) -> Result<(), HandlerError> { self.register_command_mount_inner(mount) } @@ -359,12 +359,10 @@ impl Service { mount.spec().id ))); } - if !self - .registered_command_mounts - .iter() - .any(|registered| registered.spec().id == mount.spec().id - && registered.spec().fingerprint == mount.spec().fingerprint) - { + if !self.registered_command_mounts.iter().any(|registered| { + registered.spec().id == mount.spec().id + && registered.spec().fingerprint == mount.spec().fingerprint + }) { return Err(HandlerError::Rejected( "command mount was not registered against this service".into(), )); @@ -404,10 +402,7 @@ impl Service { .await } - fn register_command_mount_inner( - &mut self, - mount: CommandMount, - ) -> Result<(), HandlerError> { + fn register_command_mount_inner(&mut self, mount: CommandMount) -> Result<(), HandlerError> { let Some(indices) = self .index .get(&MessageKind::Command) @@ -449,9 +444,11 @@ impl Service { mount.spec().id ))); } - if self.registered_command_mounts.iter().any(|registered| { - registered.spec().id == mount.spec().id - }) { + if self + .registered_command_mounts + .iter() + .any(|registered| registered.spec().id == mount.spec().id) + { return Err(HandlerError::Rejected(format!( "command mount `{}` is registered more than once", mount.spec().id @@ -592,6 +589,60 @@ impl Service { Ok(specs) } + /// Compile this Service's typed command inventory and an authorized Surface + /// into one logical application. + /// + /// Command namespaces (the segment before the first `.`) become modules. + /// Every Service command must exist in the Surface so its authorization and + /// projection contract can be bound, and application validation rejects any + /// Surface command that is not owned by the Service. + pub fn application( + &self, + name: impl Into, + surface: SurfaceSpec, + ) -> ApplicationResult { + let mut modules = BTreeMap::>::new(); + for command in self.command_specs()? { + let namespace = command + .id + .split_once('.') + .map(|(namespace, _)| namespace) + .filter(|namespace| !namespace.is_empty()) + .ok_or_else(|| { + ApplicationError::InvalidSpec(format!( + "typed command `{}` has no module namespace; expected `.`", + command.id + )) + })? + .to_string(); + let exposed = surface + .commands + .iter() + .find(|exposed| exposed.id == command.id) + .ok_or_else(|| ApplicationError::Missing { + kind: "surface command", + identity: command.id.clone(), + })?; + let command = command.with_surface_binding(exposed)?; + modules + .entry(namespace) + .or_default() + .push(CommandDefinition::contract(command)); + } + + let modules = modules + .into_iter() + .map(|(namespace, commands)| { + Module::new(namespace).command_definitions(commands).build() + }) + .collect::>>()?; + + Application::new(name) + .modules(modules) + .surface(surface) + .build() + } + /// Attach Eventual projection metadata to a cell wait-path result using this /// process's command contract (no second aggregate write). #[cfg(feature = "graphql")] @@ -1192,10 +1243,7 @@ impl Service { } impl CommandMountRegistrar for Service { - fn register_command_mount( - &mut self, - mount: CommandMount, - ) -> Result<(), HandlerError> { + fn register_command_mount(&mut self, mount: CommandMount) -> Result<(), HandlerError> { self.register_command_mount_inner(mount) } } diff --git a/src/microsvc/service/tests.rs b/src/microsvc/service/tests.rs index 7cf9340b6..089f86c79 100644 --- a/src/microsvc/service/tests.rs +++ b/src/microsvc/service/tests.rs @@ -6,6 +6,12 @@ use crate::aggregate::Aggregate; #[cfg(feature = "graphql")] use crate::bus::RunOptions; use crate::bus::{Message, MessageKind, SubscriptionPlan}; +#[cfg(all(feature = "graphql", feature = "sqlite"))] +use crate::command::Eventual; +use crate::command::{typed_command, PreparedCommand, Succeeded}; +#[cfg(feature = "graphql")] +use crate::command::{Atomic, CommandConsistency}; +use crate::command::{CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField}; #[cfg(feature = "graphql")] use crate::command_ledger::{ AttemptFence, CausalCommitBatch, CausalGetStream, CausalRepositoryIdentity, @@ -13,17 +19,9 @@ use crate::command_ledger::{ CommandLedgerStore, CommandLookup, CommandLookupScope, CommandReservation, ReservationOutcome, }; #[cfg(feature = "graphql")] -use crate::graphql::command_contract::CommandConsistency; -#[cfg(feature = "graphql")] use crate::graphql::identity::VerifiedPrincipal; -#[cfg(all(feature = "graphql", feature = "sqlite"))] -use crate::graphql::Eventual; -use crate::graphql::{ - typed_command, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, - PreparedCommand, Succeeded, -}; #[cfg(feature = "graphql")] -use crate::graphql::{Atomic, SurfaceDirectProjection, SurfaceProjector}; +use crate::graphql::{SurfaceDirectProjection, SurfaceProjector}; #[cfg(feature = "graphql")] use crate::microsvc::HasOutboxStore; use crate::microsvc::{ @@ -75,10 +73,10 @@ struct TypedOutput { id: String, } -fn one_string_field(name: &str, field: &str) -> GraphqlTypeDef { - GraphqlTypeDef::new( +fn one_string_field(name: &str, field: &str) -> CommandTypeDef { + CommandTypeDef::new( name, - vec![GraphqlTypeField { + vec![CommandTypeField { name: field.into(), type_name: "String".into(), nullable: false, @@ -89,14 +87,14 @@ fn one_string_field(name: &str, field: &str) -> GraphqlTypeDef { ) } -impl GraphqlInputType for TypedInput { - fn graphql_type() -> GraphqlTypeDef { +impl CommandInputType for TypedInput { + fn command_type() -> CommandTypeDef { one_string_field("TypedInput", "id").with_type_id(std::any::TypeId::of::()) } } -impl GraphqlOutputType for TypedOutput { - fn graphql_type() -> GraphqlTypeDef { +impl CommandOutputType for TypedOutput { + fn command_type() -> CommandTypeDef { one_string_field("TypedOutput", "id").with_type_id(std::any::TypeId::of::()) } } @@ -109,12 +107,12 @@ struct CausalTestInput { } #[cfg(feature = "graphql")] -impl GraphqlInputType for CausalTestInput { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandInputType for CausalTestInput { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "CausalTestInput", vec![ - GraphqlTypeField { + CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -122,7 +120,7 @@ impl GraphqlInputType for CausalTestInput { item_nullable: false, nested: None, }, - GraphqlTypeField { + CommandTypeField { name: "label".into(), type_name: "String".into(), nullable: false, @@ -137,7 +135,7 @@ impl GraphqlInputType for CausalTestInput { } #[cfg(feature = "graphql")] -#[derive(Clone, Deserialize, crate::GraphqlInput)] +#[derive(Clone, Deserialize, crate::CommandInput)] struct CausalProjectionInput { #[serde(rename = "todoId")] id: String, @@ -589,28 +587,28 @@ fn modeled_lifecycle_projector( } #[cfg(feature = "graphql")] -impl GraphqlOutputType for CausalProjectionObligationView { - fn graphql_type() -> GraphqlTypeDef { +impl CommandOutputType for CausalProjectionObligationView { + fn command_type() -> CommandTypeDef { one_string_field("CausalProjectionObligationView", "id") .with_type_id(std::any::TypeId::of::()) } } #[cfg(feature = "graphql")] -impl GraphqlOutputType for CausalProjectionSiblingView { - fn graphql_type() -> GraphqlTypeDef { +impl CommandOutputType for CausalProjectionSiblingView { + fn command_type() -> CommandTypeDef { one_string_field("CausalProjectionSiblingView", "id") .with_type_id(std::any::TypeId::of::()) } } #[cfg(feature = "graphql")] -impl GraphqlOutputType for CausalLifecycleView { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandOutputType for CausalLifecycleView { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "CausalLifecycleView", vec![ - GraphqlTypeField { + CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -618,7 +616,7 @@ impl GraphqlOutputType for CausalLifecycleView { item_nullable: false, nested: None, }, - GraphqlTypeField { + CommandTypeField { name: "label".into(), type_name: "String".into(), nullable: false, @@ -812,6 +810,13 @@ impl AmbiguousCommitRepository { #[cfg(feature = "graphql")] impl CausalGetStream for AmbiguousCommitRepository { + fn get_causal_stream_tail<'a>( + &'a self, + identity: &'a crate::StreamIdentity, + after_version: u64, + ) -> impl Future, crate::RepositoryError>> + Send + 'a { + self.inner.get_causal_stream_tail(identity, after_version) + } fn get_causal_stream<'a>( &'a self, identity: &'a crate::StreamIdentity, @@ -827,6 +832,11 @@ impl CausalRepositoryIdentity for AmbiguousCommitRepository { } } +#[cfg(feature = "graphql")] +impl crate::microsvc::dependencies::CausalHostProjections for AmbiguousCommitRepository { + crate::microsvc::dependencies::direct_causal_projection_methods!(); +} + #[cfg(feature = "graphql")] impl ProjectionProtocolStore for AmbiguousCommitRepository { fn register_projection_models<'a>( @@ -1085,6 +1095,67 @@ fn named_service_preserves_identity_with_route_bundles() { ); } +#[cfg(feature = "graphql")] +#[test] +fn service_compiles_exact_application_modules_from_command_namespaces() { + let repository = InMemoryRepository::new(); + let service = Service::new().named("catalog").routes( + Routes::new() + .with_repo(repository.queued().aggregate::()) + .typed_command( + typed_command::>("access.grant").roles(["user"]), + ) + .handle(typed_handler) + .typed_command( + typed_command::>("repository.create") + .roles(["user"]), + ) + .handle(typed_handler), + ); + let surface = crate::graphql::build_surface(&[], &crate::graphql::SurfaceOptions::sqlite()) + .expect("empty read-model Surface should build") + .with_service(&service) + .expect("typed Service should bind to the Surface"); + let surface = crate::application::SurfaceSpec::from_surface("catalog", &surface) + .expect("Surface should become a portable application contract"); + + let application = service + .application("catalog", surface.clone()) + .expect("Service and Surface should compile into one application"); + + assert_eq!( + application + .modules() + .iter() + .map(crate::application::Module::id) + .collect::>(), + ["access", "repository"] + ); + assert_eq!( + application + .manifest() + .commands + .iter() + .map(|command| command.id.as_str()) + .collect::>(), + ["access.grant", "repository.create"] + ); + + let mut missing = surface.clone(); + missing + .commands + .retain(|command| command.id != "access.grant"); + assert!(matches!( + service.application("catalog", missing), + Err(crate::ApplicationError::Missing { kind: "surface command", identity }) + if identity == "access.grant" + )); + assert!( + Service::new().application("catalog", surface).is_err(), + "a Surface cannot expose commands absent from the owning Service" + ); +} + #[tokio::test] async fn typed_direct_dispatch_fails_before_invoking_handler() { TYPED_HANDLER_INVOKED.store(false, Ordering::SeqCst); @@ -1154,7 +1225,7 @@ async fn thin_complete_registers_without_a_handler_context_body() { let routes = Routes::new() .with_repo(repository.aggregate::()) .typed_command( - typed_command::>("todo.create") + typed_command::>("todo.create") .roles(["user"]), ) .create() @@ -1166,7 +1237,7 @@ async fn thin_complete_registers_without_a_handler_context_body() { id: aggregate.entity().id().to_string(), }) .typed_command( - typed_command::>( + typed_command::>( "todo.complete", ) .roles(["user"]), @@ -1947,8 +2018,9 @@ async fn causal_dispatch_overwrites_event_and_outbox_causation_with_ledger_ident #[tokio::test] async fn causal_dispatch_uses_the_configured_immediate_outbox_publisher() { let repository = InMemoryRepository::new(); - let observed_broker_metadata = Arc::new(Mutex::new(None::<[String; 4]>)); + let observed_broker_metadata = Arc::new(Mutex::new(None::<[String; 6]>)); let route_observed_broker_metadata = Arc::clone(&observed_broker_metadata); + let bus = crate::bus::InMemoryBus::new(); let service = Service::new() .named("causal-tests") .routes( @@ -1986,6 +2058,8 @@ async fn causal_dispatch_uses_the_configured_immediate_outbox_publisher() { >| { let message = context.message(); let metadata = [ + message.id().expect("published message ID").to_string(), + message.name().to_string(), message.causation_id().unwrap_or_default().to_string(), message .metadata("x-sourced-source-aggregate-type") @@ -2008,7 +2082,7 @@ async fn causal_dispatch_uses_the_configured_immediate_outbox_publisher() { }, ), ) - .with_bus(crate::bus::InMemoryBus::new()); + .with_bus(bus.clone()); service .dispatch_causal( @@ -2028,11 +2102,14 @@ async fn causal_dispatch_uses_the_configured_immediate_outbox_publisher() { tokio::task::yield_now().await; continue; } - if !outbox - .messages_by_status(crate::outbox::OutboxMessageStatus::Published, usize::MAX) + if outbox + .messages_by_status(crate::outbox::OutboxMessageStatus::InFlight, usize::MAX) .await .unwrap() .is_empty() + && bus + .published_ids() + .contains(&"todo-immediate:immediate-fact".to_string()) { break; } @@ -2046,22 +2123,25 @@ async fn causal_dispatch_uses_the_configured_immediate_outbox_publisher() { .messages_by_status(crate::outbox::OutboxMessageStatus::Published, usize::MAX) .await .unwrap(); - assert_eq!(published.len(), 1); - assert_eq!(published[0].id(), "todo-immediate:immediate-fact"); - assert_eq!(published[0].event_type, "causal.immediate_fact"); - let causation = published[0] + assert!( + published.is_empty(), + "delivered messages are deleted, not retained as evidence" + ); + let stream = repository + .get_stream( + &crate::StreamIdentity::new( + CausalDispatcherAggregate::aggregate_type(), + "todo-immediate", + ) + .unwrap(), + ) + .await + .unwrap() + .unwrap(); + let causation = stream.events()[0] .causation_id() - .expect("persisted outbox row should retain ledger causation") + .expect("committed event carries ledger causation") .to_string(); - assert_eq!( - published[0].source_aggregate_type.as_deref(), - Some(CausalDispatcherAggregate::aggregate_type()) - ); - assert_eq!( - published[0].source_aggregate_id.as_deref(), - Some("todo-immediate") - ); - assert_eq!(published[0].source_sequence, Some(1)); service .run(RunOptions::idempotent()) @@ -2070,6 +2150,8 @@ async fn causal_dispatch_uses_the_configured_immediate_outbox_publisher() { assert_eq!( observed_broker_metadata.lock().unwrap().as_ref(), Some(&[ + "todo-immediate:immediate-fact".to_string(), + "causal.immediate_fact".to_string(), causation, CausalDispatcherAggregate::aggregate_type().to_string(), "todo-immediate".to_string(), diff --git a/src/outbox_worker/drain.rs b/src/outbox_worker/drain.rs index c2b025049..253c61168 100644 --- a/src/outbox_worker/drain.rs +++ b/src/outbox_worker/drain.rs @@ -358,15 +358,6 @@ mod tests { crate::outbox_worker::testing::block_on(future) } - fn load(repo: &InMemoryRepository, id: &str) -> OutboxMessage { - repo.outbox_storage() - .read() - .unwrap() - .get(id) - .unwrap() - .clone() - } - #[tokio::test] async fn immediate_publish_leaves_nothing_for_the_drainer() { let repo = InMemoryRepository::new(); @@ -384,7 +375,7 @@ mod tests { .await .unwrap(); assert_eq!(outcome.published, 1); - assert_eq!(load(&repo, &id).status, OutboxMessageStatus::Published); + assert!(!repo.outbox_storage().read().unwrap().contains_key(&id)); let drain = OutboxDrainRunner::new(OutboxDispatcher::new( repo.outbox_store(), @@ -423,10 +414,11 @@ mod tests { .await .expect("drain should publish the unclaimed row"); handle.stop().await.unwrap(); - assert_eq!( - load(&repo, "evt-crash").status, - OutboxMessageStatus::Published - ); + assert!(!repo + .outbox_storage() + .read() + .unwrap() + .contains_key("evt-crash")); } #[tokio::test] diff --git a/src/outbox_worker/mod.rs b/src/outbox_worker/mod.rs index 3abd9520c..6bd02d011 100644 --- a/src/outbox_worker/mod.rs +++ b/src/outbox_worker/mod.rs @@ -45,7 +45,6 @@ mod store; pub(crate) mod testing; // Repository helpers -#[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) use store::ensure_active_claim; pub use store::{ ClaimOutboxMessages, OutboxBacklogStats, OutboxClaimRef, OutboxPublishFailureAction, diff --git a/src/outbox_worker/outbox_dispatch.rs b/src/outbox_worker/outbox_dispatch.rs index 34d6b78f2..166f7e23f 100644 --- a/src/outbox_worker/outbox_dispatch.rs +++ b/src/outbox_worker/outbox_dispatch.rs @@ -596,7 +596,7 @@ mod tests { ); // The publisher saw the message id, and the row is completed only after. assert_eq!(dispatcher.publisher.ids(), vec!["evt-1".to_string()]); - assert!(load(&repo, &id).is_published()); + assert!(!repo.outbox_storage().read().unwrap().contains_key(&id)); } #[test] @@ -640,7 +640,7 @@ mod tests { assert_eq!(outcome.claimed, 1); assert_eq!(outcome.published, 1); - assert!(load(&repo, &wanted).is_published()); + assert!(!repo.outbox_storage().read().unwrap().contains_key(&wanted)); // The unrequested row is untouched. assert!(load(&repo, &other).is_pending()); } @@ -723,8 +723,8 @@ mod tests { assert_eq!(outcome.claimed, 3); assert_eq!(outcome.published, 2); assert_eq!(outcome.released, 1); - assert!(load(&repo, "evt-1").is_published()); - assert!(load(&repo, "evt-3").is_published()); + assert!(!repo.outbox_storage().read().unwrap().contains_key("evt-1")); + assert!(!repo.outbox_storage().read().unwrap().contains_key("evt-3")); // The failed row is released for retry, untouched by the batched complete. let failed = load(&repo, "evt-2"); assert!(failed.is_pending()); @@ -745,7 +745,7 @@ mod tests { assert_eq!(outcome.claimed, 3); assert_eq!(outcome.published, 3); for id in ["evt-1", "evt-2", "evt-3"] { - assert!(load(&repo, id).is_published()); + assert!(!repo.outbox_storage().read().unwrap().contains_key(id)); } assert_eq!(dispatcher.publisher.ids().len(), 3); } diff --git a/src/outbox_worker/outbox_source.rs b/src/outbox_worker/outbox_source.rs index 361120a91..33a5527f3 100644 --- a/src/outbox_worker/outbox_source.rs +++ b/src/outbox_worker/outbox_source.rs @@ -256,7 +256,7 @@ mod tests { let mut src = source(&repo); let received = block_on(src.recv()).unwrap().unwrap(); block_on(received.ack()).unwrap(); - assert_eq!(status(&repo, "m1"), Some(OutboxMessageStatus::Published)); + assert_eq!(status(&repo, "m1"), None); } #[test] @@ -308,8 +308,8 @@ mod tests { let mut ids = handled.lock().unwrap().clone(); ids.sort(); assert_eq!(ids, vec!["m1".to_string(), "m2".to_string()]); - assert_eq!(status(&repo, "m1"), Some(OutboxMessageStatus::Published)); - assert_eq!(status(&repo, "m2"), Some(OutboxMessageStatus::Published)); + assert_eq!(status(&repo, "m1"), None); + assert_eq!(status(&repo, "m2"), None); } #[test] @@ -327,6 +327,6 @@ mod tests { ), ); block_on(run_source(service, source(&repo), RunOptions::idempotent())).unwrap(); - assert_eq!(status(&repo, "m1"), Some(OutboxMessageStatus::Published)); + assert_eq!(status(&repo, "m1"), None); } } diff --git a/src/outbox_worker/store/api.rs b/src/outbox_worker/store/api.rs index 75e21b608..4229f8874 100644 --- a/src/outbox_worker/store/api.rs +++ b/src/outbox_worker/store/api.rs @@ -156,6 +156,8 @@ pub trait OutboxStore: Send + Sync { request: ClaimOutboxMessages, ) -> impl Future, RepositoryError>> + Send + 'a; + /// Delete a delivered row while holding its active claim. The outbox is + /// pending delivery work, not a publication-history or replay store. fn complete<'a>( &'a self, claim: &'a OutboxClaimRef, diff --git a/src/outbox_worker/store/in_memory.rs b/src/outbox_worker/store/in_memory.rs index 13a2c718a..b58aedcee 100644 --- a/src/outbox_worker/store/in_memory.rs +++ b/src/outbox_worker/store/in_memory.rs @@ -127,13 +127,7 @@ impl OutboxStore for InMemoryOutboxStore { &'a self, claim: &'a OutboxClaimRef, ) -> impl Future> + Send + 'a { - async move { - self.update_outbox_message(&claim.message_id, |message| { - ensure_active_claim(message, Some(claim), crate::time::now())?; - message.complete()?; - Ok(()) - }) - } + async move { self.complete_many(std::slice::from_ref(claim)).await } } /// Batched complete under a single write lock instead of one lock @@ -161,7 +155,7 @@ impl OutboxStore for InMemoryOutboxStore { } })?; ensure_active_claim(message, Some(claim), now)?; - message.complete()?; + storage.remove(&claim.message_id); } Ok(()) } diff --git a/src/outbox_worker/store/tests.rs b/src/outbox_worker/store/tests.rs index 51f5be0cd..fbd42a7e2 100644 --- a/src/outbox_worker/store/tests.rs +++ b/src/outbox_worker/store/tests.rs @@ -471,7 +471,7 @@ async fn complete_many_completes_the_whole_batch() { store.complete_many(&claims).await.unwrap(); for id in ["msg-1", "msg-2", "msg-3"] { - assert!(load_message(&repo, id).is_published()); + assert!(!repo.outbox_storage().read().unwrap().contains_key(id)); } } @@ -496,9 +496,9 @@ async fn complete_many_rejects_stale_and_missing_claims() { let claims = vec![OutboxClaimRef::from_message(&claimed[0]).unwrap()]; store.complete_many(&claims).await.unwrap(); - // Re-settling the now-published row is a stale claim, same as `complete`. + // Re-settling a deleted delivery row is NotFound, same as `complete`. let err = store.complete_many(&claims).await.unwrap_err(); - assert!(matches!(err, RepositoryError::InvalidState { .. })); + assert!(matches!(err, RepositoryError::NotFound { .. })); let missing = vec![OutboxClaimRef { message_id: "missing".into(), @@ -529,5 +529,5 @@ async fn already_published_message_is_not_completed_again() { store.complete(&claim).await.unwrap(); let err = store.complete(&claim).await.unwrap_err(); - assert!(matches!(err, RepositoryError::InvalidState { .. })); + assert!(matches!(err, RepositoryError::NotFound { .. })); } diff --git a/src/projection/catalog.rs b/src/projection/catalog.rs index 73b6471cb..a3ffa2487 100644 --- a/src/projection/catalog.rs +++ b/src/projection/catalog.rs @@ -2024,6 +2024,7 @@ mod tests { fn relationship_kind_is_part_of_the_output_schema_identity() { let mut schema = todo_schema("todos"); schema.relationships.push(RelationshipDef { + references: None, field_name: "owner".into(), kind: RelationshipKind::BelongsTo, target_model: "Owners".into(), diff --git a/src/projection/error.rs b/src/projection/error.rs index 75dae206d..fb05a2a73 100644 --- a/src/projection/error.rs +++ b/src/projection/error.rs @@ -4,6 +4,8 @@ use std::fmt; #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum ProjectionProgramError { + /// Derived facts are not new authoritative aggregate snapshots. + DerivedSourceSnapshot, /// A stable name, identifier, field, or storage name was empty. EmptyName(&'static str), /// A declared version must be non-zero. @@ -118,6 +120,9 @@ pub enum ProjectionProgramError { impl fmt::Display for ProjectionProgramError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::DerivedSourceSnapshot => { + formatter.write_str("derived facts cannot establish an aggregate source snapshot") + } Self::EmptyName(kind) => write!(formatter, "{kind} must not be empty"), Self::ZeroVersion(kind) => write!(formatter, "{kind} must be non-zero"), Self::InvalidBodyFingerprint => formatter.write_str( diff --git a/src/projection/executor.rs b/src/projection/executor.rs index 1a97efe2d..a67deaa74 100644 --- a/src/projection/executor.rs +++ b/src/projection/executor.rs @@ -39,6 +39,7 @@ struct ExecutionMutation { scope: ProjectionRecordScope, intent: LifecycleIntent, mutation: TableMutation, + source_snapshot: Option, } /// Store-free execution preflight produced before any adapter read. @@ -118,7 +119,7 @@ impl PreparedProjectionExecution { )); } - let mut staged = Vec::with_capacity(self.mutations.len()); + let mut next = workspace.clone(); for mutation in self.mutations { let snapshot = snapshots.get(&mutation.scope).ok_or_else(|| { ProjectionProtocolError::InvalidBatch(format!( @@ -127,12 +128,32 @@ impl PreparedProjectionExecution { )) })?; validate_snapshot_scope(snapshot)?; - staged.push(stage_for_snapshot(mutation, snapshot)?); - } - - let mut next = workspace.clone(); - for (mutation, expectation, kind) in staged { - next.stage_execution_mutation(mutation, expectation, kind)?; + let source = mutation.source_snapshot.clone(); + if let Some(record) = &snapshot.record { + match (&source, &record.source_snapshot) { + (Some(incoming), Some(current)) if !incoming.advances(current)? => { + // A stale command still observes the current authoritative + // revision. The commit validates that exact revision, so a + // racing writer cannot turn this into a false confirmation. + let (schema, _) = mutation_schema_key(&mutation.mutation); + next.confirm_existing(schema, record.revision.clone())?; + continue; + } + (Some(_), None) | (None, Some(_)) => { + return Err(ProjectionProtocolError::InvalidBatch( + "projection source-ordering policy changed; rebuild the read model" + .into(), + )); + } + _ => {} + } + } + let (physical, expectation, kind) = if source.is_some() { + stage_source_snapshot(mutation, snapshot)? + } else { + stage_for_snapshot(mutation, snapshot)? + }; + next.stage_execution_mutation(physical, expectation, kind, source)?; } *workspace = next; Ok(()) @@ -164,6 +185,15 @@ pub(crate) fn prepare_portable_projection( }; validate_execution_bounds(lowered.write_plan.mutations.len(), client_visible)?; lowered.write_plan.validate()?; + let source_snapshot = lowered + .resolved + .source_snapshots() + .then(|| { + crate::projection_protocol::SourceSnapshotVersion::from_occurrence( + lowered.resolved.occurrence(), + ) + }) + .transpose()?; let mut used_logical = vec![false; lowered.resolved.mutations().len()]; let mut mutations = Vec::with_capacity(lowered.write_plan.mutations.len()); @@ -203,6 +233,7 @@ pub(crate) fn prepare_portable_projection( scope, intent, mutation: physical, + source_snapshot: source_snapshot.clone(), }); } if used_logical.iter().any(|used| !used) { @@ -241,6 +272,7 @@ pub(crate) fn prepare_graph_projection( scope, intent: intent_for_physical(&mutation), mutation, + source_snapshot: None, }); } prepare(workspace, mutations, cached) @@ -433,6 +465,55 @@ pub(crate) fn validate_snapshot_scope( } } +fn stage_source_snapshot( + execution: ExecutionMutation, + snapshot: &ProjectionScopedRowSnapshot, +) -> Result< + ( + TableMutation, + ProjectionRecordExpectation, + ProjectionMutationKind, + ), + ProjectionProtocolError, +> { + let expected = snapshot + .record + .as_ref() + .map_or(ProjectionRecordExpectation::Missing, |record| { + ProjectionRecordExpectation::Exact(record.revision.clone()) + }); + match execution.intent { + LifecycleIntent::Upsert => { + let tombstone = snapshot + .record + .as_ref() + .is_some_and(|record| record.tombstone); + let mutation = if snapshot.row.is_some() { + normalize_save(execution.mutation)? + } else { + normalize_create(execution.mutation)? + }; + Ok(( + mutation, + expected, + if tombstone { + ProjectionMutationKind::Recreate + } else { + ProjectionMutationKind::Upsert + }, + )) + } + LifecycleIntent::Delete => Ok(( + normalize_delete(execution.mutation)?, + expected, + ProjectionMutationKind::Delete, + )), + _ => Err(ProjectionProtocolError::InvalidBatch( + "source snapshots cannot apply partial/delta mutations".into(), + )), + } +} + fn stage_for_snapshot( execution: ExecutionMutation, snapshot: &ProjectionScopedRowSnapshot, @@ -1067,6 +1148,7 @@ mod tests { scope: workspace.record_scope(schema, key).unwrap(), intent, mutation, + source_snapshot: None, } } @@ -1076,6 +1158,7 @@ mod tests { revision: u64, ) -> ProjectionRecordMetadata { ProjectionRecordMetadata { + source_snapshot: None, revision: RecordRevision::new(scope, 1, revision).unwrap(), tombstone, change: ProjectionChangeCursor::new( diff --git a/src/projection/mod.rs b/src/projection/mod.rs index 46b8d98b6..6620e0523 100644 --- a/src/projection/mod.rs +++ b/src/projection/mod.rs @@ -22,6 +22,8 @@ pub mod executor; pub mod local_mounts; pub mod lower; pub mod placement; +#[cfg(feature = "graphql")] +pub mod rebuild; pub use local_mounts::{ LocalDirectMount, LocalEventualMount, LocalProjectionMounts, LocalProjectionMountsBuilder, @@ -54,3 +56,6 @@ pub use provenance::{ #[cfg(test)] mod tests; + +#[cfg(all(test, feature = "graphql"))] +mod source_snapshot_tests; diff --git a/src/projection/placement.rs b/src/projection/placement.rs index a4ed8e41b..489522be2 100644 --- a/src/projection/placement.rs +++ b/src/projection/placement.rs @@ -1358,6 +1358,12 @@ fn validate_direct_eligibility( program: &ProjectionProgram, outputs: &[ProjectionOutput], ) -> Result<(), ProjectionTopologyError> { + if program.source_snapshots() { + return Err(ProjectionTopologyError::DirectIneligible { + reason: "source snapshots require canonical occurrences at the eventual projector" + .into(), + }); + } let [output] = outputs else { return Err(ProjectionTopologyError::DirectIneligible { reason: "direct evidence requires exactly one registered output schema".to_owned(), diff --git a/src/projection/plan.rs b/src/projection/plan.rs index b872d4c41..82b704ece 100644 --- a/src/projection/plan.rs +++ b/src/projection/plan.rs @@ -369,6 +369,8 @@ pub struct ResolvedProjectionPlan { arm_id: String, partition: ResolvedProjectionPartition, mutations: Vec, + #[serde(skip_serializing_if = "std::ops::Not::not")] + source_snapshots: bool, } impl ResolvedProjectionPlan { @@ -385,6 +387,9 @@ impl ResolvedProjectionPlan { program: &ProjectionProgram, occurrence: &DomainEventOccurrence, ) -> Result { + if program.source_snapshots() && occurrence.derivation().is_some() { + return Err(ProjectionProgramError::DerivedSourceSnapshot); + } let matches = program .arms() .iter() @@ -429,9 +434,15 @@ impl ResolvedProjectionPlan { arm_id: arm.arm_id().to_owned(), partition, mutations, + source_snapshots: program.source_snapshots(), }) } + /// Whether full-state writes require authoritative source-version fencing. + pub fn source_snapshots(&self) -> bool { + self.source_snapshots + } + /// Return the canonical program identity. pub fn program_id(&self) -> ProjectionProgramId { self.program_id diff --git a/src/projection/program.rs b/src/projection/program.rs index a40b3c149..23dd83e53 100644 --- a/src/projection/program.rs +++ b/src/projection/program.rs @@ -643,6 +643,8 @@ pub struct ProjectionProgram { version: u64, partition: ProjectionPartition, arms: Vec, + #[serde(skip_serializing_if = "std::ops::Not::not")] + source_snapshots: bool, } impl ProjectionProgram { @@ -691,9 +693,39 @@ impl ProjectionProgram { version, partition, arms, + source_snapshots: false, }) } + /// Fence complete row snapshots by their canonical aggregate occurrence. + /// + /// This is not appropriate for delta folds: dropping an older increment + /// would lose work. Snapshot programs must use a unit partition and only + /// full-row upserts or deletes, without relationship side effects. + pub fn with_source_snapshots(mut self) -> Result { + if !matches!(self.partition, ProjectionPartition::Unit) + || self.arms.iter().flat_map(|arm| arm.operations()).any(|op| { + !matches!( + op.kind(), + ProjectionMutationKind::Upsert | ProjectionMutationKind::Delete + ) || !op.relationship_effects().is_empty() + || !op.invalidations().is_empty() + }) + { + return Err(ProjectionProgramError::InvalidOperation { + operation: self.name.clone(), + reason: "source snapshots require unit-partition full-row upserts/deletes without relationship effects".into(), + }); + } + self.source_snapshots = true; + Ok(self) + } + + /// Whether authoritative row snapshots are fenced by aggregate version. + pub fn source_snapshots(&self) -> bool { + self.source_snapshots + } + /// Return the stable program name. pub fn name(&self) -> &str { &self.name diff --git a/src/projection/rebuild.rs b/src/projection/rebuild.rs new file mode 100644 index 000000000..26506ffc1 --- /dev/null +++ b/src/projection/rebuild.rs @@ -0,0 +1,347 @@ +//! Explicit, bounded maintenance for full-state snapshot projections. +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use crate::graphql::SurfaceProjector; +use crate::projection::lower::ProjectionServerExecutorDescriptor; +use crate::projection_protocol::*; +use crate::table::{TableMutation, TableWritePlan}; +use crate::DomainEventOccurrence; + +pub(crate) const MAX_REBUILD_RECORDS: usize = 10_000; +const MAX_HISTORY_EVENTS: usize = 100_000; +const MAX_HISTORY_BYTES: usize = 64 * 1024 * 1024; + +pub(crate) fn invalid(detail: impl ToString) -> ProjectionProtocolError { + ProjectionProtocolError::InvalidBatch(detail.to_string()) +} + +/// Captured maintenance boundary for one registered snapshot projector. +/// +/// Stop producers and drain their outboxes before beginning. Read the complete, +/// retained canonical history *after* beginning, then call +/// `from_complete_history`. A broker retention boundary is not proof of +/// aggregate-history completeness. This API never runs event handlers, changes +/// broker checkpoints, or republishes messages. +/// +/// Concurrent row changes (including new records) invalidate the captured +/// boundary. Applying a plan is one adapter transaction; failures leave the +/// projection untouched. This bounded API is not an online shadow rebuild or +/// a schema/topology migration. +pub struct SnapshotProjectionRebuild { + pub(crate) context: RebuildContext, + executor: ProjectionServerExecutorDescriptor, + pub(crate) expected: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct RebuildContext { + pub(crate) compiled: CompiledProjectionTopology, + pub(crate) epoch: ProjectionEpoch, +} + +impl RebuildContext { + pub(crate) fn partition(&self) -> Result { + self.compiled + .codec() + .encode_partition(None) + .map_err(invalid) + } +} + +/// Opaque, validated replacement plan. It contains no transport input authority. +pub struct SnapshotProjectionRebuildPlan { + pub(crate) context: RebuildContext, + pub(crate) expected: Vec, + pub(crate) rows: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct RebuildRow { + pub(crate) scope: ProjectionRecordScope, + pub(crate) mutation: TableMutation, + pub(crate) source: SourceSnapshotVersion, +} + +impl RebuildRow { + pub(crate) fn transition( + &self, + current: Option<&ProjectionRecordMetadata>, + ) -> (ProjectionMutationKind, ProjectionRecordExpectation) { + let kind = match (&self.mutation, current) { + (TableMutation::DeleteRow(_), _) => ProjectionMutationKind::Delete, + (_, Some(record)) if record.tombstone => ProjectionMutationKind::Recreate, + _ => ProjectionMutationKind::Upsert, + }; + let expectation = current + .map(|r| ProjectionRecordExpectation::Exact(r.revision.clone())) + .unwrap_or(ProjectionRecordExpectation::Missing); + (kind, expectation) + } + + pub(crate) fn verify_physical( + &self, + current: Option<&ProjectionRecordMetadata>, + exists: bool, + ) -> Result<(), ProjectionProtocolError> { + if exists != current.is_some_and(|r| !r.tombstone) { + return Err(invalid( + "snapshot rebuild found inconsistent row/protocol metadata", + )); + } + Ok(()) + } +} + +#[allow(private_bounds)] +impl SnapshotProjectionRebuild { + /// Capture the current record inventory before reading the source archive. + pub async fn begin( + store: &impl ProjectionProtocolStore, + projector: &SurfaceProjector, + ) -> Result { + let [projection] = projector.modeled.as_slice() else { + return Err(invalid( + "snapshot rebuild requires exactly one modeled binding", + )); + }; + if !projection.is_causally_eligible() + || !matches!( + projection.route(), + crate::projection::placement::ProjectionExecutorRoute::Local { .. } + ) + { + return Err(invalid( + "snapshot rebuild requires an active local eventual binding", + )); + } + let (program, binding) = projection + .raw() + .ok_or_else(|| invalid("snapshot rebuild requires a generated binding"))?; + if !program.source_snapshots() { + return Err(invalid( + "snapshot rebuild requires source: aggregate_snapshot", + )); + } + let physical = binding + .physical_topology() + .ok_or_else(|| invalid("snapshot rebuild requires a physical topology"))?; + let topology = + ProjectorTopologyId::new(physical.version(), physical.name(), physical.digest())?; + let compiled = CompiledProjectionTopology::from_modeled_binding( + topology, + binding + .outputs() + .iter() + .map(|o| (o.model(), o.storage(), o.schema())), + )?; + let executor = projection + .server_executor() + .cloned() + .ok_or_else(|| invalid("snapshot rebuild requires a generated executor"))?; + let context = RebuildContext { + compiled, + epoch: ProjectionEpoch::new(projection.epoch().as_str())?, + }; + let expected = store.projection_rebuild_records(&context).await?; + Ok(Self { + context, + executor, + expected, + }) + } + + /// Resolve retained canonical history through the original typed projection. + /// + /// The caller must supply the entire publication history through the + /// quiescent source head, not a filtered consumer window. This method checks + /// covered record identities, sequence prefixes and conflicting duplicates; + /// it cannot discover unpublished or externally deleted source history. + pub fn from_complete_history( + self, + history: &[DomainEventOccurrence], + ) -> Result { + if history.len() > MAX_HISTORY_EVENTS { + return Err(invalid( + "snapshot rebuild history exceeds 100000 occurrences", + )); + } + let mut bytes = 0usize; + let mut identities = BTreeMap::new(); + let mut sequences: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + let mut rows: HashMap = HashMap::new(); + let mut relevant = BTreeSet::new(); + let mut versions: HashMap> = + HashMap::new(); + for event in history { + let canonical = event.canonical_bytes().map_err(invalid)?; + bytes = bytes + .checked_add(canonical.len()) + .ok_or_else(|| invalid("history size overflow"))?; + if bytes > MAX_HISTORY_BYTES { + return Err(invalid("snapshot rebuild history exceeds 64 MiB")); + } + let stream = ( + event.aggregate_type().to_owned(), + event.aggregate_id().to_owned(), + ); + sequences + .entry(stream.clone()) + .or_default() + .insert(event.aggregate_sequence()); + let identity = ( + stream.clone(), + event.aggregate_sequence(), + event.publication_ordinal(), + ); + if let Some(previous) = identities.insert(identity, canonical.clone()) { + if previous != canonical { + return Err(invalid( + "snapshot rebuild history contains conflicting occurrences", + )); + } + continue; + } + if !self.executor.matches(event) { + continue; + } + relevant.insert(stream); + let lowered = self.executor.plan(event).map_err(invalid)?; + if !lowered.resolved.source_snapshots() { + return Err(invalid("snapshot rebuild resolved a non-snapshot program")); + } + lowered.write_plan.validate()?; + let source = SourceSnapshotVersion::from_occurrence(event)?; + for mut mutation in lowered.write_plan.mutations { + // Protocol inventory CAS, not a domain version column, fences maintenance. + match &mut mutation { + TableMutation::UpsertRow(row) => { + row.expected_version = crate::table::ExpectedVersion::Any; + row.mode = crate::table::RowWriteMode::Upsert; + } + TableMutation::DeleteRow(row) => { + row.expected_version = crate::table::ExpectedVersion::Any + } + _ => {} + } + let (schema, key) = match &mutation { + TableMutation::UpsertRow(row) => (row.schema, &row.key), + TableMutation::DeleteRow(row) => (row.schema, &row.key), + TableMutation::PatchRow(_) => { + return Err(invalid("snapshot rebuild cannot apply patches")) + } + }; + let scope = self + .context + .compiled + .codec() + .encode_row_scope_in_partition( + &schema.model_name, + self.context.partition()?, + key, + ) + .map_err(invalid)?; + versions + .entry(scope.clone()) + .or_default() + .push(source.clone()); + if let Some(previous) = rows.get(&scope) { + if !source.advances(&previous.source)? { + continue; + } + } + rows.insert( + scope.clone(), + RebuildRow { + scope, + mutation, + source: source.clone(), + }, + ); + if rows.len() > MAX_REBUILD_RECORDS { + return Err(invalid("snapshot rebuild exceeds 10000 records")); + } + } + } + for stream in relevant { + let sequence = &sequences[&stream]; + if sequence + .iter() + .copied() + .ne(1..=sequence.last().copied().unwrap_or(0)) + { + return Err(invalid( + "snapshot rebuild requires a complete aggregate sequence prefix", + )); + } + } + for current in &self.expected { + let scope = current.revision.scope(); + let row = rows.get(scope).ok_or_else(|| { + invalid(format!( + "snapshot rebuild history does not cover an existing {} record", + scope.model(), + )) + })?; + if let Some(source) = ¤t.source_snapshot { + if !versions[scope].contains(source) { + return Err(invalid( + "snapshot rebuild history omits the stored source occurrence", + )); + } + // Also validates same-stream ownership and equal-version conflicts. + if source.advances(&row.source)? { + return Err(invalid( + "snapshot rebuild history ends before the stored source version", + )); + } + } + } + let mut rows: Vec<_> = rows.into_values().collect(); + rows.sort_by(|a, b| { + (a.scope.model(), a.scope.canonical_key_bytes()) + .cmp(&(b.scope.model(), b.scope.canonical_key_bytes())) + }); + Ok(SnapshotProjectionRebuildPlan { + context: self.context, + expected: self.expected, + rows, + }) + } +} + +#[allow(private_bounds)] +impl SnapshotProjectionRebuildPlan { + /// Number of complete rows/tombstones represented by this plan. + pub fn record_count(&self) -> usize { + self.rows.len() + } + + /// Atomically apply if the captured inventory has not changed. + pub async fn apply( + self, + store: &impl ProjectionProtocolStore, + ) -> Result { + store.commit_projection_rebuild(self).await + } + + pub(crate) fn verify_inventory( + &self, + current: &[ProjectionRecordMetadata], + ) -> Result<(), ProjectionProtocolError> { + let map = |rows: &[ProjectionRecordMetadata]| { + rows.iter() + .map(|r| (r.revision.scope().clone(), r.clone())) + .collect::>() + }; + if map(current) != map(&self.expected) { + return Err(invalid( + "projection changed during rebuild; begin again with fresh history", + )); + } + Ok(()) + } + + pub(crate) fn write_plan(&self) -> TableWritePlan { + TableWritePlan::new(self.rows.iter().map(|row| row.mutation.clone()).collect()) + } +} diff --git a/src/projection/source_snapshot_tests.rs b/src/projection/source_snapshot_tests.rs new file mode 100644 index 000000000..44de952c4 --- /dev/null +++ b/src/projection/source_snapshot_tests.rs @@ -0,0 +1,710 @@ +//! Same modeled execution proof across memory, SQLite and PostgreSQL. +use std::sync::Arc; + +use crate::domain_event::DomainEventContract; +use crate::projection::executor::prepare_portable_projection; +use crate::projection::lower::{EventualOnly, ProjectionDescriptor}; +use crate::projection_protocol::*; +use crate::{ + DomainEventDescriptor, DomainEventEnvelope, DomainEventOccurrence, RelationalReadModel, RowKey, + RowValue, +}; + +#[derive( + Clone, Debug, serde::Serialize, serde::Deserialize, crate::DomainState, crate::ReadModel, +)] +#[domain_state(version = 1)] +#[readmodel(table = "source_snapshot_rows", primary_key = ["id"])] +struct SnapshotRow { + id: String, + title: String, +} +type SourceSnapshotRows = SnapshotRow; + +struct Changed; +impl DomainEventContract for Changed { + const EVENT_NAME: &'static str = "snapshot.changed"; + const EVENT_VERSION: u64 = 1; + fn descriptor() -> DomainEventDescriptor { + DomainEventDescriptor::state::("snapshot.changed", 1) + } +} +struct Removed; +impl DomainEventContract for Removed { + const EVENT_NAME: &'static str = "snapshot.removed"; + const EVENT_VERSION: u64 = 1; + fn descriptor() -> DomainEventDescriptor { + DomainEventDescriptor::state::("snapshot.removed", 1) + } +} +#[allow(non_snake_case)] +fn SaveSnapshot() -> crate::Mutation<()> { + crate::mutation_file!("tests/fixtures/source_snapshot_save.graphql") +} +#[allow(non_snake_case)] +fn DeleteSnapshot() -> crate::Mutation<()> { + crate::mutation_file!("tests/fixtures/source_snapshot_delete.graphql") +} +crate::projection! { + const SNAPSHOTS: ProjectionDescriptor = { + name: "source-snapshot-test", + version: 1, + epoch: "snapshot-v1", + model: SnapshotRow, + source: aggregate_snapshot, + on { events: [Changed], mutation: SaveSnapshot, input: { row: body }, }, + on { events: [Removed], mutation: DeleteSnapshot, input: { id: aggregate_id }, }, + }; +} + +fn event(id: &str, sequence: u64, title: &str, delete: bool) -> DomainEventOccurrence { + occurrence(id, sequence, 0, id, title, delete) +} + +fn occurrence( + id: &str, + sequence: u64, + ordinal: u32, + row_id: &str, + title: &str, + delete: bool, +) -> DomainEventOccurrence { + let mut occurrence = DomainEventOccurrence::capture( + if delete { + Removed::descriptor() + } else { + Changed::descriptor() + }, + DomainEventEnvelope { + aggregate_type: "snapshot-item".into(), + aggregate_id: id.into(), + aggregate_sequence: sequence, + publication_ordinal: ordinal, + occurred_at: std::time::UNIX_EPOCH, + metadata: Default::default(), + }, + &SnapshotRow { + id: row_id.into(), + title: title.into(), + }, + ) + .unwrap(); + occurrence.overwrite_causation_id(&format!("cause-{id}-{sequence}")); + occurrence +} + +struct Harness { + codec: Arc, +} +impl Harness { + async fn new(store: &impl ProjectionProtocolStore) -> Self { + let topology = ProjectorTopologyId::new(1, "source-snapshot-test", [0x8e; 32]).unwrap(); + let codec = Arc::new( + ProjectionScopeCodec::with_models( + topology.clone(), + [("SnapshotRow", SnapshotRow::schema())], + ) + .unwrap(), + ); + store + .register_projection_models( + &topology, + &[ProjectionModelOwnership::new("SnapshotRow", "source_snapshot_rows").unwrap()], + ) + .await + .unwrap(); + Self { codec } + } + + async fn prepare( + &self, + store: &impl ProjectionProtocolStore, + occurrence: &DomainEventOccurrence, + delivery: u64, + ) -> Result { + let partition = self.codec.encode_partition(None).unwrap(); + let input = TrustedProjectionInput::mint( + ProjectionInputCursor::new( + self.codec.topology().clone(), + partition, + ProjectionSource::new("test-broker", b"stream".to_vec()).unwrap(), + ProjectionEpoch::new("broker-v1").unwrap(), + delivery, + ) + .unwrap(), + ProjectionInputFingerprint::from_canonical_bytes( + &occurrence.canonical_bytes().unwrap(), + ), + occurrence.id(), + occurrence.causation_id().unwrap(), + ProjectionGeneration::initial(), + false, + ) + .unwrap(); + let mut workspace = ProjectionWorkspace::new( + self.codec.clone(), + None, + input, + ProjectionEpoch::new("snapshot-v1").unwrap(), + ) + .unwrap(); + let lowered = SNAPSHOTS + .server_executor() + .unwrap() + .plan(occurrence) + .unwrap(); + let prepared = prepare_portable_projection(&workspace, lowered)?; + let snapshots = store + .projection_execution_snapshot_batch(prepared.snapshot_request()) + .await?; + prepared.stage(&mut workspace, snapshots)?; + workspace.into_batch() + } + + async fn apply( + &self, + store: &impl ProjectionProtocolStore, + occurrence: &DomainEventOccurrence, + delivery: u64, + ) -> Result { + store + .commit_projection(self.prepare(store, occurrence, delivery).await?) + .await + } + + async fn read( + &self, + store: &impl ProjectionProtocolStore, + id: &str, + ) -> ProjectionQuerySnapshot { + store + .projection_query_snapshot( + &ProjectionQuerySnapshotRequest::new( + &self.codec, + None, + "SnapshotRow", + RowKey::new([("id", RowValue::String(id.into()))]), + Vec::new(), + ) + .unwrap(), + ) + .await + .unwrap() + } +} + +async fn matrix(store: &impl ProjectionProtocolStore) { + let h = Harness::new(store).await; + let first = h + .apply(store, &event("a", 3, "new", false), 1) + .await + .unwrap(); + assert_eq!(first.records.len(), 1); + for (delivery, sequence) in [(2, 2), (3, 1)] { + let stale = h + .apply(store, &event("a", sequence, "old", false), delivery) + .await + .unwrap(); + assert!( + stale.records.is_empty(), + "stale source must not allocate a row revision" + ); + let observations: Vec<_> = stale + .changes + .iter() + .filter(|change| change.kind == ProjectionChangeKind::Observation) + .collect(); + assert_eq!( + observations.len(), + 1, + "stale causation must observe current state" + ); + assert_eq!( + observations[0].revision, + Some(first.records[0].revision.clone()) + ); + } + let snapshot = h.read(store, "a").await; + assert_eq!( + snapshot.row.unwrap().get_serde::("title").unwrap(), + "new" + ); + assert_eq!(snapshot.record.unwrap().revision, first.records[0].revision); + + // Deletion before every earlier write must still establish a durable fence. + h.apply(store, &event("b", 5, "", true), 4).await.unwrap(); + h.apply(store, &event("b", 1, "late create", false), 5) + .await + .unwrap(); + let deleted = h.read(store, "b").await; + assert!(deleted.row.is_none()); + assert!(deleted.record.unwrap().tombstone); + // A newer snapshot explicitly recreates the row; stale deletion cannot undo it. + h.apply(store, &event("b", 6, "recreated", false), 6) + .await + .unwrap(); + h.apply(store, &event("b", 4, "", true), 7).await.unwrap(); + let recreated = h.read(store, "b").await; + assert_eq!( + recreated.row.unwrap().get_serde::("title").unwrap(), + "recreated" + ); + assert_eq!(recreated.record.unwrap().revision.incarnation(), 2); + // Repeated newer deletions advance the fence even while the row is absent. + h.apply(store, &event("b", 7, "", true), 8).await.unwrap(); + h.apply(store, &event("b", 9, "", true), 9).await.unwrap(); + h.apply(store, &event("b", 8, "delayed recreate", false), 10) + .await + .unwrap(); + assert!(h.read(store, "b").await.row.is_none()); + + // Occurrence IDs alone do not authenticate the body at an equal version. + assert!(h + .prepare(store, &event("a", 3, "conflict", false), 11) + .await + .is_err()); + // A different stream cannot overwrite another aggregate's row. + assert!(h + .prepare( + store, + &occurrence("intruder", 99, 0, "a", "takeover", false), + 11 + ) + .await + .is_err()); + // Different aggregates with equal sequence have independent clocks. + h.apply(store, &event("c", 1, "independent", false), 11) + .await + .unwrap(); + assert!(h.read(store, "c").await.row.is_some()); + + // Exact occurrence replay is a transport duplicate, not a second mutation. + let e = event("a", 4, "latest", false); + h.apply(store, &e, 12).await.unwrap(); + let replay = h.apply(store, &e, 12).await.unwrap(); + assert_eq!(replay.outcome, ProjectionCommitOutcome::Duplicate); + assert!(replay.records.is_empty()); + + // A stale-read observer cannot falsely confirm a row changed by a concurrent commit. + h.apply(store, &event("race", 3, "initial", false), 13) + .await + .unwrap(); + let stale = h + .prepare(store, &event("race", 2, "old", false), 15) + .await + .unwrap(); + h.apply(store, &event("race", 5, "racing", false), 14) + .await + .unwrap(); + assert!(matches!( + store.commit_projection(stale).await, + Err(ProjectionProtocolError::RecordRevisionConflict { .. }) + )); + + // Multiple publications in one aggregate commit share a causation but have + // an ordered publication ordinal. Late confirmation is still idempotent. + h.apply( + store, + &occurrence("ordinal", 1, 1, "ordinal", "second", false), + 16, + ) + .await + .unwrap(); + h.apply( + store, + &occurrence("ordinal", 1, 0, "ordinal", "first", false), + 17, + ) + .await + .unwrap(); + assert_eq!( + h.read(store, "ordinal") + .await + .row + .unwrap() + .get_serde::("title") + .unwrap(), + "second" + ); + + // Even a lower-level writer cannot clear an existing source fence. + let mut unfenced = h + .prepare(store, &event("a", 6, "unsafe", false), 19) + .await + .unwrap(); + unfenced.mutations[0].source_snapshot = None; + assert!(matches!( + store.commit_projection(unfenced).await, + Err(ProjectionProtocolError::InvalidBatch(_)) + )); + assert_eq!( + h.read(store, "a") + .await + .row + .unwrap() + .get_serde::("title") + .unwrap(), + "latest" + ); + + // Migration does not invent a source version for old read-model rows. + let mut unversioned = h + .prepare(store, &event("unversioned", 1, "old schema", false), 19) + .await + .unwrap(); + unversioned.mutations[0].source_snapshot = None; + store.commit_projection(unversioned).await.unwrap(); + assert!(h + .prepare(store, &event("unversioned", 2, "new schema", false), 20) + .await + .is_err()); +} + +#[tokio::test] +async fn source_snapshots_memory_reordering_and_atomic_confirmation() { + matrix(&crate::InMemoryRepository::new()).await; +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn source_snapshots_sqlite_reordering_and_restart() { + use crate::table::TableSchemaRegistry; + let path = std::env::temp_dir().join(format!( + "distributed-source-snapshots-{}.sqlite", + uuid::Uuid::now_v7() + )); + let url = format!("sqlite:{}?mode=rwc", path.display()); + let store = crate::SqliteRepository::connect_and_migrate(&url) + .await + .unwrap(); + let mut registry = TableSchemaRegistry::new(); + registry + .register_schema(SnapshotRow::schema().clone()) + .unwrap(); + store + .bootstrap_table_schema_for_dev(®istry) + .await + .unwrap(); + matrix(&store).await; + store.pool().close().await; + let restarted = crate::SqliteRepository::connect_and_migrate(&url) + .await + .unwrap(); + let h = Harness::new(&restarted).await; + h.apply(&restarted, &event("b", 2, "after restart", false), 21) + .await + .unwrap(); + assert!(h.read(&restarted, "b").await.row.is_none()); + restarted.pool().close().await; + std::fs::remove_file(path).unwrap(); +} + +#[cfg(feature = "postgres")] +#[tokio::test] +async fn source_snapshots_postgres_reordering_and_restart() { + let Ok(url) = std::env::var("DISTRIBUTED_SNAPSHOT_TEST_POSTGRES_URL") else { + return; + }; + let store = crate::PostgresRepository::connect_and_migrate(&url) + .await + .unwrap(); + let mut registry = crate::table::TableSchemaRegistry::new(); + registry + .register_schema(SnapshotRow::schema().clone()) + .unwrap(); + store + .bootstrap_table_schema_for_dev(®istry) + .await + .unwrap(); + matrix(&store).await; + store.pool().close().await; + let restarted = crate::PostgresRepository::connect_and_migrate(&url) + .await + .unwrap(); + let h = Harness::new(&restarted).await; + h.apply(&restarted, &event("b", 2, "after restart", false), 21) + .await + .unwrap(); + assert!(h.read(&restarted, "b").await.row.is_none()); +} + +#[test] +fn source_snapshots_policy_is_explicit_hashed_and_rejects_delta_programs() { + use crate::projection::{ + ProjectionArm, ProjectionMutationKind as Kind, ProjectionOperation, ProjectionPartition, + ProjectionProgram, + }; + let snapshots = SNAPSHOTS.program().unwrap(); + let ordered = ProjectionProgram::try_new( + snapshots.name(), + snapshots.version(), + ProjectionPartition::Unit, + snapshots.arms().to_vec(), + ) + .unwrap(); + assert!(snapshots.source_snapshots()); + assert!(!ordered.source_snapshots()); + assert_ne!(snapshots.id().unwrap(), ordered.id().unwrap()); + let arm = &snapshots.arms()[0]; + let op = &arm.operations()[0]; + for kind in [Kind::Insert, Kind::Patch, Kind::UpsertPatch, Kind::Recreate] { + let operation = ProjectionOperation::try_new( + op.operation_id(), + 0, + kind, + op.target().clone(), + op.key().to_vec(), + op.fields().to_vec(), + vec![], + vec![], + ) + .unwrap(); + let arm = + ProjectionArm::try_new(arm.arm_id(), arm.selector().clone(), vec![operation]).unwrap(); + let program = + ProjectionProgram::try_new("delta", 1, ProjectionPartition::Unit, vec![arm]).unwrap(); + assert!( + program.with_source_snapshots().is_err(), + "{kind:?} cannot silently become a snapshot" + ); + } + let partitioned = ProjectionProgram::try_new( + "partitioned", + 1, + ProjectionPartition::Expression(op.key()[0].expression().clone()), + snapshots.arms().to_vec(), + ) + .unwrap(); + assert!(partitioned.with_source_snapshots().is_err()); +} + +#[test] +fn source_snapshots_reject_direct_materialization() { + use crate::projection::placement::*; + let result = ProjectionBinding::materialize_direct( + DirectProjectionPlacement::new(&SNAPSHOTS), + ProjectionSourceBinding::try_new("snapshot-domain", "ordered-domain-events", 1).unwrap(), + ProjectionOwner::try_new("snapshot-direct").unwrap(), + "distributed-projection-partition", + 1, + vec![ProjectionOutput::try_new( + "SnapshotRow", + "source_snapshot_rows", + SnapshotRow::schema().clone(), + ) + .unwrap()], + vec![], + None, + ); + assert!(matches!( + result, + Err(ProjectionTopologyError::DirectIneligible { .. }) + )); +} + +fn rebuild_projector() -> crate::graphql::SurfaceProjector { + let mounts = crate::LocalProjectionMountsBuilder::new("snapshot-test", "events") + .unwrap() + .eventual_model::("rebuild-test", SNAPSHOTS, "snapshot-v1") + .unwrap() + .build() + .unwrap(); + mounts.projector("rebuild-test").unwrap() +} + +async fn rebuild_matrix(store: &impl ProjectionProtocolStore) { + use crate::projection::rebuild::SnapshotProjectionRebuild; + let projector = rebuild_projector(); + let (_, binding) = projector.modeled[0].raw().unwrap(); + let physical = binding.physical_topology().unwrap(); + let compiled = CompiledProjectionTopology::from_modeled_binding( + ProjectorTopologyId::new(physical.version(), physical.name(), physical.digest()).unwrap(), + binding + .outputs() + .iter() + .map(|o| (o.model(), o.storage(), o.schema())), + ) + .unwrap(); + store + .register_projection_models(compiled.topology(), compiled.ownership()) + .await + .unwrap(); + let h = Harness { + codec: compiled.codec(), + }; + let first = event("a", 1, "old", false); + let second = event("a", 2, "new", false); + let removed = event("a", 3, "deleted", true); + let mut old = h.prepare(store, &first, 1).await.unwrap(); + old.mutations[0].source_snapshot = None; + let applied = store.commit_projection(old).await.unwrap(); + let checkpoint = applied.checkpoint.unwrap(); + let original = h.read(store, "a").await; + + // Neither a missing record nor a suffix masquerading as full history passes. + for history in [ + vec![], + vec![second.clone()], + vec![first.clone(), removed.clone()], + ] { + assert!(SnapshotProjectionRebuild::begin(store, &projector) + .await + .unwrap() + .from_complete_history(&history) + .is_err()); + } + let mut conflicting = second.clone(); + conflicting.overwrite_causation_id("another-cause"); + assert!(SnapshotProjectionRebuild::begin(store, &projector) + .await + .unwrap() + .from_complete_history(&[first.clone(), second.clone(), conflicting]) + .is_err()); + assert_eq!(h.read(store, "a").await.record, original.record); + + let rebuild = SnapshotProjectionRebuild::begin(store, &projector) + .await + .unwrap(); + // Reverse delivery and exact duplicate occurrences are deterministic. + let plan = rebuild + .from_complete_history(&[second.clone(), first.clone(), second.clone()]) + .unwrap(); + assert_eq!(plan.record_count(), 1); + assert_eq!(plan.apply(store).await.unwrap(), 1); + let row = h.read(store, "a").await; + assert_eq!( + row.row.unwrap().get("title"), + Some(&RowValue::String("new".into())) + ); + assert!(row.record.unwrap().source_snapshot.is_some()); + assert_eq!( + store + .projection_checkpoint(checkpoint.input(), ProjectionGeneration::initial()) + .await + .unwrap(), + Some(checkpoint.clone()) + ); + // Inbox/checkpoint identity remains intact; no domain event was republished. + assert_eq!( + h.apply(store, &first, 1).await.unwrap().outcome, + ProjectionCommitOutcome::Duplicate + ); + h.apply(store, &first, 2).await.unwrap_err(); // same message, different position is still rejected + h.apply(store, &removed, 3).await.unwrap(); + + // Exact inventory CAS rejects modifications made after begin, including inserts. + let pending = SnapshotProjectionRebuild::begin(store, &projector) + .await + .unwrap() + .from_complete_history(&[first.clone(), second.clone(), removed.clone()]) + .unwrap(); + let other = event("b", 1, "other", false); + h.apply(store, &other, 4).await.unwrap(); + assert!(pending.apply(store).await.is_err()); + assert!(h.read(store, "a").await.row.is_none()); + // Missing the stored tombstone occurrence cannot resurrect it. + assert!(SnapshotProjectionRebuild::begin(store, &projector) + .await + .unwrap() + .from_complete_history(&[first.clone(), second.clone(), other.clone()]) + .is_err()); + let recreated = event("a", 4, "recreated", false); + let history = [first, second, removed, other, recreated.clone()]; + SnapshotProjectionRebuild::begin(store, &projector) + .await + .unwrap() + .from_complete_history(&history) + .unwrap() + .apply(store) + .await + .unwrap(); + let restored = h.read(store, "a").await; + assert_eq!(restored.record.unwrap().revision.incarnation(), 2); + assert_eq!( + restored.row.unwrap().get("title"), + Some(&RowValue::String("recreated".into())) + ); +} + +async fn rebuild_rollback(store: &impl ProjectionProtocolStore) { + use crate::projection::rebuild::SnapshotProjectionRebuild; + let projector = rebuild_projector(); + let before = SnapshotProjectionRebuild::begin(store, &projector) + .await + .unwrap(); + let h = Harness { + codec: before.context.compiled.codec(), + }; + let original_a = h.read(store, "a").await; + let original_b = h.read(store, "b").await; + let history = [ + event("a", 1, "old", false), + event("a", 2, "new", false), + event("a", 3, "deleted", true), + event("a", 4, "recreated", false), + event("a", 5, "valid first write", false), + event("b", 1, "other", false), + event("b", 2, "reject me", false), + ]; + let error = before + .from_complete_history(&history) + .unwrap() + .apply(store) + .await + .unwrap_err(); + assert!(error.to_string().contains("rebuild_test_reject"), "{error}"); + let after_a = h.read(store, "a").await; + let after_b = h.read(store, "b").await; + assert_eq!(after_a.row, original_a.row); + assert_eq!(after_b.row, original_b.row); + assert_eq!(after_a.record, original_a.record); + assert_eq!(after_b.record, original_b.record); +} + +#[tokio::test] +async fn snapshot_rebuild_memory() { + rebuild_matrix(&crate::InMemoryRepository::new()).await; +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn snapshot_rebuild_sqlite() { + let store = crate::SqliteRepository::connect_and_migrate("sqlite::memory:") + .await + .unwrap(); + let mut registry = crate::table::TableSchemaRegistry::new(); + registry + .register_schema(SnapshotRow::schema().clone()) + .unwrap(); + store + .bootstrap_table_schema_for_dev(®istry) + .await + .unwrap(); + rebuild_matrix(&store).await; + sqlx::query("CREATE TRIGGER rebuild_test_reject BEFORE UPDATE ON source_snapshot_rows WHEN NEW.title = 'reject me' BEGIN SELECT RAISE(ABORT, 'rebuild_test_reject'); END") + .execute(store.pool()).await.unwrap(); + rebuild_rollback(&store).await; +} + +#[cfg(feature = "postgres")] +#[tokio::test] +async fn snapshot_rebuild_postgres() { + let Ok(url) = std::env::var("DISTRIBUTED_REBUILD_TEST_POSTGRES_URL") else { + return; + }; + let store = crate::PostgresRepository::connect_and_migrate(&url) + .await + .unwrap(); + let mut registry = crate::table::TableSchemaRegistry::new(); + registry + .register_schema(SnapshotRow::schema().clone()) + .unwrap(); + store + .bootstrap_table_schema_for_dev(®istry) + .await + .unwrap(); + rebuild_matrix(&store).await; + sqlx::query("ALTER TABLE source_snapshot_rows ADD CONSTRAINT rebuild_test_reject CHECK (title <> 'reject me')") + .execute(store.pool()).await.unwrap(); + rebuild_rollback(&store).await; +} diff --git a/src/projection_protocol.rs b/src/projection_protocol.rs index 17293d241..7173c1f19 100644 --- a/src/projection_protocol.rs +++ b/src/projection_protocol.rs @@ -1,8 +1,9 @@ //! Adapter-neutral identities and ordering vocabulary for durable projections. //! -//! These types deliberately separate three different notions of progress: +//! These types deliberately separate four different notions of progress: //! //! - [`ProjectionInputCursor`] orders trusted inputs from one exact source; +//! - [`SourceSnapshotVersion`] fences snapshots from one aggregate stream; //! - [`RecordRevision`] orders versions of one exact projected record; and //! - [`ProjectionChangeCursor`] orders durable changes emitted by a projector. //! @@ -16,8 +17,11 @@ use std::num::{NonZeroU32, NonZeroU64}; use serde::{Deserialize, Serialize}; mod codec; +mod source_snapshot; mod store; mod workspace; +pub(crate) use source_snapshot::validate_snapshot_write; +pub use source_snapshot::SourceSnapshotVersion; pub(crate) use codec::{ canonical_projection_topology_bytes, compile_projection_topology, digest_projection_binding, diff --git a/src/projection_protocol/source_snapshot.rs b/src/projection_protocol/source_snapshot.rs new file mode 100644 index 000000000..fdf8d6542 --- /dev/null +++ b/src/projection_protocol/source_snapshot.rs @@ -0,0 +1,104 @@ +//! Authoritative source versions, separate from broker and row revisions. + +use super::ProjectionProtocolError; +use crate::DomainEventOccurrence; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Durable fence for a full-state projection of one aggregate stream. +/// +/// Constructed by the framework from a validated canonical occurrence, never +/// from transport headers or application-supplied timestamps. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceSnapshotVersion { + aggregate_type: String, + aggregate_id: String, + sequence: u64, + publication_ordinal: u32, + occurrence_id: String, + occurrence_fingerprint: [u8; 32], +} + +impl SourceSnapshotVersion { + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn validate_stored(&self) -> Result<(), ProjectionProtocolError> { + if self.sequence == 0 + || crate::bus::validate_message_name(&self.aggregate_type).is_err() + || crate::bus::validate_stable_message_id(Some(&self.aggregate_id)).is_err() + || crate::bus::validate_stable_message_id(Some(&self.occurrence_id)).is_err() + { + return Err(ProjectionProtocolError::InvalidBatch( + "invalid stored source snapshot identity".into(), + )); + } + Ok(()) + } + + pub(crate) fn from_occurrence( + event: &DomainEventOccurrence, + ) -> Result { + if event.derivation().is_some() { + return Err(ProjectionProtocolError::InvalidBatch( + "derived facts cannot establish an aggregate source snapshot".into(), + )); + } + let canonical = event + .canonical_bytes() + .map_err(|error| ProjectionProtocolError::InvalidBatch(error.to_string()))?; + Ok(Self { + aggregate_type: event.aggregate_type().into(), + aggregate_id: event.aggregate_id().into(), + sequence: event.aggregate_sequence(), + publication_ordinal: event.publication_ordinal(), + occurrence_id: event.id().into(), + occurrence_fingerprint: Sha256::digest(canonical).into(), + }) + } + + /// True only when this occurrence advances the same authoritative stream. + pub(crate) fn advances(&self, current: &Self) -> Result { + if self.aggregate_type != current.aggregate_type + || self.aggregate_id != current.aggregate_id + { + return Err(ProjectionProtocolError::InvalidBatch( + "source snapshot row is owned by another aggregate stream".into(), + )); + } + let incoming = (self.sequence, self.publication_ordinal); + let stored = (current.sequence, current.publication_ordinal); + if incoming == stored + && (self.occurrence_id != current.occurrence_id + || self.occurrence_fingerprint != current.occurrence_fingerprint) + { + return Err(ProjectionProtocolError::InvalidBatch( + "source snapshot version has conflicting occurrence identities".into(), + )); + } + Ok(incoming > stored) + } +} + +/// Defense at the atomic commit boundary, including non-modeled writers. +pub(crate) fn validate_snapshot_write( + current: Option<&super::ProjectionRecordMetadata>, + incoming: Option<&SourceSnapshotVersion>, +) -> Result<(), ProjectionProtocolError> { + match (current, incoming) { + (Some(record), Some(incoming)) => match &record.source_snapshot { + Some(stored) if incoming.advances(stored)? => Ok(()), + Some(_) => Err(ProjectionProtocolError::InvalidBatch( + "source snapshot write does not advance the stored version".into(), + )), + None => Err(ProjectionProtocolError::InvalidBatch( + "source snapshot requires rebuilding an unversioned row".into(), + )), + }, + (Some(record), None) if record.source_snapshot.is_some() => { + Err(ProjectionProtocolError::InvalidBatch( + "unversioned write cannot replace a source-fenced row".into(), + )) + } + _ => Ok(()), + } +} diff --git a/src/projection_protocol/store/backend_helpers.rs b/src/projection_protocol/store/backend_helpers.rs index 47feb32d4..6b6f62a2a 100644 --- a/src/projection_protocol/store/backend_helpers.rs +++ b/src/projection_protocol/store/backend_helpers.rs @@ -5,7 +5,8 @@ use super::{ }; use crate::projection_protocol::MAX_PROJECTION_POSITION; use crate::table::{ - has_many_join_columns, RelationshipDef, TableMutation, TableSchema, TableStoreError, + resolve_direct_join_keys, RelationshipDef, RelationshipKind, RowValue, RowValues, + TableMutation, TableSchema, TableStoreError, }; pub(crate) fn checked_next( @@ -101,15 +102,40 @@ pub(crate) fn validate_projection_graph_snapshot_request( Ok(()) } -pub(crate) fn projection_has_many_columns( +pub(crate) fn projection_relationship_values( root_schema: &TableSchema, + root_row: &RowValues, relationship: &RelationshipDef, target_schema: &TableSchema, -) -> Result<(String, String), ProjectionProtocolError> { - has_many_join_columns(root_schema, relationship, target_schema).map_err(|error| match error { - TableStoreError::Metadata(message) => ProjectionProtocolError::InvalidBatch(message), - other => ProjectionProtocolError::Table(other), - }) +) -> Result, ProjectionProtocolError> { + let pairs = + resolve_direct_join_keys(root_schema, relationship, target_schema).map_err(|error| { + match error { + TableStoreError::Metadata(message) => { + ProjectionProtocolError::InvalidBatch(message) + } + other => ProjectionProtocolError::Table(other), + } + })?; + pairs + .into_iter() + .map(|pair| { + let (source, target) = match relationship.kind { + RelationshipKind::HasMany => (pair.primary_key_column, pair.foreign_key_column), + RelationshipKind::BelongsTo => (pair.foreign_key_column, pair.primary_key_column), + RelationshipKind::ManyToMany => { + unreachable!("direct resolver rejects many-to-many") + } + }; + let value = root_row.get(&source).cloned().ok_or_else(|| { + ProjectionProtocolError::InvalidBatch(format!( + "projection graph root `{}` is missing relationship key `{source}`", + root_schema.model_name + )) + })?; + Ok((target, value)) + }) + .collect() } pub(crate) fn checked_projection_graph_materialization( diff --git a/src/projection_protocol/store/commit.rs b/src/projection_protocol/store/commit.rs index 525a35f04..5a50f58b5 100644 --- a/src/projection_protocol/store/commit.rs +++ b/src/projection_protocol/store/commit.rs @@ -34,6 +34,14 @@ impl ProjectionCommitBatch { let mut scopes = std::collections::HashSet::new(); for mutation in &self.mutations { validate_scope(topology, partition, &mutation.scope)?; + if mutation.kind == ProjectionMutationKind::Delete + && matches!(mutation.expectation, ProjectionRecordExpectation::Missing) + && mutation.source_snapshot.is_none() + { + return Err(ProjectionProtocolError::InvalidBatch( + "delete of an unseen row requires an authoritative source snapshot".into(), + )); + } let schema = match &mutation.mutation { TableMutation::UpsertRow(mutation) => mutation.schema, TableMutation::PatchRow(mutation) => mutation.schema, diff --git a/src/projection_protocol/store/identity.rs b/src/projection_protocol/store/identity.rs index 791d42025..14d55028d 100644 --- a/src/projection_protocol/store/identity.rs +++ b/src/projection_protocol/store/identity.rs @@ -231,6 +231,7 @@ pub(crate) struct ProjectionRecordMutation { pub(crate) mutation: TableMutation, pub(crate) expectation: ProjectionRecordExpectation, pub(crate) kind: ProjectionMutationKind, + pub(crate) source_snapshot: Option, } impl ProjectionRecordMutation { @@ -239,6 +240,16 @@ impl ProjectionRecordMutation { mutation: TableMutation, expectation: ProjectionRecordExpectation, kind: ProjectionMutationKind, + ) -> Result { + Self::with_source_snapshot(scope, mutation, expectation, kind, None) + } + + pub(crate) fn with_source_snapshot( + scope: ProjectionRecordScope, + mutation: TableMutation, + expectation: ProjectionRecordExpectation, + kind: ProjectionMutationKind, + source_snapshot: Option, ) -> Result { if let ProjectionRecordExpectation::Exact(revision) = &expectation { if revision.scope() != &scope { @@ -253,10 +264,9 @@ impl ProjectionRecordMutation { "projection delete kind and table mutation disagree".into(), )); } - if matches!( - kind, - ProjectionMutationKind::Delete | ProjectionMutationKind::Recreate - ) && !matches!(expectation, ProjectionRecordExpectation::Exact(_)) + if (kind == ProjectionMutationKind::Recreate + || (kind == ProjectionMutationKind::Delete && source_snapshot.is_none())) + && !matches!(expectation, ProjectionRecordExpectation::Exact(_)) { return Err(ProjectionProtocolError::InvalidBatch( "projection delete/recreate requires an exact record revision".into(), @@ -267,6 +277,7 @@ impl ProjectionRecordMutation { mutation, expectation, kind, + source_snapshot, }) } } diff --git a/src/projection_protocol/store/mod.rs b/src/projection_protocol/store/mod.rs index 6c54b57c3..0ab5d9fb3 100644 --- a/src/projection_protocol/store/mod.rs +++ b/src/projection_protocol/store/mod.rs @@ -56,7 +56,7 @@ use identity::{ pub(crate) use backend_helpers::{ change_kind_for_mutation, checked_next, checked_projection_graph_materialization, - failure_matches_batch, projection_has_many_columns, table_model_name, + failure_matches_batch, projection_relationship_values, table_model_name, validate_projection_graph_snapshot_request, }; pub(crate) use commit::{ diff --git a/src/projection_protocol/store/query.rs b/src/projection_protocol/store/query.rs index eb611470a..035207498 100644 --- a/src/projection_protocol/store/query.rs +++ b/src/projection_protocol/store/query.rs @@ -6,6 +6,8 @@ pub struct ProjectionRecordMetadata { pub revision: RecordRevision, pub tombstone: bool, pub change: ProjectionChangeCursor, + /// Authoritative source fence for explicitly declared snapshot projections. + pub source_snapshot: Option, } /// One exact input-source checkpoint requested alongside a physical query row. diff --git a/src/projection_protocol/store/replay.rs b/src/projection_protocol/store/replay.rs index 9bcc2d102..df6a257e0 100644 --- a/src/projection_protocol/store/replay.rs +++ b/src/projection_protocol/store/replay.rs @@ -280,6 +280,7 @@ impl ReplayCursor { impl ReplayRecord { fn into_record(self) -> Result { Ok(ProjectionRecordMetadata { + source_snapshot: None, revision: self.revision.into_revision()?, tombstone: self.tombstone, change: self.change.into_cursor()?, diff --git a/src/projection_protocol/store/tests.rs b/src/projection_protocol/store/tests.rs index bb401dd79..0c7d6685c 100644 --- a/src/projection_protocol/store/tests.rs +++ b/src/projection_protocol/store/tests.rs @@ -42,6 +42,7 @@ fn same_transaction_evidence() -> SameTransactionProjectionEvidence { .unwrap(); SameTransactionProjectionEvidence { records: vec![ProjectionRecordMetadata { + source_snapshot: None, revision: revision.clone(), tombstone: false, change: change.clone(), diff --git a/src/projection_protocol/store/trait.rs b/src/projection_protocol/store/trait.rs index b8fda9b4c..378b6699c 100644 --- a/src/projection_protocol/store/trait.rs +++ b/src/projection_protocol/store/trait.rs @@ -15,6 +15,31 @@ pub enum ProjectionChangeRead { /// Adapter contract for atomic causal projection persistence. pub(crate) trait ProjectionProtocolStore: Send + Sync { + #[cfg(feature = "graphql")] + fn projection_rebuild_records<'a>( + &'a self, + _context: &'a crate::projection::rebuild::RebuildContext, + ) -> impl Future, ProjectionProtocolError>> + Send + 'a + { + async { + Err(ProjectionProtocolError::InvalidBatch( + "this store does not support snapshot projection rebuilds".into(), + )) + } + } + + #[cfg(feature = "graphql")] + fn commit_projection_rebuild( + &self, + _plan: crate::projection::rebuild::SnapshotProjectionRebuildPlan, + ) -> impl Future> + Send + '_ { + async { + Err(ProjectionProtocolError::InvalidBatch( + "this store does not support snapshot projection rebuilds".into(), + )) + } + } + /// Install model-wide causal ownership before projector traffic begins. /// This bootstrap marker closes the absent-row race with legacy/raw write /// plans; per-partition ownership is still verified inside each commit. diff --git a/src/projection_protocol/workspace.rs b/src/projection_protocol/workspace.rs index 8f67b0144..e6bdb9c21 100644 --- a/src/projection_protocol/workspace.rs +++ b/src/projection_protocol/workspace.rs @@ -317,11 +317,21 @@ impl ProjectionWorkspace { mutation: TableMutation, expectation: ProjectionRecordExpectation, kind: ProjectionMutationKind, + source: Option, ) -> Result<&mut Self, ProjectionProtocolError> { validate_execution_mutation_shape(&mutation, &expectation, kind)?; let (schema, key) = mutation_schema_key(&mutation); let scope = self.record_scope(schema, key)?; - self.stage(schema, scope, mutation, expectation, kind) + self.push_staged( + schema, + ProjectionRecordMutation::with_source_snapshot( + scope, + mutation, + expectation, + kind, + source, + )?, + ) } #[allow(dead_code)] @@ -434,6 +444,18 @@ impl ProjectionWorkspace { expectation: ProjectionRecordExpectation, kind: ProjectionMutationKind, ) -> Result<&mut Self, ProjectionProtocolError> { + self.push_staged( + schema, + ProjectionRecordMutation::new(scope, mutation, expectation, kind)?, + ) + } + + fn push_staged( + &mut self, + schema: &'static TableSchema, + mutation: ProjectionRecordMutation, + ) -> Result<&mut Self, ProjectionProtocolError> { + let scope = mutation.scope.clone(); if !self.staged_scopes.insert(scope.clone()) { return Err(ProjectionProtocolError::InvalidBatch(format!( "projection workspace repeats model `{}` record scope", @@ -441,12 +463,7 @@ impl ProjectionWorkspace { ))); } self.register_ownership(schema)?; - self.mutations.push(ProjectionRecordMutation::new( - scope.clone(), - mutation, - expectation, - kind, - )?); + self.mutations.push(mutation); self.observations.push(ProjectionObservationRequest { kind: ProjectionObservationKind::Record, target: ProjectionObservationTarget::StagedRecord(scope), @@ -486,7 +503,7 @@ fn validate_execution_mutation_shape( ) => row.mode == PatchMode::UpdateExisting && row.expected_version == ExpectedVersion::Any, ( TableMutation::DeleteRow(row), - ProjectionRecordExpectation::Exact(_), + ProjectionRecordExpectation::Exact(_) | ProjectionRecordExpectation::Missing, ProjectionMutationKind::Delete, ) => row.expected_version == ExpectedVersion::Any, ( diff --git a/src/queued_repo/repository.rs b/src/queued_repo/repository.rs index 0bb295279..d4138054b 100644 --- a/src/queued_repo/repository.rs +++ b/src/queued_repo/repository.rs @@ -187,6 +187,14 @@ where R: CausalGetStream, L: LockManager, { + fn get_causal_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl Future, RepositoryError>> + Send + 'a { + self.inner.get_causal_stream_tail(identity, after_version) + } + fn get_causal_stream<'a>( &'a self, identity: &'a StreamIdentity, @@ -296,11 +304,73 @@ where } } +impl crate::microsvc::CausalHostProjections for QueuedRepository +where + R: crate::microsvc::CausalHostProjections, + L: LockManager, +{ + #[cfg(feature = "graphql")] + fn command_obligation_evidence<'a>( + &'a self, + request: &'a crate::projection_protocol::ProjectionObligationEvidenceBatchRequest, + ) -> impl std::future::Future< + Output = Result< + crate::projection_protocol::ProjectionObligationEvidenceBatch, + crate::projection_protocol::ProjectionProtocolError, + >, + > + Send + + 'a { + self.inner.command_obligation_evidence(request) + } + + #[cfg(feature = "graphql")] + fn command_causation_evidence<'a>( + &'a self, + request: &'a crate::projection_protocol::ProjectionCausationEvidenceRequest, + ) -> impl std::future::Future< + Output = Result< + crate::projection_protocol::ProjectionCausationEvidenceBatch, + crate::projection_protocol::ProjectionProtocolError, + >, + > + Send + + 'a { + self.inner.command_causation_evidence(request) + } + fn __register_direct_projection_models<'a>( + &'a self, + topology: &'a crate::projection_protocol::ProjectorTopologyId, + ownership: &'a [crate::projection_protocol::ProjectionModelOwnership], + ) -> impl std::future::Future< + Output = Result<(), crate::projection_protocol::ProjectionProtocolError>, + > + Send + + 'a { + self.inner + .__register_direct_projection_models(topology, ownership) + } +} + impl ProjectionProtocolStore for QueuedRepository where R: ProjectionProtocolStore, L: LockManager, { + #[cfg(feature = "graphql")] + async fn projection_rebuild_records( + &self, + context: &crate::projection::rebuild::RebuildContext, + ) -> Result, ProjectionProtocolError> + { + self.inner.projection_rebuild_records(context).await + } + + #[cfg(feature = "graphql")] + async fn commit_projection_rebuild( + &self, + plan: crate::projection::rebuild::SnapshotProjectionRebuildPlan, + ) -> Result { + self.inner.commit_projection_rebuild(plan).await + } + fn register_projection_models<'a>( &'a self, topology: &'a ProjectorTopologyId, diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index 6b3ded6ef..ce82fdac4 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -13,9 +13,7 @@ use super::{ RelationalReadModel, Versioned, }; use crate::repository::{ReadModelWritePlanStore, RelationalReadModelQueryStore}; -use crate::table::{ - column_name_for, has_many_join_columns, key_fingerprint, validate_key, validate_row_values, -}; +use crate::table::{has_many_join_columns, key_fingerprint, validate_key, validate_row_values}; use crate::table::{ ExpectedVersion, PatchMode, RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, RowWriteMode, TableAdapterCapabilities, TableCommitOutcome, TableMutation, TableSchema, @@ -493,50 +491,23 @@ fn load_belongs_to_rows( root_row: &RowValues, spec: &IncludeSpec, ) -> Result>, TableStoreError> { - let foreign_key = spec.relationship.foreign_key.as_deref().ok_or_else(|| { - TableStoreError::Metadata(format!( - "relationship `{}` must declare a foreign key", - spec.relationship.field_name - )) - })?; - let source_column = column_name_for(root_schema, foreign_key).ok_or_else(|| { - TableStoreError::Metadata(format!( - "relationship `{}` foreign key `{}` is not a source column", - spec.relationship.field_name, foreign_key - )) - })?; - let target_column = belongs_to_target_column(&spec.target_schema, &source_column)?; + let (source_column, target_column) = crate::table::belongs_to_join_columns( + root_schema, + &spec.relationship, + &spec.target_schema, + )?; let source_value = root_row.get(&source_column).ok_or_else(|| { TableStoreError::Metadata(format!( "read model `{}` root row is missing relationship key `{}`", root_schema.model_name, source_column )) })?; - let key = RowKey::new([(target_column, source_value.clone())]); - let storage_key = relational_storage_key(&spec.target_schema.table_name, &key); - Ok(rows - .get(&storage_key) - .map(|row| { - vec![Versioned { - data: row.values.clone(), - version: row.version, - }] - }) - .unwrap_or_default()) -} - -fn belongs_to_target_column( - target_schema: &TableSchema, - source_column: &str, -) -> Result { - if target_schema.primary_key.columns.len() != 1 { - return Err(TableStoreError::Metadata(format!( - "belongs_to target `{}` must have a single-column primary key to load from `{}`", - target_schema.model_name, source_column - ))); - } - - Ok(target_schema.primary_key.columns[0].clone()) + Ok(rows_matching_column( + rows, + &spec.target_schema.table_name, + &target_column, + source_value, + )) } fn rows_matching_column( @@ -545,6 +516,9 @@ fn rows_matching_column( column: &str, value: &RowValue, ) -> Vec> { + if matches!(value, RowValue::Null) { + return Vec::new(); + } let prefix = format!("{table_name}:"); let mut matches = rows .iter() @@ -623,6 +597,69 @@ mod tests { ); } + #[test] + fn belongs_to_unique_key_matches_values_not_primary_key_and_never_nulls() { + let mut target = test_row_schema().clone(); + target.columns.push(TableColumn { + nullable: true, + ..TableColumn::new("slug", "slug", ColumnType::Text) + }); + target.indexes.push(crate::TableIndex { + name: None, + columns: vec!["slug".into()], + unique: true, + }); + let mut source = test_row_schema().clone(); + source.columns.push(TableColumn { + nullable: true, + ..TableColumn::new("target_slug", "target_slug", ColumnType::Text) + }); + let spec = IncludeSpec { + name: "target".into(), + target_schema: target.clone(), + relationship: RelationshipDef { + field_name: "target".into(), + kind: RelationshipKind::BelongsTo, + target_model: target.model_name.clone(), + foreign_key: Some("target_slug".into()), + references: Some("slug".into()), + through: None, + target_foreign_key: None, + }, + }; + let mut rows = HashMap::new(); + for (id, slug) in [ + ("opaque", RowValue::String("chosen".into())), + ("chosen", RowValue::String("other".into())), + ("null-target", RowValue::Null), + ] { + let key = RowKey::new([("id", RowValue::String(id.into()))]); + let mut values = RowValues::new(); + values.insert("id", RowValue::String(id.into())); + values.insert("slug", slug); + rows.insert( + relational_storage_key(&target.table_name, &key), + StoredRow { values, version: 1 }, + ); + } + let mut root = RowValues::new(); + root.insert("target_slug", RowValue::String("chosen".into())); + let found = load_belongs_to_rows(&rows, &source, &root, &spec).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!( + found[0].data.get("id"), + Some(&RowValue::String("opaque".into())) + ); + root.insert("target_slug", RowValue::Null); + assert!(load_belongs_to_rows(&rows, &source, &root, &spec) + .unwrap() + .is_empty()); + root.insert("target_slug", RowValue::String("missing".into())); + assert!(load_belongs_to_rows(&rows, &source, &root, &spec) + .unwrap() + .is_empty()); + } + #[tokio::test] async fn relational_write_plan_patches_and_deletes_rows() { let store = InMemoryReadModelStore::new(); diff --git a/src/repository/migrations.rs b/src/repository/migrations.rs new file mode 100644 index 000000000..8a73455ef --- /dev/null +++ b/src/repository/migrations.rs @@ -0,0 +1,20 @@ +//! Validated SQL migration inventory shared by native and cell executors. + +#[derive(Clone, Copy)] +pub(crate) struct EmbeddedMigration { + pub(crate) version: i64, + pub(crate) description: &'static str, + pub(crate) sql: &'static str, +} + +include!(concat!(env!("OUT_DIR"), "/migration_inventory.rs")); + +/// Cells host commands, not read-model projectors. These are the event, +/// snapshot, delivery and command-ledger migrations from the native inventory. +/// Projection-owned tables are initialized by their separate query host. +#[cfg(all(feature = "workers-rs", target_arch = "wasm32"))] +pub(crate) fn cell_migrations() -> impl Iterator { + SQLITE_MIGRATIONS + .iter() + .filter(|migration| matches!(migration.version, 1 | 2 | 4)) +} diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 7ed5d177d..29c100c17 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -1,6 +1,14 @@ mod error; mod identity; mod inbox; +#[cfg(any( + feature = "sqlite", + feature = "postgres", + all(feature = "workers-rs", target_arch = "wasm32") +))] +pub(crate) mod migrations; +pub(crate) mod sql; +pub(crate) mod sqlite_codec; mod traits; mod validation; @@ -12,6 +20,4 @@ pub use traits::{ RelationalReadModelQueryStore, Repository, SnapshotStore, SnapshotWrite, StreamWrite, TransactionalCommit, }; -#[cfg(any(feature = "postgres", feature = "sqlite"))] -pub(crate) use validation::validate_supported_event_codec; pub(crate) use validation::{validate_commit_batch, validate_snapshot_identity}; diff --git a/src/repository/sql.rs b/src/repository/sql.rs new file mode 100644 index 000000000..e6482c910 --- /dev/null +++ b/src/repository/sql.rs @@ -0,0 +1,464 @@ +//! SQL event-store operations independent of the connection runtime. +//! +//! SQLx and Durable Object SQL execute the same statements and decode the same +//! event/snapshot records. Only binding, row access, and transaction ownership +//! belong to the execution adapter. This module never opens a transaction: a +//! command's events, snapshots, ledger, and outbox must use the caller's one +//! transaction, not an independent transaction per participant. + +use std::borrow::Cow; +use std::future::Future; +use std::time::{Duration, SystemTime}; + +pub(crate) mod ledger; +pub(crate) mod outbox; + +use crate::entity::{Entity, EventRecord, BITCODE_PAYLOAD_CODEC}; +use crate::snapshot::SnapshotRecord; + +use super::{validate_snapshot_identity, PreparedEventAppend, RepositoryError, StreamIdentity}; + +#[derive(Clone, Debug)] +pub(crate) enum SqlBind<'a> { + Text(String), + Integer(i64), + Bytes(Cow<'a, [u8]>), + Metadata(String), + Timestamp(SystemTime), +} + +impl From<&str> for SqlBind<'_> { + fn from(value: &str) -> Self { + Self::Text(value.into()) + } +} +impl From for SqlBind<'_> { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} +impl<'a> From<&'a [u8]> for SqlBind<'a> { + fn from(value: &'a [u8]) -> Self { + Self::Bytes(Cow::Borrowed(value)) + } +} + +#[derive(Clone, Debug)] +pub(crate) enum SqlPart<'a> { + Sql(String), + Bind(SqlBind<'a>), + LedgerNow, + LedgerNowEpoch, + LedgerDeadline(Duration), + LedgerDeadlineIsLive(SystemTime), + LedgerJson(String), + TimestampCompare { + column: &'static str, + operator: &'static str, + value: SystemTime, + }, +} + +/// Structural statements: values are never interpolated into SQL text. The +/// executor renders placeholders for its dialect; it never splits SQL on `?`. +#[derive(Clone, Debug)] +pub(crate) struct Statement<'a>(pub Vec>); + +impl<'a> Statement<'a> { + pub fn push(&mut self, sql: impl Into) { + self.0.push(SqlPart::Sql(sql.into())); + } + + pub fn push_bind(&mut self, value: impl Into>) { + self.0.push(SqlPart::Bind(value.into())); + } + + pub fn part(&mut self, part: SqlPart<'a>) { + self.0.push(part); + } + pub fn new(sql: impl Into) -> Self { + Self(vec![SqlPart::Sql(sql.into())]) + } + + pub fn sql(mut self, sql: impl Into) -> Self { + self.0.push(SqlPart::Sql(sql.into())); + self + } + + pub fn bind(mut self, value: SqlBind<'a>) -> Self { + self.0.push(SqlPart::Bind(value)); + self + } + + fn identity(self, identity: &StreamIdentity) -> Self { + self.bind(SqlBind::Text(identity.aggregate_type().into())) + .sql(" AND aggregate_id = ") + .bind(SqlBind::Text(identity.aggregate_id().into())) + } +} + +pub(crate) trait SqlRow: Send { + fn text(&self, column: &'static str) -> Result; + fn optional_text(&self, column: &'static str) -> Result, RepositoryError>; + fn integer(&self, column: &'static str) -> Result; + fn optional_integer(&self, column: &'static str) -> Result, RepositoryError>; + fn bytes(&self, column: &'static str) -> Result, RepositoryError>; + fn timestamp(&self, column: &'static str) -> Result; + fn optional_timestamp( + &self, + column: &'static str, + ) -> Result, RepositoryError>; +} + +pub(crate) trait SqlExecutor: Send { + type Row: SqlRow; + const EVENT_SELECT: &'static str; + const SNAPSHOT_SELECT: &'static str; + const OUTBOX_SELECT: &'static str; + const NOW: &'static str; + const COMMAND_LEDGER_SELECT: &'static str; + const COMMAND_LEDGER_LOCK_SUFFIX: &'static str; + const COMMAND_LEDGER_COMPACTION_LOCK_SUFFIX: &'static str; + + fn query( + &mut self, + statement: Statement<'_>, + ) -> impl Future, RepositoryError>> + Send; + fn execute( + &mut self, + statement: Statement<'_>, + ) -> impl Future> + Send; +} + +pub(crate) struct EventInsert<'a> { + pub statement: Statement<'a>, + pub candidates: Vec<(StreamIdentity, u64)>, +} + +/// Prepare only new events, preserving the existing ten-column schema and +/// bound-parameter chunking. Conflict candidates are scoped to each statement: +/// earlier chunks in the same transaction must not skew conflict diagnostics. +pub(crate) fn event_inserts<'a>( + appends: &[PreparedEventAppend<'a>], + max_bind_params: usize, +) -> Result>, RepositoryError> { + const COLUMNS: usize = 10; + if max_bind_params < COLUMNS { + return Err(RepositoryError::Model( + "SQL executor cannot bind one event row".into(), + )); + } + let mut inserts = Vec::new(); + let mut current: Option> = None; + let mut count = 0; + for append in appends { + for event in append.events { + let insert = current.get_or_insert_with(|| EventInsert { + statement: Statement::new("INSERT INTO aggregate_events (aggregate_type, aggregate_id, sequence, event_name, event_version, payload, payload_codec, payload_codec_version, metadata, recorded_at) VALUES "), + candidates: Vec::new(), + }); + if count != 0 { + insert.statement.0.push(SqlPart::Sql(", ".into())); + } + insert.statement.0.push(SqlPart::Sql("(".into())); + let values = [ + SqlBind::Text(append.identity.aggregate_type().into()), + SqlBind::Text(append.identity.aggregate_id().into()), + SqlBind::Integer(signed(event.sequence, "sequence")?), + SqlBind::Text(event.event_name.clone()), + SqlBind::Integer(signed(event.event_version, "event version")?), + SqlBind::Bytes(Cow::Borrowed(&event.payload)), + SqlBind::Text(event.payload_codec.to_string()), + SqlBind::Integer(i64::from(event.payload_codec_version)), + SqlBind::Metadata(serde_json::to_string(&event.metadata).map_err(json_error)?), + SqlBind::Timestamp(event.timestamp), + ]; + for (index, value) in values.into_iter().enumerate() { + if index != 0 { + insert.statement.0.push(SqlPart::Sql(", ".into())); + } + insert.statement.0.push(SqlPart::Bind(value)); + } + insert.statement.0.push(SqlPart::Sql(")".into())); + if !insert + .candidates + .iter() + .any(|(id, _)| id == &append.identity) + { + insert + .candidates + .push((append.identity.clone(), append.expected_version)); + } + count += 1; + if count == max_bind_params / COLUMNS { + inserts.push(current.take().expect("event insert initialized")); + count = 0; + } + } + } + if let Some(insert) = current { + inserts.push(insert); + } + Ok(inserts) +} + +fn signed(value: u64, field: &str) -> Result { + i64::try_from(value) + .map_err(|_| RepositoryError::Model(format!("{field} exceeds SQL signed integer storage"))) +} + +fn unsigned(value: i64, field: &str) -> Result { + u64::try_from(value).map_err(|_| RepositoryError::Model(format!("stored {field} is negative"))) +} + +fn codec_version(value: i64) -> Result { + u16::try_from(value) + .map_err(|_| RepositoryError::Model("stored payload codec version is invalid".into())) +} + +fn json_error(error: serde_json::Error) -> RepositoryError { + RepositoryError::Model(format!("SQL metadata: {error}")) +} + +pub(crate) fn event_from_row(row: &impl SqlRow) -> Result { + let codec = row.text("payload_codec")?; + let event = EventRecord { + event_name: row.text("event_name")?, + event_version: unsigned(row.integer("event_version")?, "event version")?, + sequence: unsigned(row.integer("sequence")?, "event sequence")?, + payload: row.bytes("payload")?, + payload_codec: if codec == BITCODE_PAYLOAD_CODEC { + Cow::Borrowed(BITCODE_PAYLOAD_CODEC) + } else { + Cow::Owned(codec) + }, + payload_codec_version: codec_version(row.integer("payload_codec_version")?)?, + metadata: serde_json::from_str(&row.text("metadata")?).map_err(json_error)?, + timestamp: row.timestamp("recorded_at")?, + }; + super::validation::validate_supported_event_codec(&event)?; + Ok(event) +} + +pub(crate) fn snapshot_from_row(row: &impl SqlRow) -> Result { + Ok(SnapshotRecord { + aggregate_type: row.text("aggregate_type")?, + aggregate_id: row.text("aggregate_id")?, + version: unsigned(row.integer("version")?, "snapshot version")?, + snapshot_version: unsigned(row.integer("snapshot_version")?, "snapshot payload version")?, + payload: row.bytes("payload")?, + payload_codec: row.text("payload_codec")?, + payload_codec_version: codec_version(row.integer("payload_codec_version")?)?, + metadata: serde_json::from_str(&row.text("metadata")?).map_err(json_error)?, + recorded_at: row.timestamp("recorded_at")?, + }) +} + +pub(crate) async fn stream_version( + executor: &mut impl SqlExecutor, + identity: &StreamIdentity, +) -> Result { + let rows = executor + .query( + Statement::new( + "SELECT MAX(sequence) AS version FROM aggregate_events WHERE aggregate_type = ", + ) + .identity(identity), + ) + .await?; + let row = rows + .first() + .ok_or_else(|| RepositoryError::Model("missing stream version row".into()))?; + unsigned( + row.optional_integer("version")?.unwrap_or(0), + "event sequence", + ) +} + +pub(crate) async fn load_stream( + executor: &mut E, + identity: &StreamIdentity, + after_version: Option, +) -> Result, RepositoryError> { + let mut statement = Statement::new("SELECT ") + .sql(E::EVENT_SELECT) + .sql(" FROM aggregate_events WHERE aggregate_type = ") + .identity(identity); + if let Some(after) = after_version { + statement = statement + .sql(" AND sequence > ") + .bind(SqlBind::Integer(signed( + after, + "snapshot tail lower bound", + )?)); + } + let rows = executor + .query(statement.sql(" ORDER BY sequence ASC")) + .await?; + let events = rows + .iter() + .map(event_from_row) + .collect::, _>>()?; + let mut entity = Entity::with_id(identity.aggregate_id()); + match after_version { + None if events.is_empty() => return Ok(None), + None => entity.load_from_history(events), + Some(after) => { + // Keep the existing SQL snapshot-only restore contract. A future + // snapshot above a non-empty durable stream is clamped, not trusted. + let prefix = if events.is_empty() { + match stream_version(executor, identity).await? { + 0 => after, + version => after.min(version), + } + } else { + after + }; + entity.load_tail_from_history(events, prefix); + } + } + Ok(Some(entity)) +} + +pub(crate) async fn load_snapshot( + executor: &mut E, + identity: &StreamIdentity, +) -> Result, RepositoryError> { + let rows = executor + .query( + Statement::new("SELECT ") + .sql(E::SNAPSHOT_SELECT) + .sql(" FROM aggregate_snapshots WHERE aggregate_type = ") + .identity(identity), + ) + .await?; + rows.first().map(snapshot_from_row).transpose() +} + +/// The same upsert is used by standalone cache writes and command commits. +pub(crate) async fn save_snapshot( + executor: &mut E, + identity: &StreamIdentity, + record: &SnapshotRecord, +) -> Result<(), RepositoryError> { + validate_snapshot_identity(identity, record)?; + let values = [ + SqlBind::Text(identity.aggregate_type().into()), + SqlBind::Text(identity.aggregate_id().into()), + SqlBind::Integer(signed(record.version, "snapshot version")?), + SqlBind::Integer(signed(record.snapshot_version, "snapshot payload version")?), + SqlBind::Bytes(Cow::Borrowed(&record.payload)), + SqlBind::Text(record.payload_codec.clone()), + SqlBind::Integer(i64::from(record.payload_codec_version)), + SqlBind::Metadata(serde_json::to_string(&record.metadata).map_err(json_error)?), + SqlBind::Timestamp(record.recorded_at), + ]; + let mut statement = Statement::new("INSERT INTO aggregate_snapshots (aggregate_type, aggregate_id, version, snapshot_version, payload, payload_codec, payload_codec_version, metadata, recorded_at) VALUES ("); + for (index, value) in values.into_iter().enumerate() { + if index != 0 { + statement = statement.sql(", "); + } + statement = statement.bind(value); + } + executor.execute(statement.sql(") ON CONFLICT(aggregate_type, aggregate_id) DO UPDATE SET version = excluded.version, snapshot_version = excluded.snapshot_version, payload = excluded.payload, payload_codec = excluded.payload_codec, payload_codec_version = excluded.payload_codec_version, metadata = excluded.metadata, recorded_at = excluded.recorded_at, updated_at = ").sql(E::NOW)).await?; + Ok(()) +} + +pub(crate) async fn delete_snapshot( + executor: &mut impl SqlExecutor, + identity: &StreamIdentity, +) -> Result { + Ok(executor + .execute( + Statement::new("DELETE FROM aggregate_snapshots WHERE aggregate_type = ") + .identity(identity), + ) + .await? + > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_plan_borrows_only_new_payloads_and_respects_bind_limit() { + let mut entity = Entity::with_id("large-history"); + for _ in 0..1000 { + entity.digest("created", &vec![7_u8; 4096]).unwrap(); + } + entity.mark_committed(); + for _ in 0..3 { + entity.digest("changed", &vec![9_u8; 4096]).unwrap(); + } + let identity = StreamIdentity::new("Item", "large-history").unwrap(); + let append = PreparedEventAppend { + identity: identity.clone(), + expected_version: 1000, + events: entity.new_events(), + }; + let plans = event_inserts(&[append], 20).unwrap(); + assert_eq!(plans.len(), 2); + assert_eq!( + plans[0] + .statement + .0 + .iter() + .filter(|part| matches!(part, SqlPart::Bind(_))) + .count(), + 20 + ); + assert_eq!( + plans[1] + .statement + .0 + .iter() + .filter(|part| matches!(part, SqlPart::Bind(_))) + .count(), + 10 + ); + let payloads = plans + .iter() + .flat_map(|plan| &plan.statement.0) + .filter_map(|part| match part { + SqlPart::Bind(SqlBind::Bytes(Cow::Borrowed(bytes))) => Some(*bytes), + SqlPart::Bind(SqlBind::Bytes(Cow::Owned(_))) => { + panic!("event plan cloned a payload") + } + _ => None, + }) + .collect::>(); + assert_eq!( + payloads.len(), + 3, + "old history must not enter the insert plan" + ); + for (payload, event) in payloads.iter().zip(entity.new_events()) { + assert_eq!(payload.as_ptr(), event.payload.as_ptr()); + } + for plan in plans { + assert_eq!(plan.candidates, vec![(identity.clone(), 1000)]); + } + } + + #[test] + fn event_plan_rejects_unrepresentable_sequence_and_too_few_binds() { + let mut entity = Entity::with_id("one"); + entity.digest_empty("created").unwrap(); + let mut events = entity.new_events().to_vec(); + let identity = StreamIdentity::new("Item", "one").unwrap(); + let append = PreparedEventAppend { + identity: identity.clone(), + expected_version: 0, + events: &events, + }; + assert!(event_inserts(&[append], 9).is_err()); + events[0].sequence = u64::MAX; + let append = PreparedEventAppend { + identity, + expected_version: 0, + events: &events, + }; + assert!(event_inserts(&[append], 900).is_err()); + } +} diff --git a/src/repository/sql/ledger.rs b/src/repository/sql/ledger.rs new file mode 100644 index 000000000..2e1ed41bd --- /dev/null +++ b/src/repository/sql/ledger.rs @@ -0,0 +1,408 @@ +//! Shared fenced command-ledger operations. The caller owns the transaction. +//! Both SQLx and cell SQL use these statements and the same record state machine. +use super::{signed, unsigned, SqlBind, SqlExecutor, SqlPart, SqlRow, Statement}; +use crate::command_ledger::{ + AttemptFence, AttemptToken, CanonicalInputHash, CausationId, CommandCompletion, + CommandContractFingerprint, CommandId, CommandLedgerError, CommandLedgerKey, + CommandLedgerRecord, CommandLedgerState, CommandLookup, CommandLookupScope, CommandReservation, + PrincipalPartitionId, ReservationDecision, ReservationOutcome, +}; +use std::time::SystemTime; + +fn corrupt(error: CommandLedgerError) -> CommandLedgerError { + CommandLedgerError::Corrupt(error.to_string()) +} + +pub(crate) fn key_from_row(row: &impl SqlRow) -> Result { + CommandLedgerKey::new( + row.text("service_id")?, + PrincipalPartitionId::new(row.text("principal_partition")?).map_err(corrupt)?, + CommandId::parse(row.text("command_id")?).map_err(corrupt)?, + ) + .map_err(corrupt) +} + +fn record_from_row( + row: &impl SqlRow, + key: CommandLedgerKey, +) -> Result { + let record = CommandLedgerRecord { + key, + command_name: row.text("command_name")?, + contract_fingerprint: CommandContractFingerprint::try_from_slice( + &row.bytes("command_contract_hash")?, + ) + .map_err(corrupt)?, + input_hash: CanonicalInputHash::try_from_slice(&row.bytes("input_hash")?) + .map_err(corrupt)?, + state: CommandLedgerState::parse(&row.text("state")?)?, + causation_id: CausationId::parse_stored(row.text("causation_id")?)?, + attempt_token: row + .optional_text("attempt_token")? + .map(AttemptToken::parse_stored) + .transpose()?, + attempt_number: unsigned( + row.integer("attempt_number")?, + "command ledger attempt number", + )?, + lease_expires_at: row.optional_timestamp("lease_expires_at")?, + outcome_json: row.optional_text("outcome")?, + created_at: row.timestamp("created_at")?, + updated_at: row.timestamp("updated_at")?, + completed_at: row.optional_timestamp("completed_at")?, + retention_expires_at: row.timestamp("retention_expires_at")?, + compacted_at: row.optional_timestamp("compacted_at")?, + }; + record.validate_stored_shape()?; + Ok(record) +} + +pub(crate) async fn now(executor: &mut impl SqlExecutor) -> Result { + let mut statement = Statement::new("SELECT "); + statement.part(SqlPart::LedgerNowEpoch); + statement.push(" AS ledger_now"); + let rows = executor.query(statement).await?; + let row = rows + .first() + .ok_or_else(|| CommandLedgerError::Corrupt("database clock returned no row".into()))?; + Ok(row.timestamp("ledger_now")?) +} + +fn key_filter<'a>(mut statement: Statement<'a>, key: &CommandLedgerKey) -> Statement<'a> { + statement.push_bind(key.service_id()); + statement.push(" AND principal_partition = "); + statement.push_bind(key.principal_partition()); + statement.push(" AND command_id = "); + statement.push_bind(key.command_id()); + statement +} + +async fn select( + executor: &mut E, + key: &CommandLedgerKey, + expected_command_name: Option<&str>, +) -> Result, CommandLedgerError> { + let mut statement = Statement::new("SELECT "); + statement.push(E::COMMAND_LEDGER_SELECT); + statement.push(" FROM command_ledger WHERE service_id = "); + statement = key_filter(statement, key); + if let Some(name) = expected_command_name { + statement.push(" AND command_name = "); + statement.push_bind(name); + } + statement.push(E::COMMAND_LEDGER_LOCK_SUFFIX); + let rows = executor.query(statement).await?; + rows.first() + .map(|row| record_from_row(row, key.clone())) + .transpose() +} + +pub(crate) async fn preflight( + executor: &mut E, + completion: &CommandCompletion, +) -> Result<(), CommandLedgerError> { + let fence = completion.attempt_fence(); + + // SQLite needs a write statement to reserve the database writer before + // the read; PostgreSQL's subsequent SELECT also carries FOR UPDATE. This + // establishes one portable lock order before any domain participant is + // mutated. + let mut lock = + Statement::new("UPDATE command_ledger SET updated_at = updated_at WHERE service_id = "); + lock.push_bind(fence.key().service_id()); + lock.push(" AND principal_partition = "); + lock.push_bind(fence.key().principal_partition()); + lock.push(" AND command_id = "); + lock.push_bind(fence.key().command_id()); + let result = executor.execute(lock).await?; + if result != 1 { + return Err(CommandLedgerError::AttemptFenced { + command_id: fence.key().command_id().to_string(), + }); + } + + let record = select(executor, fence.key(), None).await?.ok_or_else(|| { + CommandLedgerError::AttemptFenced { + command_id: fence.key().command_id().to_string(), + } + })?; + let now = now(executor).await?; + record.validate_live_attempt(&fence, now) +} + +pub(crate) async fn insert_reservation( + executor: &mut E, + reservation: &CommandReservation, +) -> Result { + let mut builder = Statement::new( + "INSERT INTO command_ledger (service_id, principal_partition, command_id, \ + command_name, command_contract_hash, input_hash, state, causation_id, attempt_token, \ + attempt_number, lease_expires_at, outcome, created_at, updated_at, completed_at, \ + retention_expires_at, compacted_at) VALUES (", + ); + builder.push_bind(reservation.key().service_id()); + builder.push(", "); + builder.push_bind(reservation.key().principal_partition()); + builder.push(", "); + builder.push_bind(reservation.key().command_id()); + builder.push(", "); + builder.push_bind(reservation.command_name()); + builder.push(", "); + builder.push_bind(reservation.contract_fingerprint_bytes().as_slice()); + builder.push(", "); + builder.push_bind(reservation.input_hash_bytes().as_slice()); + builder.push(", "); + builder.push_bind(CommandLedgerState::InProgress.as_str()); + builder.push(", "); + builder.push_bind(reservation.candidate_causation().as_str()); + builder.push(", "); + builder.push_bind(reservation.candidate_attempt().as_str()); + builder.push(", "); + builder.push_bind(1_i64); + builder.push(", "); + builder.part(SqlPart::LedgerDeadline(reservation.lease())); + builder.push(", NULL, "); + builder.part(SqlPart::LedgerNow); + builder.push(", "); + builder.part(SqlPart::LedgerNow); + builder.push(", NULL, "); + builder.part(SqlPart::LedgerDeadline(reservation.retention())); + builder.push(", NULL"); + builder.push(") ON CONFLICT (service_id, principal_partition, command_id) DO NOTHING"); + let result = executor.execute(builder).await?; + Ok(result == 1) +} + +pub(crate) async fn expire( + executor: &mut E, + key: &CommandLedgerKey, + require_retention_due: bool, +) -> Result { + let mut builder = Statement::new( + "UPDATE command_ledger SET state = 'expired', attempt_token = NULL, \ + lease_expires_at = NULL, outcome = NULL, updated_at = ", + ); + builder.part(SqlPart::LedgerNow); + builder.push(", compacted_at = "); + builder.part(SqlPart::LedgerNow); + builder.push(" WHERE service_id = "); + builder.push_bind(key.service_id()); + builder.push(" AND principal_partition = "); + builder.push_bind(key.principal_partition()); + builder.push(" AND command_id = "); + builder.push_bind(key.command_id()); + builder.push(" AND state <> 'expired'"); + if require_retention_due { + builder.push(" AND retention_expires_at <= "); + builder.part(SqlPart::LedgerNow); + } + let result = executor.execute(builder).await?; + Ok(result) +} + +pub(crate) async fn reclaim( + executor: &mut E, + record: &mut CommandLedgerRecord, + reservation: &CommandReservation, + now: SystemTime, +) -> Result<(), CommandLedgerError> { + record.reclaim(reservation, now)?; + let attempt_number = signed(record.attempt_number, "command ledger attempt number")?; + let mut builder = + Statement::new("UPDATE command_ledger SET state = 'in_progress', attempt_token = "); + builder.push_bind(reservation.candidate_attempt().as_str()); + builder.push(", attempt_number = "); + builder.push_bind(attempt_number); + builder.push(", lease_expires_at = "); + builder.part(SqlPart::LedgerDeadline(reservation.lease())); + builder.push(", outcome = NULL, updated_at = "); + builder.part(SqlPart::LedgerNow); + builder.push(", completed_at = NULL, retention_expires_at = "); + builder.part(SqlPart::LedgerDeadline(reservation.retention())); + builder.push(", compacted_at = NULL WHERE service_id = "); + builder.push_bind(record.key.service_id()); + builder.push(" AND principal_partition = "); + builder.push_bind(record.key.principal_partition()); + builder.push(" AND command_id = "); + builder.push_bind(record.key.command_id()); + let result = executor.execute(builder).await?; + if result != 1 { + return Err(CommandLedgerError::AttemptFenced { + command_id: record.key.command_id().to_string(), + }); + } + Ok(()) +} + +pub(crate) async fn complete( + executor: &mut E, + completion: &CommandCompletion, +) -> Result<(), CommandLedgerError> { + let fence = completion.attempt_fence(); + let attempt_number = signed(fence.attempt_number(), "command ledger attempt number")?; + let terminal_state = CommandLedgerState::from(completion.state()).as_str(); + let retention_expires_at = completion.retention_expires_at(); + let mut builder = Statement::new("UPDATE command_ledger SET state = "); + builder.push_bind(terminal_state); + builder.push(", attempt_token = NULL, lease_expires_at = NULL, outcome = "); + builder.part(SqlPart::LedgerJson(completion.replay_json().into())); + builder.push(", updated_at = "); + builder.part(SqlPart::LedgerNow); + builder.push(", completed_at = "); + builder.part(SqlPart::LedgerNow); + builder.push(", retention_expires_at = "); + match retention_expires_at.as_ref() { + Some(deadline) => builder.push_bind(SqlBind::Timestamp(*deadline)), + None => builder.part(SqlPart::LedgerDeadline(completion.retention())), + } + builder.push(", compacted_at = NULL WHERE service_id = "); + builder.push_bind(fence.key().service_id()); + builder.push(" AND principal_partition = "); + builder.push_bind(fence.key().principal_partition()); + builder.push(" AND command_id = "); + builder.push_bind(fence.key().command_id()); + builder.push(" AND command_contract_hash = "); + builder.push_bind(fence.contract_fingerprint_bytes().as_slice()); + builder.push(" AND input_hash = "); + builder.push_bind(fence.input_hash_bytes().as_slice()); + builder.push(" AND state = 'in_progress' AND causation_id = "); + builder.push_bind(fence.causation_id().as_str()); + builder.push(" AND attempt_token = "); + builder.push_bind(fence.attempt_token().as_str()); + builder.push(" AND attempt_number = "); + builder.push_bind(attempt_number); + builder.push(" AND lease_expires_at > "); + builder.part(SqlPart::LedgerNow); + if let Some(deadline) = retention_expires_at.as_ref() { + builder.push(" AND "); + builder.part(SqlPart::LedgerDeadlineIsLive(*deadline)); + } + let result = executor.execute(builder).await?; + if result != 1 { + return Err(CommandLedgerError::AttemptFenced { + command_id: fence.key().command_id().to_string(), + }); + } + Ok(()) +} + +pub(crate) async fn reserve( + executor: &mut impl SqlExecutor, + reservation: &CommandReservation, +) -> Result { + if insert_reservation(executor, reservation).await? { + return Ok(ReservationOutcome::Acquired( + reservation.acquired_candidate_attempt(), + )); + } + let mut record = select(executor, reservation.key(), None) + .await? + .ok_or_else(|| { + CommandLedgerError::Corrupt("conflicting command disappeared during reservation".into()) + })?; + let now = now(executor).await?; + match record.classify_reservation(reservation, now)? { + ReservationDecision::Expire => { + expire(executor, reservation.key(), false).await?; + Ok(ReservationOutcome::Expired) + } + ReservationDecision::Reclaim => { + reclaim(executor, &mut record, reservation, now).await?; + Ok(ReservationOutcome::Acquired(record.acquired_attempt()?)) + } + other => record.reservation_outcome(other), + } +} + +pub(crate) async fn lookup( + executor: &mut impl SqlExecutor, + key: &CommandLedgerKey, + scope: CommandLookupScope<'_>, +) -> Result { + let expected_name = match scope { + CommandLookupScope::CommandName(name) + | CommandLookupScope::CommandContract { + command_name: name, .. + } => Some(name), + CommandLookupScope::Attempt(_) => None, + }; + // Reserve SQLite's writer before reading; PostgreSQL also locks the selected row. + let mut lock = key_filter( + Statement::new("UPDATE command_ledger SET updated_at = updated_at WHERE service_id = "), + key, + ); + if let Some(name) = expected_name { + lock.push(" AND command_name = "); + lock.push_bind(name); + } + executor.execute(lock).await?; + let Some(mut record) = select(executor, key, expected_name).await? else { + return Ok(CommandLookup::Unknown); + }; + if !record.matches_lookup_scope(scope) { + return Ok(CommandLookup::Unknown); + } + let now = now(executor).await?; + if record.state != CommandLedgerState::Expired && record.retention_expires_at <= now { + expire(executor, key, true).await?; + record.expire(now); + } + record.lookup() +} + +pub(crate) async fn mark_retryable( + executor: &mut impl SqlExecutor, + attempt: &AttemptFence, +) -> Result<(), CommandLedgerError> { + let mut builder = Statement::new("UPDATE command_ledger SET state = 'retryable_unknown', attempt_token = NULL, lease_expires_at = NULL, updated_at = "); + builder.part(SqlPart::LedgerNow); + builder.push(" WHERE service_id = "); + builder = key_filter(builder, attempt.key()); + builder.push(" AND command_contract_hash = "); + builder.push_bind(attempt.contract_fingerprint_bytes().as_slice()); + builder.push(" AND input_hash = "); + builder.push_bind(attempt.input_hash_bytes().as_slice()); + builder.push(" AND state = 'in_progress' AND causation_id = "); + builder.push_bind(attempt.causation_id().as_str()); + builder.push(" AND attempt_token = "); + builder.push_bind(attempt.attempt_token().as_str()); + builder.push(" AND attempt_number = "); + builder.push_bind(signed( + attempt.attempt_number(), + "command ledger attempt number", + )?); + if executor.execute(builder).await? != 1 { + return Err(CommandLedgerError::AttemptFenced { + command_id: attempt.key().command_id().to_string(), + }); + } + Ok(()) +} + +pub(crate) async fn compact( + executor: &mut E, + limit: usize, +) -> Result { + if limit == 0 { + return Ok(0); + } + let limit = i64::try_from(limit) + .map_err(|_| CommandLedgerError::Invalid("command compaction limit exceeds i64".into()))?; + executor + .execute(Statement::new( + "UPDATE command_ledger SET updated_at = updated_at WHERE 1 = 0", + )) + .await?; + let mut select = Statement::new("SELECT service_id, principal_partition, command_id FROM command_ledger WHERE state <> 'expired' AND retention_expires_at <= "); + select.part(SqlPart::LedgerNow); + select + .push(" ORDER BY retention_expires_at, service_id, principal_partition, command_id LIMIT "); + select.push_bind(limit); + select.push(E::COMMAND_LEDGER_COMPACTION_LOCK_SUFFIX); + let rows = executor.query(select).await?; + let mut count = 0; + for row in rows { + count += expire(executor, &key_from_row(&row)?, true).await?; + } + Ok(count) +} diff --git a/src/repository/sql/outbox.rs b/src/repository/sql/outbox.rs new file mode 100644 index 000000000..438a04d84 --- /dev/null +++ b/src/repository/sql/outbox.rs @@ -0,0 +1,446 @@ +//! Delivery rows shared by native SQL and cell-local SQL. Transactions belong +//! to the caller, so these inserts join the command's event and receipt writes. + +use super::{json_error, signed, unsigned, SqlBind, SqlExecutor, SqlPart, SqlRow, Statement}; +use crate::outbox::{OutboxMessage, OutboxMessageStatus}; +use crate::outbox_worker::{ensure_active_claim, ClaimOutboxMessages, OutboxClaimRef}; +use crate::repository::{sqlite_codec, RepositoryError}; +use std::borrow::Cow; +use std::time::SystemTime; + +pub(crate) struct OutboxInsert<'a> { + pub statement: Statement<'a>, + pub first_id: &'a str, +} + +fn optional(value: Option>) -> SqlPart<'_> { + value + .map(SqlPart::Bind) + .unwrap_or_else(|| SqlPart::Sql("NULL".into())) +} + +/// Borrow payloads and emit at most nineteen bindings per row. Optional NULLs +/// are SQL literals, never stringified values or untyped driver bindings. +pub(crate) fn inserts( + messages: &[OutboxMessage], + max_bind_params: usize, +) -> Result>, RepositoryError> { + const COLUMNS: usize = 19; + if max_bind_params < COLUMNS { + return Err(RepositoryError::Model( + "SQL executor cannot bind one outbox row".into(), + )); + } + let mut result = Vec::new(); + for chunk in messages.chunks(max_bind_params / COLUMNS) { + let mut statement = Statement::new("INSERT INTO outbox_messages (message_id, event_type, payload, payload_codec, payload_codec_version, destination, metadata, status, created_at, next_available_at, claimed_by, claimed_until, attempts, last_error, source_aggregate_type, source_aggregate_id, source_sequence, correlation_id, causation_id) VALUES "); + for (index, message) in chunk.iter().enumerate() { + if index != 0 { + statement.push(", "); + } + statement.push("("); + let values = [ + Some(SqlBind::from(message.id())), + Some(SqlBind::from(message.event_type.as_str())), + Some(SqlBind::Bytes(Cow::Borrowed(&message.payload))), + Some(SqlBind::from(message.payload_codec.as_str())), + Some(SqlBind::Integer(i64::from(message.payload_codec_version))), + message.destination.as_deref().map(SqlBind::from), + Some(SqlBind::Metadata( + serde_json::to_string(&message.metadata).map_err(json_error)?, + )), + Some(SqlBind::from(message.status.as_str())), + Some(SqlBind::Timestamp(message.created_at)), + Some(SqlBind::Timestamp(message.created_at)), + message.worker_id.as_deref().map(SqlBind::from), + message.leased_until.map(SqlBind::Timestamp), + Some(SqlBind::Integer(i64::from(message.attempts))), + message.last_error.as_deref().map(SqlBind::from), + message.source_aggregate_type.as_deref().map(SqlBind::from), + message.source_aggregate_id.as_deref().map(SqlBind::from), + message + .source_sequence + .map(|value| signed(value, "outbox source sequence").map(SqlBind::Integer)) + .transpose()?, + message.correlation_id().map(SqlBind::from), + message.causation_id().map(SqlBind::from), + ]; + for (column, value) in values.into_iter().enumerate() { + if column != 0 { + statement.push(", "); + } + statement.part(optional(value)); + } + statement.push(")"); + } + result.push(OutboxInsert { + statement, + first_id: chunk[0].id(), + }); + } + Ok(result) +} + +pub(crate) fn from_row(row: impl SqlRow) -> Result { + let status_text = row.text("status")?; + let status = status_text + .parse::() + .map_err(|_| RepositoryError::Model(format!("outbox status `{status_text}` is invalid")))?; + let mut metadata: std::collections::HashMap = + serde_json::from_str(&row.text("metadata")?).map_err(json_error)?; + for column in ["correlation_id", "causation_id"] { + if let Some(value) = row.optional_text(column)? { + metadata.insert(column.into(), value); + } + } + Ok(OutboxMessage { + id: row.text("message_id")?, + event_type: row.text("event_type")?, + payload: row.bytes("payload")?, + payload_codec: row.text("payload_codec")?, + payload_codec_version: u16::try_from(row.integer("payload_codec_version")?) + .map_err(|_| RepositoryError::Model("invalid outbox payload codec version".into()))?, + status, + metadata, + created_at: row.timestamp("created_at")?, + worker_id: row.optional_text("claimed_by")?, + leased_until: row.optional_timestamp("claimed_until")?, + attempts: u32::try_from(row.integer("attempts")?) + .map_err(|_| RepositoryError::Model("invalid outbox attempts".into()))?, + last_error: row.optional_text("last_error")?, + destination: row.optional_text("destination")?, + source_aggregate_type: row.optional_text("source_aggregate_type")?, + source_aggregate_id: row.optional_text("source_aggregate_id")?, + source_sequence: row + .optional_integer("source_sequence")? + .map(|value| unsigned(value, "outbox source sequence")) + .transpose()?, + }) +} + +fn sqlite_claimable(statement: &mut Statement<'_>, now: SystemTime) { + statement.push("((status = 'pending' AND CAST(next_available_at AS REAL) <= CAST("); + statement.push_bind(SqlBind::Timestamp(now)); + statement.push(" AS REAL)) OR (status = 'in_flight' AND (claimed_until IS NULL OR CAST(claimed_until AS REAL) <= CAST("); + statement.push_bind(SqlBind::Timestamp(now)); + statement.push(" AS REAL))))"); +} + +fn destination(statement: &mut Statement<'_>, request: &ClaimOutboxMessages) { + if let Some(destination) = request.destination.as_deref() { + statement.push(" AND destination = "); + statement.push_bind(destination); + } +} + +/// SQLite has no row-lock/SKIP LOCKED claim. Both SQLx SQLite and cells use +/// this candidate scan plus conditional update in a caller-owned transaction. +pub(crate) async fn claim_sqlite( + executor: &mut impl SqlExecutor, + request: ClaimOutboxMessages, + now: SystemTime, +) -> Result, RepositoryError> { + if request.batch_size == 0 { + return Ok(Vec::new()); + } + let deadline = now + .checked_add(request.lease) + .ok_or_else(|| RepositoryError::Model("failed to compute outbox lease deadline".into()))?; + let ids = if let Some(ids) = request.message_ids.clone() { + ids + } else { + let mut query = Statement::new("SELECT message_id FROM outbox_messages WHERE "); + sqlite_claimable(&mut query, now); + destination(&mut query, &request); + query.push(" ORDER BY CAST(created_at AS REAL) ASC, message_id ASC LIMIT "); + query.push_bind(signed(request.batch_size as u64, "outbox claim limit")?); + executor + .query(query) + .await? + .into_iter() + .map(|row| row.text("message_id")) + .collect::, _>>()? + }; + let mut claimed = Vec::new(); + for id in ids { + if claimed.len() >= request.batch_size { + break; + } + let mut update = + Statement::new("UPDATE outbox_messages SET status = 'in_flight', claimed_by = ") + .bind(request.worker_id.as_str().into()) + .sql(", claimed_until = ") + .bind(SqlBind::Timestamp(deadline)) + .sql( + ", attempts = attempts + 1, updated_at = CURRENT_TIMESTAMP WHERE message_id = ", + ) + .bind(id.as_str().into()) + .sql(" AND "); + sqlite_claimable(&mut update, now); + destination(&mut update, &request); + if executor.execute(update).await? == 0 { + continue; + } + let rows = executor + .query( + Statement::new("SELECT ") + .sql(sqlite_codec::OUTBOX_SELECT) + .sql(" FROM outbox_messages WHERE message_id = ") + .bind(id.as_str().into()), + ) + .await?; + let row = rows + .into_iter() + .next() + .ok_or_else(|| RepositoryError::NotFound { id })?; + claimed.push(from_row(row)?); + } + Ok(claimed) +} + +pub(crate) enum Transition<'a> { + Complete, + Release(&'a str), + Fail(&'a str), +} + +/// Acknowledgement removes delivery work. Event history and command-replay +/// evidence are separate records and are never reconstructed from this table. +pub(crate) async fn transition( + executor: &mut E, + claim: &OutboxClaimRef, + transition: Transition<'_>, + now: SystemTime, +) -> Result<(), RepositoryError> { + let mut statement = match transition { + Transition::Complete => Statement::new("DELETE FROM outbox_messages"), + Transition::Release(error) | Transition::Fail(error) => { + let released = matches!(transition, Transition::Release(_)); + let mut statement = Statement::new("UPDATE outbox_messages SET status = ") + .bind(if released { "pending" } else { "failed" }.into()) + .sql(", claimed_by = NULL, claimed_until = NULL, last_error = "); + statement.part(optional((!error.is_empty()).then(|| error.into()))); + statement.push(if released { + ", next_available_at = " + } else { + ", failed_at = " + }); + statement.push_bind(SqlBind::Timestamp(now)); + statement.push(", updated_at = "); + statement.push(E::NOW); + statement + } + }; + statement.push(" WHERE message_id = "); + statement.push_bind(claim.message_id.as_str()); + statement.push(" AND status = 'in_flight' AND claimed_by = "); + statement.push_bind(claim.worker_id.as_str()); + statement.push(" AND attempts = "); + statement.push_bind(i64::from(claim.attempt)); + statement.push(" AND claimed_until IS NOT NULL AND "); + statement.part(SqlPart::TimestampCompare { + column: "claimed_until", + operator: ">", + value: now, + }); + if executor.execute(statement).await? > 0 { + return Ok(()); + } + let row = executor + .query( + Statement::new("SELECT ") + .sql(E::OUTBOX_SELECT) + .sql(" FROM outbox_messages WHERE message_id = ") + .bind(claim.message_id.as_str().into()), + ) + .await? + .into_iter() + .next() + .ok_or_else(|| RepositoryError::NotFound { + id: claim.message_id.clone(), + })?; + let message = from_row(row)?; + ensure_active_claim(&message, Some(claim), now)?; + Err(RepositoryError::InvalidState { + id: claim.message_id.clone(), + expected: "settled outbox claim", + actual: "conditional settlement changed no row".into(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_plans_bound_rows_and_borrow_payloads() { + let messages = (0..3) + .map(|n| { + OutboxMessage::create(format!("message-{n}"), "Changed", vec![7; 4096]).unwrap() + }) + .collect::>(); + let plans = inserts(&messages, 38).unwrap(); + assert_eq!(plans.len(), 2); + let payloads = plans + .iter() + .flat_map(|plan| &plan.statement.0) + .filter_map(|part| match part { + SqlPart::Bind(SqlBind::Bytes(Cow::Borrowed(bytes))) => Some(*bytes), + SqlPart::Bind(SqlBind::Bytes(Cow::Owned(_))) => panic!("payload was copied"), + _ => None, + }) + .collect::>(); + assert_eq!(payloads.len(), messages.len()); + for (payload, message) in payloads.iter().zip(&messages) { + assert_eq!(payload.as_ptr(), message.payload.as_ptr()); + } + for plan in &plans { + assert!( + plan.statement + .0 + .iter() + .filter(|part| matches!(part, SqlPart::Bind(_))) + .count() + <= 38 + ); + } + assert!(inserts(&messages, 18).is_err()); + let mut overflow = messages; + overflow[0].source_sequence = Some(u64::MAX); + assert!(inserts(&overflow, 38).is_err()); + } + + #[cfg(feature = "sqlite")] + #[tokio::test] + async fn stale_ack_cannot_delete_reclaimed_work_and_delivery_keeps_event_history() { + use crate::sqlx_repo::repo::ConnectionExecutor; + use crate::{ + CommitBatch, Entity, GetStream, SqliteRepository, StreamIdentity, StreamWrite, + TransactionalCommit, + }; + use std::time::{Duration, UNIX_EPOCH}; + + let repo = SqliteRepository::connect_and_migrate("sqlite::memory:") + .await + .unwrap(); + let identity = StreamIdentity::new("Item", "one").unwrap(); + let mut entity = Entity::with_id("one"); + entity.digest_empty("created").unwrap(); + let mut message = OutboxMessage::create("event-1", "Created", b"payload".to_vec()).unwrap(); + let now = UNIX_EPOCH + Duration::from_secs(100); + message.created_at = now; + let mut batch = CommitBatch::new(vec![StreamWrite::new(identity.clone(), &mut entity)]); + batch.outbox_messages.push(message); + repo.commit_batch(batch).await.unwrap(); + + let mut tx = repo.pool().begin().await.unwrap(); + let first = claim_sqlite( + &mut ConnectionExecutor::(&mut tx), + ClaimOutboxMessages::new("same-worker", 1, Duration::from_secs(1)), + now, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + let first = OutboxClaimRef::from_message(&first[0]).unwrap(); + let expired = now + Duration::from_secs(1); + let mut tx = repo.pool().begin().await.unwrap(); + let second = claim_sqlite( + &mut ConnectionExecutor::(&mut tx), + ClaimOutboxMessages::new("same-worker", 1, Duration::from_secs(60)), + expired, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + assert_eq!(second[0].id(), first.message_id); + assert_eq!(second[0].attempts, first.attempt + 1); + assert_eq!(second[0].payload, b"payload"); + let second = OutboxClaimRef::from_message(&second[0]).unwrap(); + + let mut connection = repo.pool().acquire().await.unwrap(); + let mut executor = ConnectionExecutor::(&mut connection); + for action in [ + Transition::Complete, + Transition::Release("late"), + Transition::Fail("late"), + ] { + assert!(matches!( + transition(&mut executor, &first, action, expired).await, + Err(RepositoryError::InvalidState { .. }) + )); + } + assert_eq!( + executor + .query(Statement::new("SELECT message_id FROM outbox_messages")) + .await + .unwrap() + .len(), + 1 + ); + transition(&mut executor, &second, Transition::Complete, expired) + .await + .unwrap(); + assert!(executor + .query(Statement::new("SELECT message_id FROM outbox_messages")) + .await + .unwrap() + .is_empty()); + assert!(matches!( + transition(&mut executor, &second, Transition::Complete, expired).await, + Err(RepositoryError::NotFound { .. }) + )); + drop(connection); + let restored = repo.get_stream(&identity).await.unwrap().unwrap(); + assert_eq!(restored.version(), 1); + assert_eq!(restored.committed_version(), 1); + } + + #[cfg(feature = "sqlite")] + #[tokio::test] + async fn delivery_settlement_rolls_back_with_its_transaction() { + use crate::sqlx_repo::repo::ConnectionExecutor; + use crate::{CommitBatch, OutboxStore, SqliteRepository, TransactionalCommit}; + use std::time::Duration; + let repo = SqliteRepository::connect_and_migrate("sqlite::memory:") + .await + .unwrap(); + let mut batch = CommitBatch::empty(); + batch + .outbox_messages + .push(OutboxMessage::create("event-1", "Changed", vec![]).unwrap()); + repo.commit_batch(batch).await.unwrap(); + let store = repo.outbox_store(); + let claimed = store + .claim(ClaimOutboxMessages::new( + "worker", + 1, + Duration::from_secs(60), + )) + .await + .unwrap(); + let claim = OutboxClaimRef::from_message(&claimed[0]).unwrap(); + let mut tx = repo.pool().begin().await.unwrap(); + transition( + &mut ConnectionExecutor::(&mut tx), + &claim, + Transition::Complete, + SystemTime::now(), + ) + .await + .unwrap(); + tx.rollback().await.unwrap(); + let remaining = store + .messages_by_status(OutboxMessageStatus::InFlight, 1) + .await + .unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(OutboxClaimRef::from_message(&remaining[0]).unwrap(), claim); + store.complete(&claim).await.unwrap(); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox_messages") + .fetch_one(repo.pool()) + .await + .unwrap(); + assert_eq!(count, 0); + } +} diff --git a/src/repository/sqlite_codec.rs b/src/repository/sqlite_codec.rs new file mode 100644 index 000000000..34dd1257d --- /dev/null +++ b/src/repository/sqlite_codec.rs @@ -0,0 +1,77 @@ +//! SQLite timestamp representation shared by SQLx and Durable Object SQL. + +use super::RepositoryError; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub(crate) const EVENT_SELECT: &str = "event_name, event_version, payload, payload_codec, payload_codec_version, metadata, sequence, recorded_at"; +pub(crate) const SNAPSHOT_SELECT: &str = "aggregate_type, aggregate_id, version, snapshot_version, payload, payload_codec, payload_codec_version, metadata, recorded_at"; +pub(crate) const OUTBOX_SELECT: &str = "message_id, event_type, payload, payload_codec, payload_codec_version, metadata, status, created_at, claimed_by, claimed_until, attempts, last_error, destination, source_aggregate_type, source_aggregate_id, source_sequence, correlation_id, causation_id"; + +pub(crate) fn encode(timestamp: SystemTime) -> Result { + let duration = timestamp.duration_since(UNIX_EPOCH).map_err(|error| { + RepositoryError::Model(format!( + "event timestamp before UNIX epoch cannot be stored in sqlite: {error}" + )) + })?; + Ok(format!( + "{}.{:09}", + duration.as_secs(), + duration.subsec_nanos() + )) +} + +pub(crate) fn decode(value: &str) -> Result { + let invalid = + || RepositoryError::Model(format!("sqlite stored timestamp `{value}` is invalid")); + let (secs, nanos) = value.split_once('.').ok_or_else(invalid)?; + let secs = secs.parse::().map_err(|_| invalid())?; + let nanos = nanos.parse::().map_err(|_| invalid())?; + if nanos >= 1_000_000_000 { + return Err(invalid()); + } + UNIX_EPOCH + .checked_add(Duration::new(secs, nanos)) + .ok_or_else(invalid) +} + +pub(crate) fn decode_epoch(value: f64) -> Result { + let invalid = + || RepositoryError::Model(format!("sqlite timestamp epoch value {value} is invalid")); + let duration = Duration::try_from_secs_f64(value).map_err(|_| invalid())?; + UNIX_EPOCH.checked_add(duration).ok_or_else(invalid) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timestamp_round_trip_preserves_nanoseconds() { + let value = UNIX_EPOCH + Duration::new(1_700_000_000, 123_456_789); + assert_eq!(encode(value).unwrap(), "1700000000.123456789"); + assert_eq!(decode(&encode(value).unwrap()).unwrap(), value); + assert_eq!( + decode_epoch(42.25).unwrap(), + UNIX_EPOCH + Duration::new(42, 250_000_000) + ); + } + + #[test] + fn invalid_timestamps_return_errors_without_panicking() { + for value in [ + "", + "not-a-time", + "-1.000000000", + "1.1000000000", + "1.2.3", + "18446744073709551615.000000000", + ] { + assert!(decode(value).is_err(), "accepted {value}"); + } + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, f64::MAX] { + assert!(decode_epoch(value).is_err(), "accepted {value}"); + } + assert!(encode(UNIX_EPOCH - Duration::from_secs(1)).is_err()); + } +} +pub(crate) const COMMAND_LEDGER_SELECT: &str = "command_name, command_contract_hash, input_hash, state, causation_id, attempt_token, attempt_number, lease_expires_at, outcome, created_at, updated_at, completed_at, retention_expires_at, compacted_at"; diff --git a/src/snapshot/repository.rs b/src/snapshot/repository.rs index f26f82547..f8d5bd565 100644 --- a/src/snapshot/repository.rs +++ b/src/snapshot/repository.rs @@ -1,7 +1,7 @@ use std::future::Future; use std::pin::Pin; -use crate::aggregate::{hydrate, AggregateRepository, SnapshotPolicy}; +use crate::aggregate::{hydrate, AggregateRepository, SnapshotPolicy, StreamReads}; use crate::entity::{upcast_events_for_replay, Entity, EventRecord}; use crate::repository::{GetStream, RepositoryError, SnapshotStore, StreamIdentity}; @@ -319,6 +319,7 @@ where fn load_from_store<'a, R, A>( repo: &'a R, identity: &'a StreamIdentity, + reads: StreamReads, ) -> Pin, RepositoryError>> + Send + 'a>> where R: SnapshotStore + GetStream + Sync, @@ -327,8 +328,7 @@ where Box::pin(async move { let Some(snapshot) = repo.get_snapshot(identity).await? else { // No snapshot: a plain full load (same as a snapshotless repo). - return repo - .get_stream(identity) + return (reads.full)(repo, identity) .await? .map(hydrate::) .transpose(); @@ -336,7 +336,7 @@ where // Fetch only events after the snapshot. The returned entity records the // true stream version even though it holds only the tail. - let Some(entity) = repo.get_stream_tail(identity, snapshot.version).await? else { + let Some(entity) = (reads.tail)(repo, identity, snapshot.version).await? else { // The stream is gone but a snapshot lingers; nothing to hydrate. return Ok(None); }; @@ -354,8 +354,7 @@ where // rebuild the aggregate, so fall back to a full stream load. This is // the I/O we hoped to avoid, but it only happens when the snapshot is // unusable — the correct, safe outcome. - Err(SnapshotHydrationError::Cache(_)) => Ok(repo - .get_stream(identity) + Err(SnapshotHydrationError::Cache(_)) => Ok((reads.full)(repo, identity) .await? .map(hydrate::) .transpose()?), @@ -404,6 +403,113 @@ mod tests { saw_snapshot: std::sync::atomic::AtomicBool, } + struct CausalReadProbe { + inner: crate::InMemoryRepository, + reads: std::sync::Mutex>>, + } + impl GetStream for CausalReadProbe { + async fn get_stream(&self, _: &StreamIdentity) -> Result, RepositoryError> { + panic!("causal snapshot loading must not use ordinary locking reads") + } + } + impl crate::command_ledger::CausalGetStream for CausalReadProbe { + async fn get_causal_stream( + &self, + identity: &StreamIdentity, + ) -> Result, RepositoryError> { + self.reads.lock().unwrap().push(None); + self.inner.get_stream(identity).await + } + async fn get_causal_stream_tail( + &self, + identity: &StreamIdentity, + after: u64, + ) -> Result, RepositoryError> { + self.reads.lock().unwrap().push(Some(after)); + self.inner.get_stream_tail(identity, after).await + } + } + impl SnapshotStore for CausalReadProbe { + async fn get_snapshot( + &self, + identity: &StreamIdentity, + ) -> Result, RepositoryError> { + self.inner.get_snapshot(identity).await + } + async fn save_snapshot( + &self, + identity: &StreamIdentity, + record: SnapshotRecord, + ) -> Result<(), RepositoryError> { + self.inner.save_snapshot(identity, record).await + } + async fn delete_snapshot( + &self, + identity: &StreamIdentity, + ) -> Result { + self.inner.delete_snapshot(identity).await + } + } + + #[tokio::test] + async fn causal_snapshots_use_only_tail_through_queued_wrappers() { + let inner = crate::InMemoryRepository::new(); + let writer = AggregateRepository::new(inner.clone()).with_snapshots(2); + let mut aggregate = TestAggregate::default(); + for _ in 0..3 { + aggregate.touch().unwrap(); + writer.commit(&mut aggregate).await.unwrap(); + } + let identity = StreamIdentity::new(TestAggregate::aggregate_type(), "snap-1").unwrap(); + let reader = AggregateRepository::<_, TestAggregate>::new(crate::QueuedRepository::new( + CausalReadProbe { + inner, + reads: Default::default(), + }, + )) + .with_snapshots(2); + // Repeated loads prove no queue lock leaks across a handler await. + for _ in 0..2 { + let loaded = tokio::time::timeout( + std::time::Duration::from_secs(1), + reader.get_causal(&identity), + ) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(loaded.value, 3); + assert_eq!(loaded.entity.version(), 3); + assert_eq!(loaded.entity.snapshot_version(), 2); + assert_eq!(loaded.entity.events().len(), 1); + } + assert_eq!( + *reader.repo().inner().reads.lock().unwrap(), + [Some(2), Some(2)] + ); + + // Invalid cache bytes retain the ordinary safe full-replay behavior, + // while still bypassing locking reads on that recovery path. + let mut snapshot = reader + .repo() + .get_snapshot(&identity) + .await + .unwrap() + .unwrap(); + snapshot.payload = vec![255; 9]; + reader + .repo() + .save_snapshot(&identity, snapshot) + .await + .unwrap(); + let loaded = reader.get_causal(&identity).await.unwrap().unwrap(); + assert_eq!(loaded.value, 3); + assert_eq!( + *reader.repo().inner().reads.lock().unwrap(), + [Some(2), Some(2), Some(2), None] + ); + } + impl TransactionalCommit for FailingSnapshotRepo { async fn commit_batch<'a>(&'a self, batch: CommitBatch<'a>) -> Result<(), RepositoryError> { { diff --git a/src/sqlite_repo/mod.rs b/src/sqlite_repo/mod.rs index 0f4621d3c..47f442246 100644 --- a/src/sqlite_repo/mod.rs +++ b/src/sqlite_repo/mod.rs @@ -7,26 +7,28 @@ //! candidate-scan outbox claim (SQLite has no row locks). It is feature-gated //! behind `sqlite` and async-only. +use crate::repository::sqlite_codec::{ + decode as system_time_from_storage, decode_epoch as system_time_from_epoch_secs, + encode as system_time_to_storage, +}; use std::sync::LazyLock; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime}; use sqlx::migrate::Migrator; use sqlx::query_builder::Separated; use sqlx::sqlite::SqliteRow; use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; -use crate::outbox::{OutboxMessage, OutboxMessageStatus}; +use crate::outbox::OutboxMessage; use crate::outbox_worker::ClaimOutboxMessages; use crate::repository::RepositoryError; use crate::sqlx_repo::read_model::quote_identifier; use crate::sqlx_repo::repo::{ - embedded_migrator, outbox_message_by_id, system_time_epoch_secs, SqlxOutboxStore, - SqlxRepository, SQLITE_MIGRATIONS, + embedded_migrator, SqlxOutboxStore, SqlxRepository, SQLITE_MIGRATIONS, }; use crate::sqlx_repo::{ self, is_sqlite_unique_constraint, read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, read_model_u64_from_i64 as sqlx_read_model_u64_from_i64, - repository_i64_from_u64 as sqlx_repository_i64_from_u64, }; use crate::table::TableSqlDialect; use crate::table::{ @@ -54,19 +56,13 @@ impl crate::sqlx_repo::repo::SqlxRepoBackend for Sqlite { // recovery re-reads stream versions in the same transaction. const CONFLICT_REREAD_IN_TX: bool = true; const NOW: &'static str = "CURRENT_TIMESTAMP"; - const COMMAND_LEDGER_SELECT: &'static str = "command_name, command_contract_hash, \ - input_hash, state, causation_id, attempt_token, attempt_number, lease_expires_at, \ - outcome, created_at, updated_at, completed_at, retention_expires_at, compacted_at"; + const COMMAND_LEDGER_SELECT: &'static str = + crate::repository::sqlite_codec::COMMAND_LEDGER_SELECT; const COMMAND_LEDGER_LOCK_SUFFIX: &'static str = ""; const COMMAND_LEDGER_COMPACTION_LOCK_SUFFIX: &'static str = ""; - const EVENT_SELECT: &'static str = "event_name, event_version, payload, payload_codec, \ - payload_codec_version, metadata, sequence, recorded_at"; - const SNAPSHOT_SELECT: &'static str = "aggregate_type, aggregate_id, version, \ - snapshot_version, payload, payload_codec, payload_codec_version, metadata, recorded_at"; - const OUTBOX_SELECT: &'static str = "message_id, event_type, payload, payload_codec, \ - payload_codec_version, metadata, status, created_at, claimed_by, claimed_until, \ - attempts, last_error, destination, source_aggregate_type, source_aggregate_id, \ - source_sequence, correlation_id, causation_id"; + const EVENT_SELECT: &'static str = crate::repository::sqlite_codec::EVENT_SELECT; + const SNAPSHOT_SELECT: &'static str = crate::repository::sqlite_codec::SNAPSHOT_SELECT; + const OUTBOX_SELECT: &'static str = crate::repository::sqlite_codec::OUTBOX_SELECT; const ORDER_BY_CREATED_AT: &'static str = "CAST(created_at AS REAL)"; const OUTBOX_OLDEST_CREATED_AT_SELECT: &'static str = "MIN(CAST(created_at AS REAL)) AS oldest_created_at"; @@ -211,121 +207,23 @@ impl crate::sqlx_repo::repo::SqlxRepoBackend for Sqlite { pool: &SqlitePool, request: ClaimOutboxMessages, ) -> Result, RepositoryError> { - { - if request.batch_size == 0 { - return Ok(Vec::new()); - } - - let now = SystemTime::now(); - let now_epoch = system_time_epoch_secs::(now)?; - let claimed_until = now.checked_add(request.lease).ok_or_else(|| { - RepositoryError::Model("failed to compute outbox lease deadline".into()) - })?; - let claimed_until_storage = system_time_to_storage(claimed_until)?; - - let mut tx = pool - .begin() - .await - .map_err(|err| repository_storage_error("begin outbox claim transaction", err))?; - - // Explicit ids (after-commit immediate dispatch) bypass the ordered - // candidate scan; the per-id conditional UPDATE below still enforces - // claimability and destination, so raced/unclaimable ids are skipped. - let candidate_ids: Vec = if let Some(ids) = request.message_ids.clone() { - ids - } else { - let limit = sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - request.batch_size as u64, - "outbox claim limit", - SIGNED_INTEGER_STORAGE, - )?; - let candidate_rows = sqlx::query( - r#" - SELECT message_id - FROM outbox_messages - WHERE ( - (status = ? AND CAST(next_available_at AS REAL) <= ?) - OR (status = ? AND (claimed_until IS NULL OR CAST(claimed_until AS REAL) <= ?)) - ) - AND (? IS NULL OR destination = ?) - ORDER BY CAST(created_at AS REAL) ASC, message_id ASC - LIMIT ? - "#, - ) - .bind(OutboxMessageStatus::Pending.as_str()) - .bind(now_epoch) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(now_epoch) - .bind(request.destination.as_deref()) - .bind(request.destination.as_deref()) - .bind(limit) - .fetch_all(&mut *tx) - .await - .map_err(|err| { - repository_storage_error("select claimable outbox messages", err) - })?; - let mut ids = Vec::with_capacity(candidate_rows.len()); - for row in candidate_rows { - ids.push(row.try_get::("message_id").map_err(|err| { - repository_storage_error("decode outbox message id row", err) - })?); - } - ids - }; - - let mut claimed = Vec::new(); - for message_id in candidate_ids { - if claimed.len() >= request.batch_size { - break; - } - let result = sqlx::query( - r#" - UPDATE outbox_messages - SET status = ?, - claimed_by = ?, - claimed_until = ?, - attempts = attempts + 1, - updated_at = CURRENT_TIMESTAMP - WHERE message_id = ? - AND ( - (status = ? AND CAST(next_available_at AS REAL) <= ?) - OR ( - status = ? - AND (claimed_until IS NULL OR CAST(claimed_until AS REAL) <= ?) - ) - ) - AND (? IS NULL OR destination = ?) - "#, - ) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(&request.worker_id) - .bind(&claimed_until_storage) - .bind(&message_id) - .bind(OutboxMessageStatus::Pending.as_str()) - .bind(now_epoch) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(now_epoch) - .bind(request.destination.as_deref()) - .bind(request.destination.as_deref()) - .execute(&mut *tx) - .await - .map_err(|err| repository_storage_error("claim outbox message", err))?; - - if result.rows_affected() == 0 { - continue; - } - - if let Some(message) = outbox_message_by_id(&mut *tx, &message_id).await? { - claimed.push(message); - } - } - - tx.commit() - .await - .map_err(|err| repository_storage_error("commit outbox claim transaction", err))?; - Ok(claimed) + if request.batch_size == 0 { + return Ok(Vec::new()); } + let mut tx = pool + .begin() + .await + .map_err(|error| repository_storage_error("begin outbox claim transaction", error))?; + let claimed = crate::repository::sql::outbox::claim_sqlite( + &mut crate::sqlx_repo::repo::ConnectionExecutor::(&mut tx), + request, + SystemTime::now(), + ) + .await?; + tx.commit() + .await + .map_err(|error| repository_storage_error("commit outbox claim transaction", error))?; + Ok(claimed) } } @@ -465,40 +363,6 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { } } -fn system_time_to_storage(timestamp: SystemTime) -> Result { - let duration = timestamp.duration_since(UNIX_EPOCH).map_err(|err| { - RepositoryError::Model(format!( - "event timestamp before UNIX epoch cannot be stored in sqlite: {err}" - )) - })?; - Ok(format!( - "{}.{:09}", - duration.as_secs(), - duration.subsec_nanos() - )) -} - -fn system_time_from_storage(value: &str) -> Result { - let invalid = - || RepositoryError::Model(format!("sqlite stored timestamp `{value}` is invalid")); - let (secs, nanos) = value.split_once('.').ok_or_else(invalid)?; - let secs = secs.parse::().map_err(|_| invalid())?; - let nanos = nanos.parse::().map_err(|_| invalid())?; - if nanos >= 1_000_000_000 { - return Err(invalid()); - } - Ok(UNIX_EPOCH + Duration::new(secs, nanos)) -} - -fn system_time_from_epoch_secs(value: f64) -> Result { - if !value.is_finite() || value < 0.0 { - return Err(RepositoryError::Model(format!( - "sqlite timestamp epoch value {value} is invalid" - ))); - } - Ok(UNIX_EPOCH + Duration::from_secs_f64(value)) -} - fn repository_storage_error(operation: &str, err: sqlx::Error) -> RepositoryError { sqlx_repo::repository_storage_error(SQLITE_BACKEND, operation, err) } diff --git a/src/sqlx_repo/mod.rs b/src/sqlx_repo/mod.rs index b6af4bcc1..56da2af9e 100644 --- a/src/sqlx_repo/mod.rs +++ b/src/sqlx_repo/mod.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use crate::repository::RepositoryError; #[cfg(any(feature = "postgres", feature = "sqlite"))] use crate::table::TableStoreError; @@ -11,20 +9,7 @@ pub(crate) mod read_model; #[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) mod repo; -pub(crate) fn serialize_event_metadata( - metadata: &HashMap, -) -> Result { - serde_json::to_string(metadata) - .map_err(|err| RepositoryError::Model(format!("serialize event metadata: {err}"))) -} - -pub(crate) fn deserialize_event_metadata( - metadata_json: &str, -) -> Result, RepositoryError> { - serde_json::from_str(metadata_json) - .map_err(|err| RepositoryError::Model(format!("deserialize event metadata: {err}"))) -} - +#[cfg(feature = "postgres")] pub(crate) fn repository_i64_from_u64( backend: &str, value: u64, @@ -45,16 +30,6 @@ pub(crate) fn repository_u64_from_i64( .map_err(|_| RepositoryError::Model(format!("{backend} {field} value {value} is negative"))) } -#[cfg(any(feature = "postgres", feature = "sqlite"))] -pub(crate) fn repository_u16_from_i64( - backend: &str, - value: i64, - field: &str, -) -> Result { - u16::try_from(value) - .map_err(|_| RepositoryError::Model(format!("{backend} {field} value {value} is invalid"))) -} - #[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) fn read_model_i64_from_u64( backend: &str, diff --git a/src/sqlx_repo/projection_protocol/mod.rs b/src/sqlx_repo/projection_protocol/mod.rs index 4fec8f891..d27b7c6bf 100644 --- a/src/sqlx_repo/projection_protocol/mod.rs +++ b/src/sqlx_repo/projection_protocol/mod.rs @@ -20,7 +20,7 @@ use sqlx::{Encode, Executor, IntoArguments, Pool, QueryBuilder, Row, Transaction use crate::projection_protocol::{ change_kind_for_mutation, checked_next, checked_projection_graph_materialization, - projection_has_many_columns, table_model_name, validate_projection_graph_snapshot_request, + projection_relationship_values, table_model_name, validate_projection_graph_snapshot_request, ProjectionCausationEvidenceBatch, ProjectionCausationEvidenceRequest, ProjectionChange, ProjectionChangeCursor, ProjectionChangeKind, ProjectionChangeRead, ProjectionChangeRetention, ProjectionCheckpoint, ProjectionCommitBatch, ProjectionCommitOutcome, ProjectionCommitResult, @@ -48,8 +48,8 @@ use crate::sqlx_repo::read_model::{ }; use crate::sqlx_repo::repo::{repository_storage_error, SqlxRepoBackend, SqlxRepository}; use crate::table::{ - column_name_for, validate_row_values, RelationshipKind, RowKey, RowValue, RowValues, - TableMutation, TableSchema, TableStoreError, TableWritePlan, + validate_row_values, RowKey, RowValues, TableMutation, TableSchema, TableStoreError, + TableWritePlan, }; mod helpers; @@ -57,6 +57,8 @@ mod identity; mod locks; mod partitions; mod reads; +#[cfg(feature = "graphql")] +mod rebuild; mod store_impl; mod types; mod writes; diff --git a/src/sqlx_repo/projection_protocol/postgres_tests.rs b/src/sqlx_repo/projection_protocol/postgres_tests.rs index 9f2ff8353..07ae6f960 100644 --- a/src/sqlx_repo/projection_protocol/postgres_tests.rs +++ b/src/sqlx_repo/projection_protocol/postgres_tests.rs @@ -87,6 +87,7 @@ mod postgres_tests { indexes: Vec::new(), relationships: vec![ RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "PostgresGraphChildView".into(), @@ -95,6 +96,7 @@ mod postgres_tests { target_foreign_key: None, }, RelationshipDef { + references: None, field_name: "featured_children".into(), kind: RelationshipKind::HasMany, target_model: "PostgresGraphChildView".into(), diff --git a/src/sqlx_repo/projection_protocol/reads.rs b/src/sqlx_repo/projection_protocol/reads.rs index f71807e94..1dfac6dd0 100644 --- a/src/sqlx_repo/projection_protocol/reads.rs +++ b/src/sqlx_repo/projection_protocol/reads.rs @@ -1,5 +1,125 @@ use super::*; +#[cfg(all(test, feature = "sqlite"))] +#[tokio::test] +async fn candidate_key_snapshot_lookup_selects_surrogate_keys_in_sql() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + let mut connection = pool.acquire().await.unwrap(); + candidate_key_snapshot_sql_fixture::(&mut connection).await; +} + +#[cfg(all(test, feature = "postgres"))] +#[tokio::test] +#[ignore = "requires dedicated DISTRIBUTED_UNIQUE_KEY_TEST_POSTGRES_URL; explicitly run in CI"] +async fn candidate_key_snapshot_lookup_postgres() { + let url = std::env::var("DISTRIBUTED_UNIQUE_KEY_TEST_POSTGRES_URL") + .expect("dedicated test database URL"); + let options: sqlx::postgres::PgConnectOptions = url.parse().unwrap(); + assert!(options + .get_database() + .unwrap_or("") + .starts_with("distributed_unique_key_test")); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + let mut connection = pool.acquire().await.unwrap(); + candidate_key_snapshot_sql_fixture::(&mut connection).await; +} + +#[cfg(test)] +async fn candidate_key_snapshot_sql_fixture(connection: &mut DB::Connection) +where + DB: SqlxRepoBackend, + DB::Arguments: IntoArguments, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'r> &'r str: sqlx::ColumnIndex, +{ + use crate::table::{ + ColumnType, PrimaryKey, RelationshipDef, RelationshipKind, RowValue, TableColumn, + TableIndex, TableKind, + }; + for statement in [ + "CREATE TEMP TABLE snapshot_targets (id TEXT PRIMARY KEY, tenant TEXT NOT NULL, candidate TEXT, UNIQUE(tenant,candidate))", + "INSERT INTO snapshot_targets VALUES ('opaque-a','a','same'),('opaque-b','b','same'),('null-target','a',NULL)", + ] { sqlx::query::(statement).execute(&mut *connection).await.unwrap(); } + let target = TableSchema { + model_name: "SnapshotTarget".into(), + table_name: "snapshot_targets".into(), + columns: vec![ + TableColumn { + primary_key: true, + ..TableColumn::new("id", "id", ColumnType::Text) + }, + TableColumn::new("tenant", "tenant", ColumnType::Text), + TableColumn { + nullable: true, + ..TableColumn::new("candidate", "candidate", ColumnType::Text) + }, + ], + primary_key: PrimaryKey::new(["id"]), + version_column: None, + foreign_keys: vec![], + indexes: vec![TableIndex { + name: None, + columns: vec!["tenant".into(), "candidate".into()], + unique: true, + }], + relationships: vec![], + kind: TableKind::ReadModel, + }; + let mut source = target.clone(); + source.model_name = "SnapshotSource".into(); + source.table_name = "snapshot_sources".into(); + let relation = RelationshipDef { + field_name: "target".into(), + kind: RelationshipKind::BelongsTo, + target_model: target.model_name.clone(), + foreign_key: Some("tenant,candidate".into()), + references: Some("tenant,candidate".into()), + through: None, + target_foreign_key: None, + }; + for (tenant, candidate, expected) in [ + ("a", Some("same"), Some("opaque-a")), + ("b", Some("same"), Some("opaque-b")), + ("missing", Some("same"), None), + ("a", None, None), + ] { + let mut values = RowValues::new(); + values.insert("id", RowValue::String("source-id".into())); + values.insert("tenant", RowValue::String(tenant.into())); + values.insert( + "candidate", + candidate + .map(|v| RowValue::String(v.into())) + .unwrap_or(RowValue::Null), + ); + let keys = read_projection_relationship_keys_in_executor::( + &mut *connection, + &source, + &values, + &relation, + &target, + 2, + ) + .await + .unwrap(); + assert_eq!( + keys, + expected + .into_iter() + .map(|id| RowKey::new([("id", RowValue::String(id.into()))])) + .collect::>() + ); + } +} + pub(super) fn decode_change_row( row: &DB::Row, topology: &ProjectorTopologyId, @@ -200,6 +320,10 @@ where } let current = record_in_tx(tx, &staged.scope, &state.change_epoch).await?; + crate::projection_protocol::validate_snapshot_write( + current.as_ref().map(|record| &record.metadata), + None, + )?; let physical_exists = physical_row_exists_in_tx(tx, &staged.mutation).await?; match current.as_ref().map(|record| &record.metadata) { None if physical_exists => { @@ -228,6 +352,7 @@ where &expectation, ProjectionMutationKind::Upsert, current.as_ref(), + false, )?; debug_assert!(!tombstone); let change = allocate_change( @@ -242,6 +367,7 @@ where None, )?; let metadata = ProjectionRecordMetadata { + source_snapshot: None, revision: revision.clone(), tombstone, change: change.cursor.clone(), @@ -358,6 +484,7 @@ where record.tombstone AS ps_record_tombstone, \ record.change_epoch AS ps_record_change_epoch, \ record.change_position AS ps_record_change_position, \ + record.source_snapshot AS ps_source_snapshot, \ checkpoint.source_bytes AS ps_cursor_source_bytes, \ checkpoint.source_hash AS ps_cursor_source_hash, \ checkpoint.source_partition_bytes AS ps_cursor_partition_bytes, \ @@ -671,6 +798,11 @@ where )?, )?, tombstone, + source_snapshot: decode_source_snapshot( + first.try_get("ps_source_snapshot").map_err(|error| { + protocol_storage_error::("decode source snapshot", error) + })?, + )?, change: ProjectionChangeCursor::new( request.scope.topology().clone(), request.scope.projection_partition().clone(), @@ -1074,53 +1206,12 @@ where for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, for<'r> &'r str: sqlx::ColumnIndex, { - let foreign_key = relationship.foreign_key.as_deref().ok_or_else(|| { - ProjectionProtocolError::InvalidBatch(format!( - "projection graph relationship `{}` has no foreign key", - relationship.field_name - )) - })?; - let (target_column, value) = match relationship.kind { - RelationshipKind::HasMany => { - let (target_column, root_column) = - projection_has_many_columns(root_schema, relationship, target_schema)?; - let value = root_row.get(&root_column).cloned().ok_or_else(|| { - ProjectionProtocolError::InvalidBatch(format!( - "projection graph root `{}` is missing relationship key `{root_column}`", - root_schema.model_name - )) - })?; - (target_column, value) - } - RelationshipKind::BelongsTo => { - let source_column = column_name_for(root_schema, foreign_key).ok_or_else(|| { - ProjectionProtocolError::InvalidBatch(format!( - "projection graph relationship `{}` foreign key `{foreign_key}` is not a source column", - relationship.field_name - )) - })?; - let [target_column] = target_schema.primary_key.columns.as_slice() else { - return Err(ProjectionProtocolError::InvalidBatch(format!( - "projection graph belongs-to target `{}` must have one primary-key column", - target_schema.model_name - ))); - }; - let value = root_row.get(&source_column).cloned().ok_or_else(|| { - ProjectionProtocolError::InvalidBatch(format!( - "projection graph root `{}` is missing relationship key `{source_column}`", - root_schema.model_name - )) - })?; - (target_column.clone(), value) - } - RelationshipKind::ManyToMany => { - return Err(ProjectionProtocolError::InvalidBatch(format!( - "projection graph relationship `{}` is many-to-many; project an explicit join read model instead", - relationship.field_name - ))); - } - }; - if value == RowValue::Null { + let values = + projection_relationship_values(root_schema, root_row, relationship, target_schema)?; + if values + .iter() + .any(|(_, value)| *value == crate::table::RowValue::Null) + { return Ok(Vec::new()); } @@ -1134,13 +1225,18 @@ where builder.push(" FROM "); builder.push(quote_identifier(&target_schema.table_name)); builder.push(" WHERE "); - builder.push(quote_identifier(&target_column)); - builder.push(" = "); - DB::push_row_value_bind( - &mut builder, - value, - column_by_name(target_schema, &target_column)?, - )?; + for (index, (target_column, value)) in values.into_iter().enumerate() { + if index > 0 { + builder.push(" AND "); + } + builder.push(quote_identifier(&target_column)); + builder.push(" = "); + DB::push_row_value_bind( + &mut builder, + value, + column_by_name(target_schema, &target_column)?, + )?; + } push_order_by_primary_key(&mut builder, target_schema); builder.push(" LIMIT "); builder.push(max_unique.saturating_add(1).to_string()); @@ -1845,7 +1941,7 @@ where "SELECT record.topology_hash AS live_topology_hash, record.partition_hash AS \ live_partition_hash, record.model_name AS live_model_name, \ record.canonical_key_bytes, record.canonical_key_hash, record.incarnation, \ - record.revision, record.tombstone, record.change_epoch, record.change_position, \ + record.revision, record.tombstone, record.change_epoch, record.change_position, record.source_snapshot, \ partition.topology_bytes AS live_topology_bytes, \ partition.partition_bytes AS live_partition_bytes, \ partition.change_epoch AS live_partition_epoch, \ @@ -1998,16 +2094,20 @@ where "projection live-record change exceeds its partition head", )); } - let metadata = ProjectionRecordMetadata { - revision: RecordRevision::new(scope.clone(), incarnation, revision)?, - tombstone: false, - change: ProjectionChangeCursor::new( - scope.topology().clone(), - scope.projection_partition().clone(), - change_epoch, - change_position, - )?, - }; + let metadata = + ProjectionRecordMetadata { + source_snapshot: decode_source_snapshot(row.try_get("source_snapshot").map_err( + |error| protocol_storage_error::("decode source snapshot", error), + )?)?, + revision: RecordRevision::new(scope.clone(), incarnation, revision)?, + tombstone: false, + change: ProjectionChangeCursor::new( + scope.topology().clone(), + scope.projection_partition().clone(), + change_epoch, + change_position, + )?, + }; if records[*index].replace(metadata).is_some() { return Err(corrupt_storage(format!( "projection live-record identity for model `{}` is ambiguous across partitions", diff --git a/src/sqlx_repo/projection_protocol/rebuild.rs b/src/sqlx_repo/projection_protocol/rebuild.rs new file mode 100644 index 000000000..1f48a7ade --- /dev/null +++ b/src/sqlx_repo/projection_protocol/rebuild.rs @@ -0,0 +1,181 @@ +use super::*; +use crate::projection::rebuild::{ + invalid, RebuildContext, SnapshotProjectionRebuildPlan, MAX_REBUILD_RECORDS, +}; + +impl SqlxRepository +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + // Caller holds the normal writer partition lock throughout inventory/commit. + async fn rebuild_inventory( + tx: &mut Transaction<'_, DB>, + context: &RebuildContext, + ) -> Result, ProjectionProtocolError> { + let topology = context.compiled.topology(); + let hash = topology.digest(); + let partition = context.partition()?; + let mut query = QueryBuilder::::new( + "SELECT model_name, canonical_key_bytes, partition_hash FROM projection_records WHERE topology_hash = " + ); + query.push_bind(hash.as_slice()); + query.push(" LIMIT "); + query.push_bind((MAX_REBUILD_RECORDS + 1) as i64); + let keys = query + .build() + .fetch_all(&mut **tx) + .await + .map_err(|e| protocol_storage_error::("read rebuild inventory", e))?; + if keys.len() > MAX_REBUILD_RECORDS { + return Err(invalid("snapshot rebuild exceeds 10000 records")); + } + let mut records = Vec::with_capacity(keys.len()); + for key in keys { + let model: String = key + .try_get("model_name") + .map_err(|e| protocol_storage_error::("decode rebuild model", e))?; + let bytes: Vec = key + .try_get("canonical_key_bytes") + .map_err(|e| protocol_storage_error::("decode rebuild key", e))?; + let stored_partition: Vec = key + .try_get("partition_hash") + .map_err(|e| protocol_storage_error::("decode rebuild partition", e))?; + verify_digest( + &stored_partition, + partition.digest(), + "snapshot rebuild partition", + )?; + let scope = + ProjectionRecordScope::new(topology.clone(), partition.clone(), model, bytes)?; + records.push( + record_in_tx(tx, &scope, &context.epoch) + .await? + .ok_or_else(|| invalid("snapshot rebuild record disappeared"))? + .metadata, + ); + } + Ok(records) + } + + pub(super) async fn snapshot_rebuild_records( + &self, + context: &RebuildContext, + ) -> Result, ProjectionProtocolError> { + let mut tx = self + .pool() + .begin() + .await + .map_err(|e| protocol_storage_error::("begin rebuild inventory", e))?; + verify_registered_topology_in_tx(&mut tx, context.compiled.topology()).await?; + lock_partition_in_tx( + &mut tx, + context.compiled.topology(), + &context.partition()?, + &context.epoch, + ) + .await?; + let records = Self::rebuild_inventory(&mut tx, context).await?; + // No bootstrap, metadata changes, or maintenance locks survive capture. + tx.rollback() + .await + .map_err(|e| protocol_storage_error::("close rebuild inventory", e))?; + Ok(records) + } + + pub(super) async fn apply_snapshot_rebuild( + &self, + plan: SnapshotProjectionRebuildPlan, + ) -> Result { + let context = &plan.context; + let topology = context.compiled.topology(); + let partition = context.partition()?; + let write_plan = plan.write_plan(); + validate_sql_write_plan(&write_plan)?; + let mut tx = self + .pool() + .begin() + .await + .map_err(|e| protocol_storage_error::("begin snapshot rebuild", e))?; + verify_registered_topology_in_tx(&mut tx, topology).await?; + let mut state = lock_partition_in_tx(&mut tx, topology, &partition, &context.epoch).await?; + plan.verify_inventory(&Self::rebuild_inventory(&mut tx, context).await?)?; + ensure_partition_ownership_in_tx( + &mut tx, + topology, + &partition, + context.compiled.ownership(), + ) + .await?; + for row in &plan.rows { + let current = record_in_tx(&mut tx, &row.scope, &context.epoch).await?; + let metadata = current.as_ref().map(|r| &r.metadata); + row.verify_physical( + metadata, + physical_row_exists_in_tx(&mut tx, &row.mutation).await?, + )?; + let (kind, expectation) = row.transition(metadata); + let (revision, tombstone) = + next_record(&row.scope, &expectation, kind, current.as_ref(), true)?; + let change = allocate_change( + &mut state, + topology, + &partition, + change_kind_for_mutation(kind), + "distributed:snapshot-rebuild".into(), + None, + Some(row.scope.clone()), + Some(revision.clone()), + None, + )?; + let record = ProjectionRecordMetadata { + revision, + tombstone, + change: change.cursor.clone(), + source_snapshot: Some(row.source.clone()), + }; + insert_change_in_tx(&mut tx, &change).await?; + upsert_record_in_tx(&mut tx, &record).await?; + } + apply_read_model_write_plan_in_tx(&mut tx, write_plan).await?; + update_partition_head_in_tx( + &mut tx, + topology, + &partition, + state.change_head, + state.pending_retry_failure_id.as_deref(), + ) + .await?; + retain_projection_change_suffix_in_tx( + &mut tx, + topology, + &partition, + &state, + self.projection_change_retention(), + ) + .await?; + let mut tables = context + .compiled + .ownership() + .iter() + .map(|o| o.table.clone()) + .collect::>(); + tables.insert(PROJECTION_CHANGE_NOTIFY_TABLE.to_string()); + if self.projection_notify_enabled() { + DB::push_change_notify(&mut *tx, &tables).await?; + } + tx.commit() + .await + .map_err(|e| protocol_storage_error::("commit snapshot rebuild", e))?; + self.publish_read_model_change(crate::ReadModelChange { tables }); + Ok(plan.rows.len()) + } +} diff --git a/src/sqlx_repo/projection_protocol/store_impl.rs b/src/sqlx_repo/projection_protocol/store_impl.rs index 5d5f28236..5e60a00ff 100644 --- a/src/sqlx_repo/projection_protocol/store_impl.rs +++ b/src/sqlx_repo/projection_protocol/store_impl.rs @@ -13,6 +13,22 @@ where for<'q> &'q [u8]: Encode<'q, DB> + Type, for<'r> &'r str: sqlx::ColumnIndex, { + #[cfg(feature = "graphql")] + async fn projection_rebuild_records( + &self, + context: &crate::projection::rebuild::RebuildContext, + ) -> Result, ProjectionProtocolError> { + self.snapshot_rebuild_records(context).await + } + + #[cfg(feature = "graphql")] + async fn commit_projection_rebuild( + &self, + plan: crate::projection::rebuild::SnapshotProjectionRebuildPlan, + ) -> Result { + self.apply_snapshot_rebuild(plan).await + } + fn register_projection_models<'a>( &'a self, topology: &'a ProjectorTopologyId, @@ -410,6 +426,11 @@ where &mutation.expectation, mutation.kind, current.as_ref(), + mutation.source_snapshot.is_some(), + )?; + crate::projection_protocol::validate_snapshot_write( + current.as_ref().map(|record| &record.metadata), + mutation.source_snapshot.as_ref(), )?; let change = allocate_change( &mut state, @@ -426,6 +447,7 @@ where revision, tombstone, change: change.cursor.clone(), + source_snapshot: mutation.source_snapshot.clone(), }; records_by_scope.insert(mutation.scope.clone(), metadata.clone()); records.push(metadata); @@ -469,7 +491,7 @@ where actual_revision: metadata.revision.revision(), }); } - if metadata.tombstone { + if metadata.tombstone && metadata.source_snapshot.is_none() { return Err(ProjectionProtocolError::RecordTombstoned { model: expected.scope().model().to_string(), }); diff --git a/src/sqlx_repo/projection_protocol/tests.rs b/src/sqlx_repo/projection_protocol/tests.rs index c45833ebf..53b908b49 100644 --- a/src/sqlx_repo/projection_protocol/tests.rs +++ b/src/sqlx_repo/projection_protocol/tests.rs @@ -162,6 +162,7 @@ mod tests { indexes: Vec::new(), relationships: vec![ RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "SqlGraphChildView".into(), @@ -170,6 +171,7 @@ mod tests { target_foreign_key: None, }, RelationshipDef { + references: None, field_name: "featured_children".into(), kind: RelationshipKind::HasMany, target_model: "SqlGraphChildView".into(), @@ -2813,6 +2815,14 @@ mod tests { #[tokio::test] async fn sqlite_direct_projection_and_ledger_replay_commit_atomically() { let repository = repository().await; + #[cfg(all(feature = "graphql", feature = "gateway-delivery"))] + let (cache_store, cache_pool, cache_tables, cache_before) = { + let pool = crate::graphql::GraphqlPool::Sqlite(repository.pool().clone()); + let tables = vec!["sql_todo_views".to_owned()]; + let store = crate::graphql::delivery::GatewayVersionStore::install(&pool, "atomic-cache-test", tables.clone()).await.unwrap(); + let before = serde_json::to_value(store.current(&pool, &tables).await.unwrap()).unwrap(); + (store, pool, tables, before) + }; let command_id = uuid::Uuid::now_v7().hyphenated().to_string(); let key = CommandLedgerKey::new( "projection-runtime-test", @@ -2862,6 +2872,12 @@ mod tests { .await .unwrap(); + #[cfg(all(feature = "graphql", feature = "gateway-delivery"))] + let cache_committed = { + let committed = serde_json::to_value(cache_store.current(&cache_pool, &cache_tables).await.unwrap()).unwrap(); + assert_ne!(committed, cache_before, "Atomic result and cache dependency versions commit together"); + committed + }; let metadata = repository .projection_record(&record_scope()) .await @@ -2956,6 +2972,9 @@ mod tests { .unwrap(), CommandLookup::InProgress { .. } )); + #[cfg(all(feature = "graphql", feature = "gateway-delivery"))] + assert_eq!(serde_json::to_value(cache_store.current(&cache_pool, &cache_tables).await.unwrap()).unwrap(), cache_committed, + "failed Atomic ledger completion must roll back cache versions too"); sqlx::query("DROP TRIGGER fail_direct_ledger_completion") .execute(repository.pool()) .await diff --git a/src/sqlx_repo/projection_protocol/writes.rs b/src/sqlx_repo/projection_protocol/writes.rs index 6c93f2809..1711222c4 100644 --- a/src/sqlx_repo/projection_protocol/writes.rs +++ b/src/sqlx_repo/projection_protocol/writes.rs @@ -196,7 +196,7 @@ where let key_hash = scope.key_digest(); let mut builder = QueryBuilder::::new( "SELECT canonical_key_bytes, canonical_key_hash, incarnation, revision, tombstone, \ - change_epoch, change_position FROM projection_records WHERE topology_hash = ", + change_epoch, change_position, source_snapshot FROM projection_records WHERE topology_hash = ", ); builder.push_bind(topology_hash.as_slice()); builder.push(" AND partition_hash = "); @@ -268,6 +268,11 @@ where )?; Ok(Some(StoredRecord { metadata: ProjectionRecordMetadata { + source_snapshot: decode_source_snapshot( + row.try_get("source_snapshot").map_err(|error| { + protocol_storage_error::("decode source snapshot", error) + })?, + )?, revision: RecordRevision::new(scope.clone(), incarnation, revision)?, tombstone, change: ProjectionChangeCursor::new( @@ -285,12 +290,16 @@ pub(super) fn next_record( expectation: &ProjectionRecordExpectation, kind: ProjectionMutationKind, current: Option<&StoredRecord>, + source_snapshot: bool, ) -> Result<(RecordRevision, bool), ProjectionProtocolError> { let current = current.map(|record| &record.metadata); match (expectation, current, kind) { (ProjectionRecordExpectation::Missing, None, ProjectionMutationKind::Upsert) => { Ok((RecordRevision::new(scope.clone(), 1, 1)?, false)) } + (ProjectionRecordExpectation::Missing, None, ProjectionMutationKind::Delete) => { + Ok((RecordRevision::new(scope.clone(), 1, 1)?, true)) + } (ProjectionRecordExpectation::Missing, Some(metadata), _) if metadata.tombstone => { Err(ProjectionProtocolError::RecordTombstoned { model: scope.model().to_string(), @@ -330,7 +339,7 @@ pub(super) fn next_record( )?, false, )), - ProjectionMutationKind::Delete if metadata.tombstone => { + ProjectionMutationKind::Delete if metadata.tombstone && !source_snapshot => { Err(ProjectionProtocolError::RecordTombstoned { model: scope.model().to_string(), }) @@ -358,11 +367,9 @@ pub(super) fn next_record( )), } } - (_, _, ProjectionMutationKind::Delete | ProjectionMutationKind::Recreate) => { - Err(ProjectionProtocolError::InvalidBatch( - "delete/recreate requires an exact record expectation".into(), - )) - } + (_, _, ProjectionMutationKind::Recreate) => Err(ProjectionProtocolError::InvalidBatch( + "delete/recreate requires an exact record expectation".into(), + )), } } @@ -508,7 +515,7 @@ where let mut builder = QueryBuilder::::new( "INSERT INTO projection_records \ (topology_hash, partition_hash, model_name, canonical_key_bytes, canonical_key_hash, \ - incarnation, revision, tombstone, change_epoch, change_position) VALUES (", + incarnation, revision, tombstone, change_epoch, change_position, source_snapshot) VALUES (", ); builder.push_bind(topology_hash.as_slice()); builder.push(", "); @@ -538,12 +545,24 @@ where metadata.change.position(), "projection record change position", )?); + builder.push(", "); + let source_json = metadata + .source_snapshot + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|error| corrupt_storage(format!("encode source snapshot: {error}")))?; + if let Some(value) = &source_json { + builder.push_bind(value.as_str()); + } else { + builder.push("NULL"); + } builder.push( ") ON CONFLICT (topology_hash, partition_hash, model_name, canonical_key_hash) \ DO UPDATE SET canonical_key_bytes = excluded.canonical_key_bytes, \ incarnation = excluded.incarnation, revision = excluded.revision, \ tombstone = excluded.tombstone, change_epoch = excluded.change_epoch, \ - change_position = excluded.change_position", + change_position = excluded.change_position, source_snapshot = excluded.source_snapshot", ); builder .build() @@ -553,6 +572,27 @@ where Ok(()) } +pub(super) fn decode_source_snapshot( + value: Option, +) -> Result, ProjectionProtocolError> { + value + .map(|value| { + if value.len() > 16384 { + return Err(corrupt_storage( + "source snapshot exceeds bounded metadata size", + )); + } + let snapshot: crate::projection_protocol::SourceSnapshotVersion = + serde_json::from_str(&value) + .map_err(|error| corrupt_storage(format!("decode source snapshot: {error}")))?; + snapshot + .validate_stored() + .map_err(|error| corrupt_storage(error.to_string()))?; + Ok(snapshot) + }) + .transpose() +} + pub(super) fn decode_observation_row( row: &DB::Row, causation_id: &str, diff --git a/src/sqlx_repo/read_model/load.rs b/src/sqlx_repo/read_model/load.rs index 5fb9cf067..128c014b9 100644 --- a/src/sqlx_repo/read_model/load.rs +++ b/src/sqlx_repo/read_model/load.rs @@ -4,9 +4,9 @@ use std::sync::RwLock; use sqlx::{Database, Encode, Executor, IntoArguments, Type}; use super::{ - belongs_to_target_column, column_by_name, push_key_predicates, push_order_by_primary_key, - quote_identifier, relational_row_select, resolve_registered_read_model_schemas, - row_to_versioned_values, IncludeSpec, SqlxReadModelBackend, + column_by_name, push_key_predicates, push_order_by_primary_key, quote_identifier, + relational_row_select, resolve_registered_read_model_schemas, row_to_versioned_values, + IncludeSpec, SqlxReadModelBackend, }; use crate::read_model::{ ReadModelIncludeRows, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, @@ -146,20 +146,11 @@ where for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex<::Row>, { - let foreign_key = spec.relationship.foreign_key.as_deref().ok_or_else(|| { - TableStoreError::Metadata(format!( - "relationship `{}` must declare a foreign key", - spec.relationship.field_name - )) - })?; - let source_column = - crate::table::column_name_for(root_schema, foreign_key).ok_or_else(|| { - TableStoreError::Metadata(format!( - "relationship `{}` foreign key `{}` is not a source column", - spec.relationship.field_name, foreign_key - )) - })?; - let target_column = belongs_to_target_column(&spec.target_schema, &source_column)?; + let (source_column, target_column) = crate::table::belongs_to_join_columns( + root_schema, + &spec.relationship, + &spec.target_schema, + )?; let source_value = root_row.get(&source_column).ok_or_else(|| { TableStoreError::Metadata(format!( "read model `{}` root row is missing relationship key `{}`", diff --git a/src/sqlx_repo/read_model/mod.rs b/src/sqlx_repo/read_model/mod.rs index 7c8b97795..08b611035 100644 --- a/src/sqlx_repo/read_model/mod.rs +++ b/src/sqlx_repo/read_model/mod.rs @@ -28,11 +28,10 @@ pub(crate) use schema_registry::{ remember_read_model_schemas, resolve_registered_read_model_schemas, IncludeSpec, }; pub(crate) use validation::{ - belongs_to_target_column, column_by_name, empty_string_as_none, initial_row_version, - patch_values_preserving_key, quote_identifier, row_concurrency_conflict, - row_values_from_key_and_patch, row_write_values, sql_read_model_capabilities, - validate_row_expected_version, validate_sql_write_plan, validate_values_match_key, - version_column, + column_by_name, initial_row_version, patch_values_preserving_key, quote_identifier, + row_concurrency_conflict, row_values_from_key_and_patch, row_write_values, + sql_read_model_capabilities, validate_row_expected_version, validate_sql_write_plan, + validate_values_match_key, version_column, }; pub(crate) use write_plan::{ apply_read_model_write_plan_in_tx, begin_read_model_tx, commit_read_model_tx, row_version_in_tx, diff --git a/src/sqlx_repo/read_model/validation.rs b/src/sqlx_repo/read_model/validation.rs index d4c6c95d0..69e889a5a 100644 --- a/src/sqlx_repo/read_model/validation.rs +++ b/src/sqlx_repo/read_model/validation.rs @@ -148,28 +148,6 @@ pub(crate) fn validate_values_match_key( Ok(()) } -pub(crate) fn belongs_to_target_column( - target_schema: &TableSchema, - source_column: &str, -) -> Result { - if target_schema.primary_key.columns.len() != 1 { - return Err(TableStoreError::Metadata(format!( - "belongs_to target `{}` must have a single-column primary key to load from `{}`", - target_schema.model_name, source_column - ))); - } - - Ok(target_schema.primary_key.columns[0].clone()) -} - -pub(crate) fn empty_string_as_none(value: &str) -> Option<&str> { - if value.is_empty() { - None - } else { - Some(value) - } -} - pub(crate) fn row_write_values<'schema>( schema: &'schema TableSchema, values: &RowValues, diff --git a/src/sqlx_repo/repo/backend.rs b/src/sqlx_repo/repo/backend.rs index 53535cf8d..79f9f1136 100644 --- a/src/sqlx_repo/repo/backend.rs +++ b/src/sqlx_repo/repo/backend.rs @@ -1,14 +1,10 @@ use super::*; -/// One migration registration emitted by the root build script. -#[derive(Clone, Copy)] -pub(crate) struct EmbeddedMigration { - pub(crate) version: i64, - pub(crate) description: &'static str, - pub(crate) sql: &'static str, -} - -include!(concat!(env!("OUT_DIR"), "/migration_inventory.rs")); +pub(crate) use crate::repository::migrations::EmbeddedMigration; +#[cfg(feature = "postgres")] +pub(crate) use crate::repository::migrations::POSTGRES_MIGRATIONS; +#[cfg(feature = "sqlite")] +pub(crate) use crate::repository::migrations::SQLITE_MIGRATIONS; /// Build an embedded migrator from the validated, generated migration inventory. pub(crate) fn embedded_migrator(files: &[EmbeddedMigration]) -> Migrator { @@ -51,14 +47,16 @@ mod tests { .iter() .map(|migration| migration.sql) .collect::>(); - assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!(versions, vec![1, 2, 3, 4, 5, 6]); assert_eq!( descriptions, vec![ "initial", "command ledger", "projection protocol", - "command ledger atomic state" + "command ledger atomic state", + "projection source snapshots", + "gateway dependency versions" ] ); assert_eq!( @@ -80,6 +78,14 @@ mod tests { env!("CARGO_MANIFEST_DIR"), "/migrations/sqlite/0004_command_ledger_atomic_state.sql" )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0005_projection_source_snapshots.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0006_gateway_dependency_versions.sql" + )), ] ); } @@ -99,14 +105,16 @@ mod tests { .iter() .map(|migration| migration.sql) .collect::>(); - assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!(versions, vec![1, 2, 3, 4, 5, 6]); assert_eq!( descriptions, vec![ "initial", "command ledger", "projection protocol", - "command ledger atomic state" + "command ledger atomic state", + "projection source snapshots", + "gateway dependency versions" ] ); assert_eq!( @@ -128,6 +136,14 @@ mod tests { env!("CARGO_MANIFEST_DIR"), "/migrations/postgres/0004_command_ledger_atomic_state.sql" )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0005_projection_source_snapshots.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0006_gateway_dependency_versions.sql" + )), ] ); } @@ -148,12 +164,6 @@ pub(super) fn ids_by_type(identities: &[StreamIdentity]) -> BTreeMap<&str, Vec<& groups } -/// Bound parameters per `aggregate_events` row. -pub(super) const EVENT_BIND_COLUMNS: usize = 10; - -/// Bound parameters per `outbox_messages` row. -pub(super) const OUTBOX_BIND_COLUMNS: usize = 19; - /// Dialect surface for the shared repository path (event store, snapshots, /// outbox lifecycle, consumer inbox, schema bootstrap). /// diff --git a/src/sqlx_repo/repo/commit.rs b/src/sqlx_repo/repo/commit.rs index d7841aec7..c42cc7736 100644 --- a/src/sqlx_repo/repo/commit.rs +++ b/src/sqlx_repo/repo/commit.rs @@ -9,42 +9,17 @@ where for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, DB::Arguments: IntoArguments, for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, - for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, for<'r> &'r str: sqlx::ColumnIndex, { - let fence = completion.attempt_fence(); - - // SQLite needs a write statement to reserve the database writer before - // the read; PostgreSQL's subsequent SELECT also carries FOR UPDATE. This - // establishes one portable lock order before any domain participant is - // mutated. - let mut lock = QueryBuilder::::new( - "UPDATE command_ledger SET updated_at = updated_at WHERE service_id = ", - ); - lock.push_bind(fence.key().service_id()); - lock.push(" AND principal_partition = "); - lock.push_bind(fence.key().principal_partition()); - lock.push(" AND command_id = "); - lock.push_bind(fence.key().command_id()); - let result = - lock.build().execute(&mut **tx).await.map_err(|error| { - repository_storage_error::("lock command attempt preflight", error) - })?; - if DB::rows_affected(&result) != 1 { - return Err(CommandLedgerError::AttemptFenced { - command_id: fence.key().command_id().to_string(), - }); - } - - let record = select_command_ledger_record_in_tx(tx, fence.key(), None) - .await? - .ok_or_else(|| CommandLedgerError::AttemptFenced { - command_id: fence.key().command_id().to_string(), - })?; - let now = command_ledger_now_in_tx(tx).await?; - record.validate_live_attempt(&fence, now) + crate::repository::sql::ledger::preflight( + &mut executor::ConnectionExecutor::(&mut **tx), + completion, + ) + .await } async fn commit_sqlx_batch<'a, DB>( @@ -254,301 +229,6 @@ where } } -fn corrupt_ledger_value(error: CommandLedgerError) -> CommandLedgerError { - CommandLedgerError::Corrupt(error.to_string()) -} - -#[allow(dead_code)] -fn command_ledger_key_from_row(row: &DB::Row) -> Result -where - DB: SqlxRepoBackend, - for<'q> String: Type + sqlx::Decode<'q, DB>, - for<'r> &'r str: sqlx::ColumnIndex, -{ - let service_id: String = row.try_get("service_id").map_err(|error| { - repository_storage_error::("decode command ledger service ID", error) - })?; - let principal: String = row.try_get("principal_partition").map_err(|error| { - repository_storage_error::("decode command ledger principal partition", error) - })?; - let command_id: String = row.try_get("command_id").map_err(|error| { - repository_storage_error::("decode command ledger command ID", error) - })?; - CommandLedgerKey::new( - service_id, - PrincipalPartitionId::new(principal).map_err(corrupt_ledger_value)?, - CommandId::parse(command_id).map_err(corrupt_ledger_value)?, - ) - .map_err(corrupt_ledger_value) -} - -fn command_ledger_record_from_row( - row: &DB::Row, - key: CommandLedgerKey, -) -> Result -where - DB: SqlxRepoBackend, - for<'q> i64: Type + sqlx::Decode<'q, DB>, - for<'q> String: Type + sqlx::Decode<'q, DB>, - for<'q> Vec: Type + sqlx::Decode<'q, DB>, - for<'r> &'r str: sqlx::ColumnIndex, -{ - let decode = |operation: &'static str, error| repository_storage_error::(operation, error); - let command_name: String = row - .try_get("command_name") - .map_err(|error| decode("decode command ledger name", error))?; - let contract: Vec = row - .try_get("command_contract_hash") - .map_err(|error| decode("decode command contract hash", error))?; - let input: Vec = row - .try_get("input_hash") - .map_err(|error| decode("decode canonical command input hash", error))?; - let state: String = row - .try_get("state") - .map_err(|error| decode("decode command ledger state", error))?; - let causation_id: String = row - .try_get("causation_id") - .map_err(|error| decode("decode command ledger causation ID", error))?; - let attempt_token: Option = row - .try_get("attempt_token") - .map_err(|error| decode("decode command ledger attempt token", error))?; - let attempt_number: i64 = row - .try_get("attempt_number") - .map_err(|error| decode("decode command ledger attempt number", error))?; - let outcome_json: Option = row - .try_get("outcome") - .map_err(|error| decode("decode command ledger outcome", error))?; - - let record = CommandLedgerRecord { - key, - command_name, - contract_fingerprint: CommandContractFingerprint::try_from_slice(&contract) - .map_err(corrupt_ledger_value)?, - input_hash: CanonicalInputHash::try_from_slice(&input).map_err(corrupt_ledger_value)?, - state: CommandLedgerState::parse(&state)?, - causation_id: CausationId::parse_stored(causation_id)?, - attempt_token: attempt_token.map(AttemptToken::parse_stored).transpose()?, - attempt_number: repository_u64_from_i64( - DB::BACKEND, - attempt_number, - "command ledger attempt number", - )?, - lease_expires_at: DB::decode_optional_timestamp(row, "lease_expires_at")?, - outcome_json, - created_at: DB::decode_timestamp(row, "created_at")?, - updated_at: DB::decode_timestamp(row, "updated_at")?, - completed_at: DB::decode_optional_timestamp(row, "completed_at")?, - retention_expires_at: DB::decode_timestamp(row, "retention_expires_at")?, - compacted_at: DB::decode_optional_timestamp(row, "compacted_at")?, - }; - record.validate_stored_shape()?; - Ok(record) -} - -async fn command_ledger_now_in_tx( - tx: &mut Transaction<'_, DB>, -) -> Result -where - DB: SqlxRepoBackend, - for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, - DB::Arguments: IntoArguments, - for<'r> &'r str: sqlx::ColumnIndex, -{ - let mut builder = QueryBuilder::::new("SELECT "); - DB::push_command_ledger_now_epoch(&mut builder); - builder.push(" AS ledger_now"); - let row = builder - .build() - .fetch_one(&mut **tx) - .await - .map_err(|error| repository_storage_error::("read command ledger clock", error))?; - Ok(DB::decode_timestamp(&row, "ledger_now")?) -} - -async fn select_command_ledger_record_in_tx( - tx: &mut Transaction<'_, DB>, - key: &CommandLedgerKey, - expected_command_name: Option<&str>, -) -> Result, CommandLedgerError> -where - DB: SqlxRepoBackend, - for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, - DB::Arguments: IntoArguments, - for<'q> i64: Type + sqlx::Decode<'q, DB>, - for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, - for<'q> Vec: Type + sqlx::Decode<'q, DB>, - for<'q> &'q str: Encode<'q, DB> + Type, - for<'r> &'r str: sqlx::ColumnIndex, -{ - let mut builder = QueryBuilder::::new("SELECT "); - builder.push(DB::COMMAND_LEDGER_SELECT); - builder.push(" FROM command_ledger WHERE service_id = "); - builder.push_bind(key.service_id()); - builder.push(" AND principal_partition = "); - builder.push_bind(key.principal_partition()); - builder.push(" AND command_id = "); - builder.push_bind(key.command_id()); - if let Some(expected_command_name) = expected_command_name { - builder.push(" AND command_name = "); - builder.push_bind(expected_command_name); - } - builder.push(DB::COMMAND_LEDGER_LOCK_SUFFIX); - let row = builder - .build() - .fetch_optional(&mut **tx) - .await - .map_err(|error| repository_storage_error::("select command ledger row", error))?; - row.map(|row| command_ledger_record_from_row::(&row, key.clone())) - .transpose() -} - -async fn insert_command_reservation_in_tx( - tx: &mut Transaction<'_, DB>, - reservation: &CommandReservation, -) -> Result -where - DB: SqlxRepoBackend, - for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, - DB::Arguments: IntoArguments, - for<'q> i64: Encode<'q, DB> + Type, - for<'q> f64: Encode<'q, DB> + Type, - for<'q> &'q str: Encode<'q, DB> + Type, - for<'q> &'q [u8]: Encode<'q, DB> + Type, -{ - let mut builder = QueryBuilder::::new( - "INSERT INTO command_ledger (service_id, principal_partition, command_id, \ - command_name, command_contract_hash, input_hash, state, causation_id, attempt_token, \ - attempt_number, lease_expires_at, outcome, created_at, updated_at, completed_at, \ - retention_expires_at, compacted_at) VALUES (", - ); - builder.push_bind(reservation.key().service_id()); - builder.push(", "); - builder.push_bind(reservation.key().principal_partition()); - builder.push(", "); - builder.push_bind(reservation.key().command_id()); - builder.push(", "); - builder.push_bind(reservation.command_name()); - builder.push(", "); - builder.push_bind(reservation.contract_fingerprint_bytes().as_slice()); - builder.push(", "); - builder.push_bind(reservation.input_hash_bytes().as_slice()); - builder.push(", "); - builder.push_bind(CommandLedgerState::InProgress.as_str()); - builder.push(", "); - builder.push_bind(reservation.candidate_causation().as_str()); - builder.push(", "); - builder.push_bind(reservation.candidate_attempt().as_str()); - builder.push(", "); - builder.push_bind(1_i64); - builder.push(", "); - DB::push_command_ledger_deadline(&mut builder, reservation.lease()); - builder.push(", NULL, "); - DB::push_command_ledger_now(&mut builder); - builder.push(", "); - DB::push_command_ledger_now(&mut builder); - builder.push(", NULL, "); - DB::push_command_ledger_deadline(&mut builder, reservation.retention()); - builder.push(", NULL"); - builder.push(") ON CONFLICT (service_id, principal_partition, command_id) DO NOTHING"); - let result = builder - .build() - .execute(&mut **tx) - .await - .map_err(|error| repository_storage_error::("insert command reservation", error))?; - Ok(DB::rows_affected(&result) == 1) -} - -async fn expire_command_in_tx( - tx: &mut Transaction<'_, DB>, - key: &CommandLedgerKey, - require_retention_due: bool, -) -> Result -where - DB: SqlxRepoBackend, - for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, - DB::Arguments: IntoArguments, - for<'q> String: Encode<'q, DB> + Type, - for<'q> &'q str: Encode<'q, DB> + Type, -{ - let mut builder = QueryBuilder::::new( - "UPDATE command_ledger SET state = 'expired', attempt_token = NULL, \ - lease_expires_at = NULL, outcome = NULL, updated_at = ", - ); - DB::push_command_ledger_now(&mut builder); - builder.push(", compacted_at = "); - DB::push_command_ledger_now(&mut builder); - builder.push(" WHERE service_id = "); - builder.push_bind(key.service_id()); - builder.push(" AND principal_partition = "); - builder.push_bind(key.principal_partition()); - builder.push(" AND command_id = "); - builder.push_bind(key.command_id()); - builder.push(" AND state <> 'expired'"); - if require_retention_due { - builder.push(" AND retention_expires_at <= "); - DB::push_command_ledger_now(&mut builder); - } - let result = builder - .build() - .execute(&mut **tx) - .await - .map_err(|error| repository_storage_error::("expire command ledger row", error))?; - Ok(DB::rows_affected(&result)) -} - -async fn reclaim_command_in_tx( - tx: &mut Transaction<'_, DB>, - record: &mut CommandLedgerRecord, - reservation: &CommandReservation, - now: SystemTime, -) -> Result<(), CommandLedgerError> -where - DB: SqlxRepoBackend, - for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, - DB::Arguments: IntoArguments, - for<'q> i64: Encode<'q, DB> + Type, - for<'q> f64: Encode<'q, DB> + Type, - for<'q> String: Encode<'q, DB> + Type, - for<'q> &'q str: Encode<'q, DB> + Type, -{ - record.reclaim(reservation, now)?; - let attempt_number = repository_i64_from_u64( - DB::BACKEND, - record.attempt_number, - "command ledger attempt number", - DB::INTEGER_STORAGE, - )?; - let mut builder = QueryBuilder::::new( - "UPDATE command_ledger SET state = 'in_progress', attempt_token = ", - ); - builder.push_bind(reservation.candidate_attempt().as_str()); - builder.push(", attempt_number = "); - builder.push_bind(attempt_number); - builder.push(", lease_expires_at = "); - DB::push_command_ledger_deadline(&mut builder, reservation.lease()); - builder.push(", outcome = NULL, updated_at = "); - DB::push_command_ledger_now(&mut builder); - builder.push(", completed_at = NULL, retention_expires_at = "); - DB::push_command_ledger_deadline(&mut builder, reservation.retention()); - builder.push(", compacted_at = NULL WHERE service_id = "); - builder.push_bind(record.key.service_id()); - builder.push(" AND principal_partition = "); - builder.push_bind(record.key.principal_partition()); - builder.push(" AND command_id = "); - builder.push_bind(record.key.command_id()); - let result = builder - .build() - .execute(&mut **tx) - .await - .map_err(|error| repository_storage_error::("reclaim command attempt", error))?; - if DB::rows_affected(&result) != 1 { - return Err(CommandLedgerError::AttemptFenced { - command_id: record.key.command_id().to_string(), - }); - } - Ok(()) -} - async fn complete_command_in_tx( tx: &mut Transaction<'_, DB>, completion: &CommandCompletion, @@ -557,292 +237,104 @@ where DB: SqlxRepoBackend, for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, DB::Arguments: IntoArguments, - for<'q> i64: Encode<'q, DB> + Type, - for<'q> f64: Encode<'q, DB> + Type, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'q> &'q str: Encode<'q, DB> + Type, for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, { - let fence = completion.attempt_fence(); - let attempt_number = repository_i64_from_u64( - DB::BACKEND, - fence.attempt_number(), - "command ledger attempt number", - DB::INTEGER_STORAGE, - )?; - let terminal_state = CommandLedgerState::from(completion.state()).as_str(); - let retention_expires_at = completion - .retention_expires_at() - .map(DB::timestamp_value) - .transpose() - .map_err(CommandLedgerError::Storage)?; - let mut builder = QueryBuilder::::new("UPDATE command_ledger SET state = "); - builder.push_bind(terminal_state); - builder.push(", attempt_token = NULL, lease_expires_at = NULL, outcome = "); - DB::push_command_ledger_json(&mut builder, completion.replay_json()); - builder.push(", updated_at = "); - DB::push_command_ledger_now(&mut builder); - builder.push(", completed_at = "); - DB::push_command_ledger_now(&mut builder); - builder.push(", retention_expires_at = "); - match retention_expires_at.as_ref() { - Some(deadline) => DB::push_timestamp_assign(&mut builder, deadline), - None => DB::push_command_ledger_deadline(&mut builder, completion.retention()), - } - builder.push(", compacted_at = NULL WHERE service_id = "); - builder.push_bind(fence.key().service_id()); - builder.push(" AND principal_partition = "); - builder.push_bind(fence.key().principal_partition()); - builder.push(" AND command_id = "); - builder.push_bind(fence.key().command_id()); - builder.push(" AND command_contract_hash = "); - builder.push_bind(fence.contract_fingerprint_bytes().as_slice()); - builder.push(" AND input_hash = "); - builder.push_bind(fence.input_hash_bytes().as_slice()); - builder.push(" AND state = 'in_progress' AND causation_id = "); - builder.push_bind(fence.causation_id().as_str()); - builder.push(" AND attempt_token = "); - builder.push_bind(fence.attempt_token().as_str()); - builder.push(" AND attempt_number = "); - builder.push_bind(attempt_number); - builder.push(" AND lease_expires_at > "); - DB::push_command_ledger_now(&mut builder); - if let Some(deadline) = retention_expires_at.as_ref() { - builder.push(" AND "); - DB::push_command_ledger_deadline_is_live(&mut builder, deadline); - } - let result = - builder.build().execute(&mut **tx).await.map_err(|error| { - repository_storage_error::("complete command ledger row", error) - })?; - if DB::rows_affected(&result) != 1 { - return Err(CommandLedgerError::AttemptFenced { - command_id: fence.key().command_id().to_string(), - }); - } - Ok(()) + crate::repository::sql::ledger::complete( + &mut executor::ConnectionExecutor::(&mut **tx), + completion, + ) + .await } impl CommandLedgerStore for SqlxRepository where DB: SqlxRepoBackend, for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, - for<'c> &'c Pool: Executor<'c, Database = DB>, DB::Arguments: IntoArguments, for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, - for<'q> f64: Encode<'q, DB> + Type, - for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'q> &'q str: Encode<'q, DB> + Type, for<'q> &'q [u8]: Encode<'q, DB> + Type, for<'r> &'r str: sqlx::ColumnIndex, { - fn reserve_command( + async fn reserve_command( &self, reservation: CommandReservation, - ) -> impl Future> + Send + '_ { - async move { - let mut tx = self.pool.begin().await.map_err(|error| { + ) -> Result { + let mut tx = + self.pool.begin().await.map_err(|error| { repository_storage_error::("begin command reservation", error) })?; - if insert_command_reservation_in_tx(&mut tx, &reservation).await? { - tx.commit().await.map_err(|error| { - repository_storage_error::("commit command reservation", error) - })?; - return Ok(ReservationOutcome::Acquired( - reservation.acquired_candidate_attempt(), - )); - } - - let mut record = select_command_ledger_record_in_tx(&mut tx, reservation.key(), None) - .await? - .ok_or_else(|| { - CommandLedgerError::Corrupt(format!( - "conflicting command `{}` disappeared during reservation", - reservation.key().command_id() - )) - })?; - let now = command_ledger_now_in_tx(&mut tx).await?; - let decision = record.classify_reservation(&reservation, now)?; - let outcome = match decision { - ReservationDecision::Expire => { - expire_command_in_tx(&mut tx, reservation.key(), false).await?; - ReservationOutcome::Expired - } - ReservationDecision::Reclaim => { - reclaim_command_in_tx(&mut tx, &mut record, &reservation, now).await?; - ReservationOutcome::Acquired(record.acquired_attempt()?) - } - other => record.reservation_outcome(other)?, - }; - tx.commit().await.map_err(|error| { - repository_storage_error::("commit command reservation decision", error) - })?; - Ok(outcome) - } + let outcome = crate::repository::sql::ledger::reserve( + &mut executor::ConnectionExecutor::(&mut *tx), + &reservation, + ) + .await?; + tx.commit() + .await + .map_err(|error| repository_storage_error::("commit command reservation", error))?; + Ok(outcome) } - fn lookup_command<'a>( + async fn lookup_command<'a>( &'a self, key: &'a CommandLedgerKey, scope: CommandLookupScope<'a>, - ) -> impl Future> + Send + 'a { - async move { - let mut tx = self.pool.begin().await.map_err(|error| { - repository_storage_error::("begin command ledger lookup", error) - })?; - - // Establish SQLite's single-writer reservation before selecting; - // PostgreSQL additionally takes the row lock through its suffix. - let mut lock = QueryBuilder::::new( - "UPDATE command_ledger SET updated_at = updated_at WHERE service_id = ", - ); - lock.push_bind(key.service_id()); - lock.push(" AND principal_partition = "); - lock.push_bind(key.principal_partition()); - lock.push(" AND command_id = "); - lock.push_bind(key.command_id()); - match scope { - CommandLookupScope::CommandName(expected_command_name) - | CommandLookupScope::CommandContract { - command_name: expected_command_name, - .. - } => { - lock.push(" AND command_name = "); - lock.push_bind(expected_command_name); - } - CommandLookupScope::Attempt(_) => {} - } - lock.build().execute(&mut *tx).await.map_err(|error| { - repository_storage_error::("lock command ledger lookup", error) - })?; - - let expected_command_name = match scope { - CommandLookupScope::CommandName(expected) => Some(expected), - CommandLookupScope::CommandContract { - command_name: expected, - .. - } => Some(expected), - CommandLookupScope::Attempt(_) => None, - }; - let Some(mut record) = - select_command_ledger_record_in_tx(&mut tx, key, expected_command_name).await? - else { - tx.commit().await.map_err(|error| { - repository_storage_error::("commit empty command ledger lookup", error) - })?; - return Ok(CommandLookup::Unknown); - }; - if !record.matches_lookup_scope(scope) { - tx.commit().await.map_err(|error| { - repository_storage_error::("commit mismatched command ledger lookup", error) - })?; - return Ok(CommandLookup::Unknown); - } - let now = command_ledger_now_in_tx(&mut tx).await?; - if record.state != CommandLedgerState::Expired && record.retention_expires_at <= now { - expire_command_in_tx(&mut tx, key, true).await?; - record.expire(now); - } - let lookup = record.lookup()?; - tx.commit().await.map_err(|error| { - repository_storage_error::("commit command ledger lookup", error) - })?; - Ok(lookup) - } + ) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|error| repository_storage_error::("begin command lookup", error))?; + let outcome = crate::repository::sql::ledger::lookup( + &mut executor::ConnectionExecutor::(&mut *tx), + key, + scope, + ) + .await?; + tx.commit() + .await + .map_err(|error| repository_storage_error::("commit command lookup", error))?; + Ok(outcome) } - fn mark_retryable_unknown( + async fn mark_retryable_unknown( &self, attempt: AttemptFence, - ) -> impl Future> + Send + '_ { - async move { - let attempt_number = repository_i64_from_u64( - DB::BACKEND, - attempt.attempt_number(), - "command ledger attempt number", - DB::INTEGER_STORAGE, - )?; - let mut builder = QueryBuilder::::new( - "UPDATE command_ledger SET state = 'retryable_unknown', attempt_token = NULL, \ - lease_expires_at = NULL, updated_at = ", - ); - DB::push_command_ledger_now(&mut builder); - builder.push(" WHERE service_id = "); - builder.push_bind(attempt.key().service_id()); - builder.push(" AND principal_partition = "); - builder.push_bind(attempt.key().principal_partition()); - builder.push(" AND command_id = "); - builder.push_bind(attempt.key().command_id()); - builder.push(" AND command_contract_hash = "); - builder.push_bind(attempt.contract_fingerprint_bytes().as_slice()); - builder.push(" AND input_hash = "); - builder.push_bind(attempt.input_hash_bytes().as_slice()); - builder.push(" AND state = 'in_progress' AND causation_id = "); - builder.push_bind(attempt.causation_id().as_str()); - builder.push(" AND attempt_token = "); - builder.push_bind(attempt.attempt_token().as_str()); - builder.push(" AND attempt_number = "); - builder.push_bind(attempt_number); - let result = builder.build().execute(&self.pool).await.map_err(|error| { - repository_storage_error::("mark command retryable unknown", error) - })?; - if DB::rows_affected(&result) != 1 { - return Err(CommandLedgerError::AttemptFenced { - command_id: attempt.key().command_id().to_string(), - }); - } - Ok(()) - } + ) -> Result<(), CommandLedgerError> { + let mut connection = self.pool.acquire().await.map_err(|error| { + repository_storage_error::("acquire command ledger connection", error) + })?; + crate::repository::sql::ledger::mark_retryable( + &mut executor::ConnectionExecutor::(&mut *connection), + &attempt, + ) + .await } - fn compact_expired_commands( - &self, - limit: usize, - ) -> impl Future> + Send + '_ { - async move { - if limit == 0 { - return Ok(0); - } - let limit = i64::try_from(limit).map_err(|_| { - CommandLedgerError::Invalid("command compaction limit exceeds i64".into()) - })?; - let mut tx = self.pool.begin().await.map_err(|error| { - repository_storage_error::("begin command ledger compaction", error) + async fn compact_expired_commands(&self, limit: usize) -> Result { + if limit == 0 { + return Ok(0); + } + let mut tx = + self.pool.begin().await.map_err(|error| { + repository_storage_error::("begin command compaction", error) })?; - - // A no-op write obtains SQLite's transaction-wide writer lock. - // PostgreSQL relies on the per-row SKIP LOCKED suffix below. - QueryBuilder::::new( - "UPDATE command_ledger SET updated_at = updated_at WHERE 1 = 0", - ) - .build() - .execute(&mut *tx) + let count = crate::repository::sql::ledger::compact( + &mut executor::ConnectionExecutor::(&mut *tx), + limit, + ) + .await?; + tx.commit() .await - .map_err(|error| { - repository_storage_error::("lock command ledger compaction", error) - })?; - - let mut select = QueryBuilder::::new( - "SELECT service_id, principal_partition, command_id FROM command_ledger \ - WHERE state <> 'expired' AND retention_expires_at <= ", - ); - DB::push_command_ledger_now(&mut select); - select.push(" ORDER BY retention_expires_at, service_id, principal_partition, command_id LIMIT "); - select.push_bind(limit); - select.push(DB::COMMAND_LEDGER_COMPACTION_LOCK_SUFFIX); - let rows = select.build().fetch_all(&mut *tx).await.map_err(|error| { - repository_storage_error::("select command ledger compaction rows", error) - })?; - let mut compacted = 0; - for row in rows { - let key = command_ledger_key_from_row::(&row)?; - compacted += expire_command_in_tx(&mut tx, &key, true).await?; - } - tx.commit().await.map_err(|error| { - repository_storage_error::("commit command ledger compaction", error) - })?; - Ok(compacted) - } + .map_err(|error| repository_storage_error::("commit command compaction", error))?; + Ok(count) } } /// Record a consumer inbox receipt in the commit transaction. The @@ -880,31 +372,7 @@ where } } -/// One `aggregate_events` row with pre-validated bind values, built before the -/// query so any conversion error surfaces before we touch the database. The -/// stream identity and expected version ride along for conflict recovery. -struct EventRow<'a, DB: SqlxRepoBackend> { - identity: &'a StreamIdentity, - expected_version: u64, - sequence: i64, - event_name: &'a str, - event_version: i64, - payload: &'a [u8], - payload_codec: &'a str, - payload_codec_version: i64, - metadata: String, - recorded_at: DB::TimestampValue, -} - -/// Insert every event across all prepared appends with multi-row INSERTs, -/// chunked to respect the backend's bound-parameter limit (Postgres is -/// effectively unlimited, so its chunking collapses to one statement). -/// -/// Conflict detection is unchanged from the per-row path: the `(aggregate_type, -/// aggregate_id, sequence)` primary key is the contiguity gate, and a unique -/// violation still surfaces as `ConcurrentWrite`. Recovery re-reads stream -/// versions in-tx or over the pool depending on -/// [`SqlxRepoBackend::CONFLICT_REREAD_IN_TX`]. +/// Execute the shared event insert plan in the command's existing transaction. async fn insert_events_in_tx( pool: &Pool, tx: &mut Transaction<'_, DB>, @@ -916,96 +384,51 @@ where for<'c> &'c Pool: Executor<'c, Database = DB>, DB::Arguments: IntoArguments, for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'q> &'q str: Encode<'q, DB> + Type, for<'q> &'q [u8]: Encode<'q, DB> + Type, for<'r> &'r str: sqlx::ColumnIndex, { - let mut rows = Vec::new(); - for append in prepared { - for event in append.events { - rows.push(EventRow:: { - identity: &append.identity, - expected_version: append.expected_version, - sequence: repository_i64_from_u64( - DB::BACKEND, - event.sequence, - "sequence", - DB::INTEGER_STORAGE, - )?, - event_name: &event.event_name, - event_version: repository_i64_from_u64( - DB::BACKEND, - event.event_version, - "event_version", - DB::INTEGER_STORAGE, - )?, - payload: &event.payload, - payload_codec: &event.payload_codec, - payload_codec_version: i64::from(event.payload_codec_version), - metadata: serialize_event_metadata(&event.metadata)?, - recorded_at: DB::timestamp_value(event.timestamp)?, - }); - } - } - - for chunk in rows.chunks(DB::MAX_BIND_PARAMS / EVENT_BIND_COLUMNS) { - let mut builder = QueryBuilder::::new( - "INSERT INTO aggregate_events (\ - aggregate_type, aggregate_id, sequence, event_name, event_version, \ - payload, payload_codec, payload_codec_version, metadata, recorded_at) ", - ); - builder.push_values(chunk, |mut row, event| { - row.push_bind(event.identity.aggregate_type()) - .push_bind(event.identity.aggregate_id()) - .push_bind(event.sequence) - .push_bind(event.event_name) - .push_bind(event.event_version) - .push_bind(event.payload) - .push_bind(event.payload_codec) - .push_bind(event.payload_codec_version); - DB::push_metadata(&mut row, event.metadata.as_str()); - DB::push_timestamp(&mut row, &event.recorded_at); - }); - - let result = builder.build().execute(&mut **tx).await; - match result { - Ok(_) => {} - Err(err) if DB::is_unique_violation(&err) => { - return Err(if DB::CONFLICT_REREAD_IN_TX { - // The transaction survives the constraint error: re-read in - // the same tx, scoped to this chunk (earlier chunks were - // already inserted in this tx and would skew the versions - // of their streams). - let mut seen = std::collections::HashSet::new(); - let candidates: Vec<_> = chunk - .iter() - .filter(|event| seen.insert(event.identity.storage_key())) - .map(|event| (event.identity, event.expected_version)) - .collect(); - concurrent_write_from_conflict(&mut **tx, &candidates).await - } else { - // The failed statement aborted the transaction: re-read the - // conflicting streams' actual versions on a separate - // connection, across the whole batch. - let candidates: Vec<_> = prepared - .iter() - .map(|append| (&append.identity, append.expected_version)) - .collect(); - match pool.acquire().await { - Ok(mut conn) => { - concurrent_write_from_conflict(&mut conn, &candidates).await - } - Err(err) => repository_storage_error::( - "acquire conflict re-read connection", - err, - ), - } - }); + use crate::repository::sql::SqlExecutor; + for insert in crate::repository::sql::event_inserts(prepared, DB::MAX_BIND_PARAMS)? { + let result = super::executor::ConnectionExecutor::(&mut **tx) + .execute(insert.statement) + .await; + if let Err(error) = result { + let unique = match &error { + RepositoryError::Storage { + source: Some(source), + .. + } => source + .downcast_ref::() + .is_some_and(DB::is_unique_violation), + _ => false, + }; + if !unique { + return Err(error); } - Err(err) => return Err(repository_storage_error::("insert events", err)), + return Err(if DB::CONFLICT_REREAD_IN_TX { + let candidates: Vec<_> = insert + .candidates + .iter() + .map(|(identity, version)| (identity, *version)) + .collect(); + concurrent_write_from_conflict(&mut **tx, &candidates).await + } else { + let candidates: Vec<_> = prepared + .iter() + .map(|append| (&append.identity, append.expected_version)) + .collect(); + match pool.acquire().await { + Ok(mut conn) => concurrent_write_from_conflict(&mut conn, &candidates).await, + Err(error) => { + repository_storage_error::("acquire conflict re-read connection", error) + } + } + }); } } - Ok(()) } diff --git a/src/sqlx_repo/repo/events.rs b/src/sqlx_repo/repo/events.rs index 351ca17b8..174543b6f 100644 --- a/src/sqlx_repo/repo/events.rs +++ b/src/sqlx_repo/repo/events.rs @@ -8,51 +8,5 @@ where for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex, { - let payload_codec: String = row - .try_get("payload_codec") - .map_err(|err| repository_storage_error::("decode payload codec row", err))?; - // Nearly every row carries the crate's own codec constant; borrow it - // instead of keeping a per-event allocation. - let payload_codec = if payload_codec == BITCODE_PAYLOAD_CODEC { - Cow::Borrowed(BITCODE_PAYLOAD_CODEC) - } else { - Cow::Owned(payload_codec) - }; - let payload_codec_version = repository_u16_from_i64( - DB::BACKEND, - row.try_get("payload_codec_version").map_err(|err| { - repository_storage_error::("decode payload codec version row", err) - })?, - "payload_codec_version", - )?; - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error::("decode metadata row", err))?; - let metadata = deserialize_event_metadata(&metadata_json)?; - let event = EventRecord { - event_name: row - .try_get("event_name") - .map_err(|err| repository_storage_error::("decode event name row", err))?, - payload_codec, - payload_codec_version, - payload: row - .try_get("payload") - .map_err(|err| repository_storage_error::("decode payload row", err))?, - event_version: repository_u64_from_i64( - DB::BACKEND, - row.try_get("event_version") - .map_err(|err| repository_storage_error::("decode event version row", err))?, - "event_version", - )?, - sequence: repository_u64_from_i64( - DB::BACKEND, - row.try_get("sequence") - .map_err(|err| repository_storage_error::("decode sequence row", err))?, - "sequence", - )?, - timestamp: DB::decode_timestamp(&row, "recorded_at")?, - metadata, - }; - validate_supported_event_codec(&event)?; - Ok(event) + crate::repository::sql::event_from_row(&super::executor::EventRow::(row)) } diff --git a/src/sqlx_repo/repo/executor.rs b/src/sqlx_repo/repo/executor.rs new file mode 100644 index 000000000..352f04d23 --- /dev/null +++ b/src/sqlx_repo/repo/executor.rs @@ -0,0 +1,147 @@ +//! SQLx execution adapter for the runtime-independent SQL event store. + +use super::*; +use crate::repository::sql::{SqlBind, SqlExecutor, SqlPart, SqlRow, Statement}; + +pub(crate) struct ConnectionExecutor<'a, DB: SqlxRepoBackend>(pub &'a mut DB::Connection); +pub(crate) struct EventRow(pub DB::Row); + +impl SqlRow for EventRow +where + DB: SqlxRepoBackend, + for<'q> i64: Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'r> &'r str: sqlx::ColumnIndex, +{ + fn text(&self, column: &'static str) -> Result { + self.0 + .try_get(column) + .map_err(|error| repository_storage_error::(column, error)) + } + fn optional_text(&self, column: &'static str) -> Result, RepositoryError> { + self.0 + .try_get(column) + .map_err(|error| repository_storage_error::(column, error)) + } + fn integer(&self, column: &'static str) -> Result { + self.0 + .try_get(column) + .map_err(|error| repository_storage_error::(column, error)) + } + fn optional_integer(&self, column: &'static str) -> Result, RepositoryError> { + self.0 + .try_get(column) + .map_err(|error| repository_storage_error::(column, error)) + } + fn bytes(&self, column: &'static str) -> Result, RepositoryError> { + self.0 + .try_get(column) + .map_err(|error| repository_storage_error::(column, error)) + } + fn timestamp(&self, column: &'static str) -> Result { + DB::decode_timestamp(&self.0, column) + } + fn optional_timestamp( + &self, + column: &'static str, + ) -> Result, RepositoryError> { + DB::decode_optional_timestamp(&self.0, column) + } +} + +fn build(statement: &Statement<'_>) -> Result, RepositoryError> +where + DB: SqlxRepoBackend, + for<'q> i64: Encode<'q, DB> + Type, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, +{ + let mut builder = QueryBuilder::::new(""); + for part in &statement.0 { + match part { + SqlPart::TimestampCompare { + column, + operator, + value, + } => { + DB::push_timestamp_cmp( + &mut builder, + column, + operator, + system_time_epoch_secs::(*value)?, + ); + } + SqlPart::LedgerNow => DB::push_command_ledger_now(&mut builder), + SqlPart::LedgerNowEpoch => DB::push_command_ledger_now_epoch(&mut builder), + SqlPart::LedgerDeadline(value) => { + DB::push_command_ledger_deadline(&mut builder, *value) + } + SqlPart::LedgerDeadlineIsLive(value) => DB::push_command_ledger_deadline_is_live( + &mut builder, + &DB::timestamp_value(*value)?, + ), + SqlPart::LedgerJson(value) => DB::push_command_ledger_json(&mut builder, value), + SqlPart::Sql(sql) => { + builder.push(sql); + } + SqlPart::Bind(SqlBind::Text(value)) => { + builder.push_bind(value.as_str()); + } + SqlPart::Bind(SqlBind::Integer(value)) => { + builder.push_bind(*value); + } + SqlPart::Bind(SqlBind::Bytes(value)) => { + builder.push_bind(value.as_ref()); + } + SqlPart::Bind(SqlBind::Metadata(value)) => { + DB::push_metadata(&mut builder.separated(""), value); + } + SqlPart::Bind(SqlBind::Timestamp(value)) => { + DB::push_timestamp(&mut builder.separated(""), &DB::timestamp_value(*value)?); + } + } + } + Ok(builder) +} + +impl SqlExecutor for ConnectionExecutor<'_, DB> +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + type Row = EventRow; + const EVENT_SELECT: &'static str = DB::EVENT_SELECT; + const SNAPSHOT_SELECT: &'static str = DB::SNAPSHOT_SELECT; + const OUTBOX_SELECT: &'static str = DB::OUTBOX_SELECT; + const NOW: &'static str = DB::NOW; + const COMMAND_LEDGER_SELECT: &'static str = DB::COMMAND_LEDGER_SELECT; + const COMMAND_LEDGER_LOCK_SUFFIX: &'static str = DB::COMMAND_LEDGER_LOCK_SUFFIX; + const COMMAND_LEDGER_COMPACTION_LOCK_SUFFIX: &'static str = + DB::COMMAND_LEDGER_COMPACTION_LOCK_SUFFIX; + + async fn query(&mut self, statement: Statement<'_>) -> Result, RepositoryError> { + build::(&statement)? + .build() + .fetch_all(&mut *self.0) + .await + .map(|rows| rows.into_iter().map(EventRow).collect()) + .map_err(|error| repository_storage_error::("query event store", error)) + } + + async fn execute(&mut self, statement: Statement<'_>) -> Result { + build::(&statement)? + .build() + .execute(&mut *self.0) + .await + .map(|result| DB::rows_affected(&result)) + .map_err(|error| repository_storage_error::("write event store", error)) + } +} diff --git a/src/sqlx_repo/repo/mod.rs b/src/sqlx_repo/repo/mod.rs index 716b1e161..194dd7556 100644 --- a/src/sqlx_repo/repo/mod.rs +++ b/src/sqlx_repo/repo/mod.rs @@ -16,7 +16,6 @@ reason = "async trait impls return impl Future + Send to preserve public Send bounds" )] -use std::borrow::Cow; use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::sync::{Arc, RwLock}; @@ -28,24 +27,20 @@ use sqlx::query_builder::Separated; use sqlx::{Encode, Executor, IntoArguments, Pool, QueryBuilder, Row, Transaction, Type}; use crate::command_ledger::{ - AttemptFence, AttemptToken, CanonicalInputHash, CausalCommitBatch, CausalGetStream, - CausalRepositoryIdentity, CausalStorageIdentity, CausalTransactionalCommit, CausationId, - CommandCompletion, CommandContractFingerprint, CommandId, CommandLedgerError, CommandLedgerKey, - CommandLedgerRecord, CommandLedgerState, CommandLedgerStore, CommandLookup, CommandLookupScope, - CommandReservation, PrincipalPartitionId, ReservationDecision, ReservationOutcome, + AttemptFence, CausalCommitBatch, CausalGetStream, CausalRepositoryIdentity, + CausalStorageIdentity, CausalTransactionalCommit, CommandCompletion, CommandLedgerError, + CommandLedgerKey, CommandLedgerStore, CommandLookup, CommandLookupScope, CommandReservation, + ReservationOutcome, }; -use crate::entity::{Entity, EventRecord, BITCODE_PAYLOAD_CODEC}; +use crate::entity::{Entity, EventRecord}; use crate::outbox::{OutboxMessage, OutboxMessageStatus}; -use crate::outbox_worker::{ - ensure_active_claim, ClaimOutboxMessages, OutboxBacklogStats, OutboxClaimRef, OutboxStore, -}; +use crate::outbox_worker::{ClaimOutboxMessages, OutboxBacklogStats, OutboxClaimRef, OutboxStore}; use crate::projection_protocol::{ProjectionChangeRetention, SameTransactionProjectionBatch}; use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities}; use crate::repository::{ - validate_commit_batch, validate_snapshot_identity, validate_supported_event_codec, CommitBatch, - GetStream, InboxReceipt, InboxStore, PreparedEventAppend, ReadModelWritePlanStore, - RelationalReadModelQueryStore, RepositoryError, SnapshotStore, SnapshotWrite, StreamIdentity, - TransactionalCommit, + validate_commit_batch, CommitBatch, GetStream, InboxReceipt, InboxStore, PreparedEventAppend, + ReadModelWritePlanStore, RelationalReadModelQueryStore, RepositoryError, SnapshotStore, + SnapshotWrite, StreamIdentity, TransactionalCommit, }; use crate::snapshot::SnapshotRecord; use crate::sqlx_repo::projection_protocol::{ @@ -54,13 +49,10 @@ use crate::sqlx_repo::projection_protocol::{ }; use crate::sqlx_repo::read_model::{ apply_read_model_write_plan_in_tx, begin_read_model_tx, commit_read_model_tx, - empty_string_as_none, load_read_model_graph, remember_read_model_schemas, - sql_read_model_capabilities, validate_sql_write_plan, SqlxReadModelBackend, -}; -use crate::sqlx_repo::{ - audited_table_schema_sql, deserialize_event_metadata, repository_i64_from_u64, - repository_u16_from_i64, repository_u64_from_i64, serialize_event_metadata, + load_read_model_graph, remember_read_model_schemas, sql_read_model_capabilities, + validate_sql_write_plan, SqlxReadModelBackend, }; +use crate::sqlx_repo::{audited_table_schema_sql, repository_u64_from_i64}; use crate::table::{ generate_table_migration_artifacts, table_schema_bootstrap_result, table_schema_statements, TableMigrationArtifact, TableSchemaBootstrap, TableSchemaRegistry, TableSqlDialect, @@ -76,6 +68,8 @@ mod backend; mod commit; mod errors; mod events; +mod executor; +pub(crate) use executor::ConnectionExecutor; mod inbox; mod outbox; mod read_models; @@ -95,8 +89,6 @@ pub(crate) use backend::POSTGRES_MIGRATIONS; #[cfg(feature = "sqlite")] pub(crate) use backend::SQLITE_MIGRATIONS; pub(crate) use errors::{repository_storage_error, system_time_epoch_secs}; -#[cfg(feature = "sqlite")] -pub(crate) use outbox::outbox_message_by_id; #[cfg(feature = "postgres")] pub(crate) use outbox::outbox_message_from_row; pub use types::{SqlxOutboxStore, SqlxRepository}; diff --git a/src/sqlx_repo/repo/outbox.rs b/src/sqlx_repo/repo/outbox.rs index 952686878..67e3dfee3 100644 --- a/src/sqlx_repo/repo/outbox.rs +++ b/src/sqlx_repo/repo/outbox.rs @@ -1,30 +1,6 @@ use super::*; -/// One claimed-message lifecycle transition (the `UPDATE` shape is shared; only -/// the assignments differ). -enum OutboxTransition<'a> { - Complete, - Release { error: &'a str }, - Fail { error: &'a str }, -} - -impl OutboxTransition<'_> { - fn target_status(&self) -> OutboxMessageStatus { - match self { - OutboxTransition::Complete => OutboxMessageStatus::Published, - OutboxTransition::Release { .. } => OutboxMessageStatus::Pending, - OutboxTransition::Fail { .. } => OutboxMessageStatus::Failed, - } - } - - fn operation(&self) -> &'static str { - match self { - OutboxTransition::Complete => "complete outbox message", - OutboxTransition::Release { .. } => "release outbox message", - OutboxTransition::Fail { .. } => "fail outbox message", - } - } -} +use crate::repository::sql::outbox::Transition as OutboxTransition; impl OutboxStore for SqlxOutboxStore where @@ -122,7 +98,7 @@ where claim: &'a OutboxClaimRef, error: &'a str, ) -> impl Future> + Send + 'a { - transition_claimed_outbox_message(&self.pool, claim, OutboxTransition::Release { error }) + transition_claimed_outbox_message(&self.pool, claim, OutboxTransition::Release(error)) } fn fail<'a>( @@ -130,16 +106,11 @@ where claim: &'a OutboxClaimRef, error: &'a str, ) -> impl Future> + Send + 'a { - transition_claimed_outbox_message(&self.pool, claim, OutboxTransition::Fail { error }) + transition_claimed_outbox_message(&self.pool, claim, OutboxTransition::Fail(error)) } } -/// Apply one claimed-message lifecycle transition (complete / release / fail). -/// -/// The conditional `UPDATE` only applies while the caller still holds the -/// active claim (`status`, `claimed_by`, unexpired `claimed_until`, and -/// matching `attempts`); when no row is updated, the message is re-read to -/// produce the precise claim error. +/// Execute the shared, lease-fenced delivery transition on one connection. async fn transition_claimed_outbox_message<'a, DB>( pool: &'a Pool, claim: &'a OutboxClaimRef, @@ -147,154 +118,29 @@ async fn transition_claimed_outbox_message<'a, DB>( ) -> Result<(), RepositoryError> where DB: SqlxRepoBackend, - for<'c> &'c Pool: Executor<'c, Database = DB>, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, DB::Arguments: IntoArguments, for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, - for<'q> f64: Encode<'q, DB> + Type, - for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'q> &'q str: Encode<'q, DB> + Type, - for<'q> Option: Encode<'q, DB> + Type, - for<'q> Option<&'q str>: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, for<'r> &'r str: sqlx::ColumnIndex, { - let now = SystemTime::now(); - let now_epoch = system_time_epoch_secs::(now)?; - let now_value = DB::timestamp_value(now)?; - - let mut builder = QueryBuilder::::new("UPDATE outbox_messages SET status = "); - builder.push_bind(transition.target_status().as_str()); - builder.push(", claimed_by = NULL, claimed_until = NULL, "); - match &transition { - OutboxTransition::Complete => { - builder.push("published_at = "); - DB::push_timestamp_assign(&mut builder, &now_value); - } - OutboxTransition::Release { error } => { - builder.push("next_available_at = "); - DB::push_timestamp_assign(&mut builder, &now_value); - builder.push(", last_error = "); - builder.push_bind(empty_string_as_none(error)); - } - OutboxTransition::Fail { error } => { - builder.push("last_error = "); - builder.push_bind(empty_string_as_none(error)); - builder.push(", failed_at = "); - DB::push_timestamp_assign(&mut builder, &now_value); - } - } - builder.push(", updated_at = "); - builder.push(DB::NOW); - builder.push(" WHERE message_id = "); - builder.push_bind(claim.message_id.as_str()); - builder.push(" AND status = "); - builder.push_bind(OutboxMessageStatus::InFlight.as_str()); - builder.push(" AND claimed_by = "); - builder.push_bind(claim.worker_id.as_str()); - builder.push(" AND claimed_until IS NOT NULL AND "); - DB::push_timestamp_cmp(&mut builder, "claimed_until", ">", now_epoch); - builder.push(" AND attempts = "); - builder.push_bind(repository_i64_from_u64( - DB::BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - DB::INTEGER_STORAGE, - )?); - - let result = builder - .build() - .execute(pool) + let mut connection = pool + .acquire() .await - .map_err(|err| repository_storage_error::(transition.operation(), err))?; - - ensure_outbox_update_applied( - pool, - DB::rows_affected(&result), - &claim.message_id, - |message| ensure_active_claim(message, Some(claim), now), + .map_err(|error| repository_storage_error::("acquire outbox connection", error))?; + crate::repository::sql::outbox::transition( + &mut executor::ConnectionExecutor::(&mut connection), + claim, + transition, + SystemTime::now(), ) .await } -/// Load an outbox message by id through any executor (pool or transaction). -pub(crate) async fn outbox_message_by_id<'e, DB, E>( - executor: E, - message_id: &str, -) -> Result, RepositoryError> -where - DB: SqlxRepoBackend, - E: Executor<'e, Database = DB>, - DB::Arguments: IntoArguments, - for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, - for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, - for<'q> Vec: Type + sqlx::Decode<'q, DB>, - for<'q> &'q str: Encode<'q, DB> + Type, - for<'r> &'r str: sqlx::ColumnIndex, -{ - let mut builder = QueryBuilder::::new("SELECT "); - builder.push(DB::OUTBOX_SELECT); - builder.push(" FROM outbox_messages WHERE message_id = "); - builder.push_bind(message_id); - let row = builder - .build() - .fetch_optional(executor) - .await - .map_err(|err| repository_storage_error::("load outbox message", err))?; - row.map(outbox_message_from_row::).transpose() -} - -pub(crate) async fn ensure_outbox_update_applied( - pool: &Pool, - rows_affected: u64, - message_id: &str, - validate: impl FnOnce(&OutboxMessage) -> Result<(), RepositoryError>, -) -> Result<(), RepositoryError> -where - DB: SqlxRepoBackend, - for<'c> &'c Pool: Executor<'c, Database = DB>, - DB::Arguments: IntoArguments, - for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, - for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, - for<'q> Vec: Type + sqlx::Decode<'q, DB>, - for<'q> &'q str: Encode<'q, DB> + Type, - for<'r> &'r str: sqlx::ColumnIndex, -{ - if rows_affected > 0 { - return Ok(()); - } - - let message = outbox_message_by_id(pool, message_id) - .await? - .ok_or_else(|| RepositoryError::NotFound { - id: message_id.to_string(), - })?; - validate(&message) -} -/// One `outbox_messages` row with pre-validated bind values. -struct OutboxRow<'a, DB: SqlxRepoBackend> { - message_id: &'a str, - event_type: &'a str, - payload: &'a [u8], - payload_codec: &'a str, - payload_codec_version: i64, - destination: Option<&'a str>, - metadata: String, - status: &'a str, - created_at: DB::TimestampValue, - worker_id: Option<&'a str>, - leased_until: Option, - attempts: i64, - last_error: Option<&'a str>, - source_aggregate_type: Option<&'a str>, - source_aggregate_id: Option<&'a str>, - source_sequence: Option, - correlation_id: Option<&'a str>, - causation_id: Option<&'a str>, -} - -/// Insert every outbox message with multi-row INSERTs (chunked to respect the -/// backend's bound-parameter limit). A unique violation on `message_id` still -/// maps to `DuplicateOutboxMessageInBatch`. +/// Insert shared delivery-row plans in the command's existing transaction. pub(super) async fn insert_outbox_messages_in_tx( tx: &mut Transaction<'_, DB>, messages: &[OutboxMessage], @@ -303,101 +149,35 @@ where DB: SqlxRepoBackend, for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, DB::Arguments: IntoArguments, - for<'q> i64: Encode<'q, DB> + Type, - for<'q> Option: Encode<'q, DB> + Type, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'q> &'q str: Encode<'q, DB> + Type, - for<'q> Option<&'q str>: Encode<'q, DB> + Type, for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, { - if messages.is_empty() { - return Ok(()); - } - - let mut rows = Vec::with_capacity(messages.len()); - for message in messages { - rows.push(OutboxRow:: { - message_id: message.id(), - event_type: &message.event_type, - payload: &message.payload, - payload_codec: &message.payload_codec, - payload_codec_version: i64::from(message.payload_codec_version), - destination: message.destination.as_deref(), - metadata: serialize_event_metadata(&message.metadata)?, - status: message.status.as_str(), - created_at: DB::timestamp_value(message.created_at)?, - worker_id: message.worker_id.as_deref(), - leased_until: message.leased_until.map(DB::timestamp_value).transpose()?, - attempts: i64::from(message.attempts), - last_error: message.last_error.as_deref(), - source_aggregate_type: message.source_aggregate_type.as_deref(), - source_aggregate_id: message.source_aggregate_id.as_deref(), - source_sequence: message - .source_sequence - .map(|value| { - repository_i64_from_u64( - DB::BACKEND, - value, - "outbox source sequence", - DB::INTEGER_STORAGE, - ) - }) - .transpose()?, - correlation_id: message.correlation_id(), - causation_id: message.causation_id(), - }); - } - - for chunk in rows.chunks(DB::MAX_BIND_PARAMS / OUTBOX_BIND_COLUMNS) { - let mut builder = QueryBuilder::::new( - "INSERT INTO outbox_messages (\ - message_id, event_type, payload, payload_codec, payload_codec_version, \ - destination, metadata, status, created_at, next_available_at, \ - claimed_by, claimed_until, attempts, last_error, source_aggregate_type, \ - source_aggregate_id, source_sequence, correlation_id, causation_id) ", - ); - builder.push_values(chunk, |mut row, message| { - row.push_bind(message.message_id) - .push_bind(message.event_type) - .push_bind(message.payload) - .push_bind(message.payload_codec) - .push_bind(message.payload_codec_version) - .push_bind(message.destination); - DB::push_metadata(&mut row, message.metadata.as_str()); - row.push_bind(message.status); - // created_at and next_available_at share the same value. - DB::push_timestamp(&mut row, &message.created_at); - DB::push_timestamp(&mut row, &message.created_at); - row.push_bind(message.worker_id); - DB::push_optional_timestamp(&mut row, message.leased_until.as_ref()); - row.push_bind(message.attempts) - .push_bind(message.last_error) - .push_bind(message.source_aggregate_type) - .push_bind(message.source_aggregate_id) - .push_bind(message.source_sequence) - .push_bind(message.correlation_id) - .push_bind(message.causation_id); - }); - - let result = builder.build().execute(&mut **tx).await; - if let Err(err) = result { - if DB::is_unique_violation(&err) { - // The batch was already deduped (validate_commit_batch), so a - // violation means the id collides with a previously committed - // row. Report the first id in the chunk, matching the per-row - // path's contract. + use crate::repository::sql::SqlExecutor; + use std::error::Error; + for insert in crate::repository::sql::outbox::inserts(messages, DB::MAX_BIND_PARAMS)? { + let result = executor::ConnectionExecutor::(&mut **tx) + .execute(insert.statement) + .await; + if let Err(error) = result { + if error + .source() + .and_then(|source| source.downcast_ref::()) + .is_some_and(DB::is_unique_violation) + { return Err(RepositoryError::DuplicateOutboxMessageInBatch { - id: chunk[0].message_id.to_string(), + id: insert.first_id.to_string(), }); } - return Err(repository_storage_error::( - "insert outbox messages", - err, - )); + return Err(error); } } - Ok(()) } + pub(crate) fn outbox_message_from_row(row: DB::Row) -> Result where DB: SqlxRepoBackend, @@ -406,85 +186,5 @@ where for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex, { - let status_text: String = row - .try_get("status") - .map_err(|err| repository_storage_error::("decode outbox status row", err))?; - let status = status_text.parse::().map_err(|_| { - RepositoryError::Model(format!( - "{} outbox status `{status_text}` is invalid", - DB::BACKEND - )) - })?; - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error::("decode outbox metadata row", err))?; - let attempts: i64 = row - .try_get("attempts") - .map_err(|err| repository_storage_error::("decode outbox attempts row", err))?; - let source_sequence = row - .try_get::, _>("source_sequence") - .map_err(|err| repository_storage_error::("decode outbox source sequence row", err))? - .map(|value| repository_u64_from_i64(DB::BACKEND, value, "outbox source sequence")) - .transpose()?; - let mut metadata = deserialize_event_metadata(&metadata_json)?; - if let Some(correlation_id) = row - .try_get::, _>("correlation_id") - .map_err(|err| repository_storage_error::("decode outbox correlation_id row", err))? - { - metadata.insert("correlation_id".into(), correlation_id); - } - if let Some(causation_id) = row - .try_get::, _>("causation_id") - .map_err(|err| repository_storage_error::("decode outbox causation_id row", err))? - { - metadata.insert("causation_id".into(), causation_id); - } - - Ok(OutboxMessage { - id: row - .try_get("message_id") - .map_err(|err| repository_storage_error::("decode outbox message id row", err))?, - event_type: row - .try_get("event_type") - .map_err(|err| repository_storage_error::("decode outbox event type row", err))?, - payload: row - .try_get("payload") - .map_err(|err| repository_storage_error::("decode outbox payload row", err))?, - payload_codec: row.try_get("payload_codec").map_err(|err| { - repository_storage_error::("decode outbox payload codec row", err) - })?, - payload_codec_version: repository_u16_from_i64( - DB::BACKEND, - row.try_get("payload_codec_version").map_err(|err| { - repository_storage_error::("decode outbox payload codec version row", err) - })?, - "outbox payload codec version", - )?, - metadata, - status, - created_at: DB::decode_timestamp(&row, "created_at")?, - worker_id: row - .try_get("claimed_by") - .map_err(|err| repository_storage_error::("decode outbox claimed_by row", err))?, - leased_until: DB::decode_optional_timestamp(&row, "claimed_until")?, - attempts: u32::try_from(attempts).map_err(|_| { - RepositoryError::Model(format!( - "{} outbox attempts value {attempts} is invalid", - DB::BACKEND - )) - })?, - last_error: row - .try_get("last_error") - .map_err(|err| repository_storage_error::("decode outbox last_error row", err))?, - destination: row - .try_get("destination") - .map_err(|err| repository_storage_error::("decode outbox destination row", err))?, - source_aggregate_type: row.try_get("source_aggregate_type").map_err(|err| { - repository_storage_error::("decode outbox source aggregate type row", err) - })?, - source_aggregate_id: row.try_get("source_aggregate_id").map_err(|err| { - repository_storage_error::("decode outbox source aggregate id row", err) - })?, - source_sequence, - }) + crate::repository::sql::outbox::from_row(executor::EventRow::(row)) } diff --git a/src/sqlx_repo/repo/snapshots.rs b/src/sqlx_repo/repo/snapshots.rs index 5c62c218f..dea549d57 100644 --- a/src/sqlx_repo/repo/snapshots.rs +++ b/src/sqlx_repo/repo/snapshots.rs @@ -18,23 +18,14 @@ where identity: &'a StreamIdentity, ) -> impl Future, RepositoryError>> + Send + 'a { async move { - let mut builder = QueryBuilder::::new("SELECT "); - builder.push(DB::SNAPSHOT_SELECT); - builder.push(" FROM aggregate_snapshots WHERE aggregate_type = "); - builder.push_bind(identity.aggregate_type()); - builder.push(" AND aggregate_id = "); - builder.push_bind(identity.aggregate_id()); - let row = builder - .build() - .fetch_optional(&self.pool) - .await - .map_err(|err| repository_storage_error::("load snapshot", err))?; - - let Some(row) = row else { - return Ok(None); - }; - - Ok(Some(snapshot_from_row::(row)?)) + let mut connection = self.pool.acquire().await.map_err(|error| { + repository_storage_error::("acquire snapshot connection", error) + })?; + crate::repository::sql::load_snapshot( + &mut super::executor::ConnectionExecutor::(&mut connection), + identity, + ) + .await } } @@ -91,18 +82,14 @@ where identity: &'a StreamIdentity, ) -> impl Future> + Send + 'a { async move { - let mut builder = - QueryBuilder::::new("DELETE FROM aggregate_snapshots WHERE aggregate_type = "); - builder.push_bind(identity.aggregate_type()); - builder.push(" AND aggregate_id = "); - builder.push_bind(identity.aggregate_id()); - let result = builder - .build() - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error::("delete snapshot", err))?; - - Ok(DB::rows_affected(&result) > 0) + let mut connection = self.pool.acquire().await.map_err(|error| { + repository_storage_error::("acquire snapshot connection", error) + })?; + crate::repository::sql::delete_snapshot( + &mut super::executor::ConnectionExecutor::(&mut connection), + identity, + ) + .await } } } @@ -115,64 +102,19 @@ where DB: SqlxRepoBackend, for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, DB::Arguments: IntoArguments, - for<'q> i64: Encode<'q, DB> + Type, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'r> &'r str: sqlx::ColumnIndex, for<'q> &'q str: Encode<'q, DB> + Type, for<'q> &'q [u8]: Encode<'q, DB> + Type, { - validate_snapshot_identity(identity, &record)?; - - let metadata = serialize_event_metadata(&record.metadata)?; - let recorded_at = DB::timestamp_value(record.recorded_at)?; - let version = repository_i64_from_u64( - DB::BACKEND, - record.version, - "snapshot version", - DB::INTEGER_STORAGE, - )?; - let snapshot_version = repository_i64_from_u64( - DB::BACKEND, - record.snapshot_version, - "snapshot payload version", - DB::INTEGER_STORAGE, - )?; - - let mut builder = QueryBuilder::::new( - "INSERT INTO aggregate_snapshots (\ - aggregate_type, aggregate_id, version, snapshot_version, payload, \ - payload_codec, payload_codec_version, metadata, recorded_at) VALUES (", - ); - { - let mut row = builder.separated(", "); - row.push_bind(identity.aggregate_type()) - .push_bind(identity.aggregate_id()) - .push_bind(version) - .push_bind(snapshot_version) - .push_bind(record.payload.as_slice()) - .push_bind(record.payload_codec.as_str()) - .push_bind(i64::from(record.payload_codec_version)); - DB::push_metadata(&mut row, metadata.as_str()); - DB::push_timestamp(&mut row, &recorded_at); - } - builder.push( - ") ON CONFLICT(aggregate_type, aggregate_id) DO UPDATE SET \ - version = excluded.version, \ - snapshot_version = excluded.snapshot_version, \ - payload = excluded.payload, \ - payload_codec = excluded.payload_codec, \ - payload_codec_version = excluded.payload_codec_version, \ - metadata = excluded.metadata, \ - recorded_at = excluded.recorded_at, \ - updated_at = ", - ); - builder.push(DB::NOW); - - builder - .build() - .execute(&mut **tx) - .await - .map_err(|err| repository_storage_error::("save snapshot", err))?; - - Ok(()) + crate::repository::sql::save_snapshot( + &mut super::executor::ConnectionExecutor::(&mut **tx), + identity, + &record, + ) + .await } pub(super) fn snapshot_from_row(row: DB::Row) -> Result where @@ -182,44 +124,5 @@ where for<'q> Vec: Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex, { - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error::("decode snapshot metadata row", err))?; - Ok(SnapshotRecord { - aggregate_type: row.try_get("aggregate_type").map_err(|err| { - repository_storage_error::("decode snapshot aggregate type row", err) - })?, - aggregate_id: row.try_get("aggregate_id").map_err(|err| { - repository_storage_error::("decode snapshot aggregate id row", err) - })?, - version: repository_u64_from_i64( - DB::BACKEND, - row.try_get("version").map_err(|err| { - repository_storage_error::("decode snapshot version row", err) - })?, - "snapshot version", - )?, - snapshot_version: repository_u64_from_i64( - DB::BACKEND, - row.try_get("snapshot_version").map_err(|err| { - repository_storage_error::("decode snapshot payload version row", err) - })?, - "snapshot payload version", - )?, - payload_codec: row.try_get("payload_codec").map_err(|err| { - repository_storage_error::("decode snapshot payload codec row", err) - })?, - payload_codec_version: repository_u16_from_i64( - DB::BACKEND, - row.try_get("payload_codec_version").map_err(|err| { - repository_storage_error::("decode snapshot payload codec version row", err) - })?, - "snapshot payload codec version", - )?, - payload: row - .try_get("payload") - .map_err(|err| repository_storage_error::("decode snapshot payload row", err))?, - metadata: deserialize_event_metadata(&metadata_json)?, - recorded_at: DB::decode_timestamp(&row, "recorded_at")?, - }) + crate::repository::sql::snapshot_from_row(&super::executor::EventRow::(row)) } diff --git a/src/sqlx_repo/repo/streams.rs b/src/sqlx_repo/repo/streams.rs index 7dfd9c70f..5079a217f 100644 --- a/src/sqlx_repo/repo/streams.rs +++ b/src/sqlx_repo/repo/streams.rs @@ -19,32 +19,15 @@ where identity: &'a StreamIdentity, ) -> impl Future, RepositoryError>> + Send + 'a { async move { - let mut builder = QueryBuilder::::new("SELECT "); - builder.push(DB::EVENT_SELECT); - builder.push(" FROM aggregate_events WHERE aggregate_type = "); - builder.push_bind(identity.aggregate_type()); - builder.push(" AND aggregate_id = "); - builder.push_bind(identity.aggregate_id()); - builder.push(" ORDER BY sequence ASC"); - let rows = builder - .build() - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error::("load stream", err))?; - - if rows.is_empty() { - return Ok(None); - } - - let mut events = Vec::with_capacity(rows.len()); - for row in rows { - events.push(event_from_row::(row)?); - } - - let mut entity = Entity::new(); - entity.set_id(identity.aggregate_id()); - entity.load_from_history(events); - Ok(Some(entity)) + let mut connection = self.pool.acquire().await.map_err(|error| { + repository_storage_error::("acquire stream connection", error) + })?; + crate::repository::sql::load_stream( + &mut super::executor::ConnectionExecutor::(&mut connection), + identity, + None, + ) + .await } } @@ -112,56 +95,15 @@ where after_version: u64, ) -> impl Future, RepositoryError>> + Send + 'a { async move { - // Fetch only the post-snapshot tail. `after_version` is the snapshot - // version (an event sequence); `sequence > ?` skips already-folded - // rows so a fresh snapshot over a long stream no longer reads and - // decodes the entire history. - let after = repository_i64_from_u64( - DB::BACKEND, - after_version, - "snapshot tail lower bound", - DB::INTEGER_STORAGE, - )?; - let mut builder = QueryBuilder::::new("SELECT "); - builder.push(DB::EVENT_SELECT); - builder.push(" FROM aggregate_events WHERE aggregate_type = "); - builder.push_bind(identity.aggregate_type()); - builder.push(" AND aggregate_id = "); - builder.push_bind(identity.aggregate_id()); - builder.push(" AND sequence > "); - builder.push_bind(after); - builder.push(" ORDER BY sequence ASC"); - let rows = builder - .build() - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error::("load stream tail", err))?; - - 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, prefix); - Ok(Some(entity)) + let mut connection = self.pool.acquire().await.map_err(|error| { + repository_storage_error::("acquire stream tail connection", error) + })?; + crate::repository::sql::load_stream( + &mut super::executor::ConnectionExecutor::(&mut connection), + identity, + Some(after_version), + ) + .await } } } @@ -180,6 +122,14 @@ where for<'q> &'q [u8]: Encode<'q, DB> + Type, for<'r> &'r str: sqlx::ColumnIndex, { + fn get_causal_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl Future, RepositoryError>> + Send + 'a { + GetStream::get_stream_tail(self, identity, after_version) + } + fn get_causal_stream<'a>( &'a self, identity: &'a StreamIdentity, diff --git a/src/table/metadata.rs b/src/table/metadata.rs index c8e7fbb60..19c03c8e2 100644 --- a/src/table/metadata.rs +++ b/src/table/metadata.rs @@ -152,6 +152,10 @@ pub enum RelationshipKind { /// Relationship metadata for a relational table model. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RelationshipDef { + /// Ordered columns of the referenced primary or declared unique key. + /// Omission selects the primary key; row identity is never changed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub references: Option, pub field_name: String, pub kind: RelationshipKind, pub target_model: String, @@ -609,6 +613,7 @@ mod tests { let schema = valid_schema(); let mut with_relationship = schema.clone(); with_relationship.relationships.push(RelationshipDef { + references: None, field_name: "player".into(), kind: RelationshipKind::BelongsTo, target_model: "Player".into(), @@ -667,6 +672,7 @@ mod tests { fn validate_rejects_relationships_without_foreign_keys() { let mut schema = valid_schema(); schema.relationships.push(RelationshipDef { + references: None, field_name: "weapons".into(), kind: RelationshipKind::HasMany, target_model: "PlayerWeapon".into(), diff --git a/src/table/mod.rs b/src/table/mod.rs index 5b893b541..1c30ec672 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -6,22 +6,22 @@ //! vocabulary. This module owns the canonical types; `read_model` builds its //! typed staging/load surface on top of them. -mod error; mod catalog; +mod error; mod metadata; mod mutation; mod plan; mod registry; mod sql; -pub use error::TableStoreError; pub use catalog::ReadModelCatalog; +pub use error::TableStoreError; pub use metadata::{ ColumnType, ForeignKey, PrimaryKey, RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, TableColumn, TableIndex, TableKind, TableSchema, DEFAULT_TABLE_VERSION_COLUMN, }; pub(crate) use mutation::{ - column_name_for, has_many_join_columns, key_fingerprint, key_from_row, + belongs_to_join_columns, column_name_for, has_many_join_columns, key_fingerprint, key_from_row, validate_delete_mutation, validate_expected_version, validate_key, validate_patch_mutation, validate_row_mutation, validate_row_values, }; @@ -32,9 +32,9 @@ pub use mutation::{ pub use plan::{TableAdapterCapabilities, TableCommitOutcome, TableWritePlan}; pub use registry::{ resolve_direct_join_keys, resolve_m2m_join_keys, DirectJoinPair, JoinColumnPair, M2mJoinKeys, - TableMigrationArtifact, TableSchemaAdapter, - TableSchemaAdapterCapabilities, TableSchemaBootstrap, TableSchemaIssue, TableSchemaIssueKind, - TableSchemaRegistry, TableSchemaVerification, + TableMigrationArtifact, TableSchemaAdapter, TableSchemaAdapterCapabilities, + TableSchemaBootstrap, TableSchemaIssue, TableSchemaIssueKind, TableSchemaRegistry, + TableSchemaVerification, }; pub use sql::{ bootstrap_result as table_schema_bootstrap_result, generate_table_migration_artifacts, diff --git a/src/table/mutation.rs b/src/table/mutation.rs index 737d7f2d5..ecf989960 100644 --- a/src/table/mutation.rs +++ b/src/table/mutation.rs @@ -377,7 +377,8 @@ pub(crate) fn has_many_join_columns( relationship.field_name ))); } - let pairs = super::registry::resolve_direct_join_keys(root_schema, relationship, target_schema)?; + let pairs = + super::registry::resolve_direct_join_keys(root_schema, relationship, target_schema)?; match pairs.as_slice() { [pair] => Ok(( pair.foreign_key_column.clone(), @@ -390,6 +391,32 @@ pub(crate) fn has_many_join_columns( } } +pub(crate) fn belongs_to_join_columns( + source: &TableSchema, + relationship: &RelationshipDef, + target: &TableSchema, +) -> Result<(String, String), TableStoreError> { + if !matches!(relationship.kind, RelationshipKind::BelongsTo) { + return Err(TableStoreError::Metadata( + "expected a belongs_to relationship".into(), + )); + } + if relationship.references.is_none() && target.primary_key.columns.len() != 1 { + return Err(TableStoreError::Metadata(format!( + "belongs_to include `{}` targeting `{}` through `{}` requires a single-column primary key", + relationship.field_name, target.model_name, relationship.foreign_key.as_deref().unwrap_or("unspecified foreign key"), + ))); + } + let pairs = super::registry::resolve_direct_join_keys(source, relationship, target)?; + match pairs.as_slice() { + [pair] => Ok((pair.foreign_key_column.clone(), pair.primary_key_column.clone())), + _ => Err(TableStoreError::Metadata(format!( + "relationship `{}` has a composite direct join; single-column belongs_to helpers cannot load it", + relationship.field_name, + ))), + } +} + pub(crate) fn key_fingerprint(key: &RowKey) -> String { let mut fingerprint = String::new(); for (column, value) in key.iter() { @@ -490,6 +517,7 @@ mod tests { kind: TableKind::ReadModel, }; let relationship = RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "Child".into(), diff --git a/src/table/registry.rs b/src/table/registry.rs index 55113634b..afe817011 100644 --- a/src/table/registry.rs +++ b/src/table/registry.rs @@ -158,6 +158,12 @@ impl TableSchemaRegistry { let _ = resolve_direct_join_keys(schema, relationship, target_schema)?; } RelationshipKind::ManyToMany => { + if relationship.references.is_some() { + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` references requires a direct relationship", + schema.model_name, relationship.field_name, + ))); + } let through = relationship.through.as_deref().ok_or_else(|| { TableStoreError::Metadata(format!( "model `{}` relationship `{}` many-to-many must declare `through`", @@ -243,7 +249,8 @@ pub struct M2mJoinKeys { pub target: Vec, } -/// One foreign-key column paired with one primary-key column for a direct join. +/// One foreign-key column paired with a referenced key column for a direct join. +/// The referenced key defaults to the primary key, or is selected by `references`. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DirectJoinPair { pub foreign_key_column: String, @@ -264,11 +271,11 @@ impl DirectJoinPair { /// Resolve `has_many` / `belongs_to` join equalities. /// -/// `foreign_key` lists the FK-holding table's columns in the other end's PK -/// order, same arity as that PK (comma-separated when more than one). +/// `foreign_key` lists the FK-holding table's columns in the order of `references`, +/// or the other end's primary key when references are omitted. Arity must match. /// -/// - **HasMany**: FK columns live on the target; PK is the source. -/// - **BelongsTo**: FK columns live on the source; PK is the target. +/// - **HasMany**: FK columns live on the target; referenced key is on the source. +/// - **BelongsTo**: FK columns live on the source; referenced key is on the target. pub fn resolve_direct_join_keys( source: &TableSchema, relationship: &RelationshipDef, @@ -284,7 +291,54 @@ pub fn resolve_direct_join_keys( ))); } }; - let pk_columns = &pk_schema.primary_key.columns; + let explicit = parse_explicit_through_columns( + source, + relationship, + "references", + relationship.references.as_deref(), + )?; + let referenced_columns = if let Some(names) = explicit { + let columns = names + .iter() + .map(|name| { + column_name_on(pk_schema, name) + .map(str::to_owned) + .ok_or_else(|| { + TableStoreError::Metadata(format!( + "model `{}` relationship `{}` references unknown column `{name}` on `{}`", + source.model_name, relationship.field_name, pk_schema.model_name, + )) + }) + }) + .collect::, _>>()?; + if columns.iter().collect::>().len() != columns.len() { + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` references repeats a physical column", + source.model_name, relationship.field_name, + ))); + } + let unique = (columns.len() == pk_schema.primary_key.columns.len() + && pk_schema + .primary_key + .columns + .iter() + .all(|column| columns.contains(column))) + || pk_schema.indexes.iter().any(|index| { + index.unique + && index.columns.len() == columns.len() + && index.columns.iter().all(|column| columns.contains(column)) + }); + if !unique { + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` references must name a declared unique key on `{}`", + source.model_name, relationship.field_name, pk_schema.model_name, + ))); + } + columns + } else { + pk_schema.primary_key.columns.clone() + }; + let pk_columns = &referenced_columns; if pk_columns.is_empty() { return Err(TableStoreError::Metadata(format!( "model `{}` relationship `{}` cannot join because `{}` has an empty primary key", @@ -305,15 +359,21 @@ pub fn resolve_direct_join_keys( }; if fk_names.len() != pk_columns.len() { return Err(TableStoreError::Metadata(format!( - "model `{}` relationship `{}` foreign_key lists {} column(s) but `{}` primary key has {}", + "model `{}` relationship `{}` foreign_key lists {} column(s) but `{}` {} has {}", source.model_name, relationship.field_name, fk_names.len(), pk_schema.model_name, + if relationship.references.is_some() { + "referenced key" + } else { + "primary key" + }, pk_columns.len() ))); } let mut pairs = Vec::with_capacity(pk_columns.len()); + let mut seen_foreign_columns = BTreeSet::new(); for (fk_name, pk_column) in fk_names.iter().zip(pk_columns) { let foreign_key_column = column_name_on(fk_schema, fk_name).ok_or_else(|| { let side = match relationship.kind { @@ -325,6 +385,30 @@ pub fn resolve_direct_join_keys( source.model_name, relationship.field_name, fk_schema.model_name )) })?; + if !seen_foreign_columns.insert(foreign_key_column) { + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` foreign_key repeats a physical column", + source.model_name, relationship.field_name, + ))); + } + let foreign = fk_schema + .columns + .iter() + .find(|column| column.column_name == foreign_key_column) + .unwrap(); + let referenced = pk_schema + .columns + .iter() + .find(|column| column.column_name == *pk_column) + .ok_or_else(|| { + TableStoreError::Metadata(format!("referenced key column `{pk_column}` is missing")) + })?; + if foreign.column_type != referenced.column_type || foreign.jsonb != referenced.jsonb { + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` joins incompatible column types for `{foreign_key_column}` and `{pk_column}`", + source.model_name, relationship.field_name, + ))); + } pairs.push(DirectJoinPair::new(foreign_key_column, pk_column.clone())); } Ok(pairs) @@ -345,6 +429,12 @@ pub fn resolve_m2m_join_keys( through_schema: &TableSchema, target_schema: &TableSchema, ) -> Result { + if relationship.references.is_some() { + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` references requires a direct relationship", + source.model_name, relationship.field_name, + ))); + } if !matches!(relationship.kind, RelationshipKind::ManyToMany) { return Err(TableStoreError::Metadata(format!( "model `{}` relationship `{}` must be many-to-many to resolve join keys", @@ -714,6 +804,7 @@ mod m2m_join_key_tests { fn labels_rel(foreign_key: Option<&str>, target_foreign_key: Option<&str>) -> RelationshipDef { RelationshipDef { + references: None, field_name: "labels".into(), kind: RelationshipKind::ManyToMany, target_model: "LabelView".into(), @@ -842,6 +933,7 @@ mod m2m_join_key_tests { fn files_rel(foreign_key: &str) -> RelationshipDef { RelationshipDef { + references: None, field_name: "files".into(), kind: RelationshipKind::HasMany, target_model: "ProjectFileView".into(), @@ -892,6 +984,7 @@ mod m2m_join_key_tests { ], &["workspace_id", "path", "file_id"], vec![RelationshipDef { + references: None, field_name: "project".into(), kind: RelationshipKind::BelongsTo, target_model: "ProjectView".into(), @@ -941,4 +1034,104 @@ mod m2m_join_key_tests { ] ); } + + #[test] + fn direct_join_can_reference_a_composite_candidate_key() { + let mut target = schema( + "Object", + "objects", + vec![pk_column("id"), column("namespace"), column("oid")], + &["id"], + vec![], + ); + target.indexes.push(crate::table::TableIndex { + name: None, + columns: vec!["namespace".into(), "oid".into()], + unique: true, + }); + let relation = RelationshipDef { + references: Some("namespace,oid".into()), + field_name: "object".into(), + kind: RelationshipKind::BelongsTo, + target_model: "Object".into(), + foreign_key: Some("scope,object_oid".into()), + through: None, + target_foreign_key: None, + }; + let source = schema( + "Ref", + "refs", + vec![pk_column("ref_id"), column("scope"), column("object_oid")], + &["ref_id"], + vec![relation.clone()], + ); + assert_eq!( + resolve_direct_join_keys(&source, &relation, &target).unwrap(), + vec![ + DirectJoinPair::new("scope", "namespace"), + DirectJoinPair::new("object_oid", "oid"), + ] + ); + assert_eq!(target.primary_key.columns, vec!["id"]); + let reverse = RelationshipDef { + field_name: "refs".into(), + kind: RelationshipKind::HasMany, + target_model: "Ref".into(), + ..relation + }; + assert_eq!( + resolve_direct_join_keys(&target, &reverse, &source).unwrap(), + vec![ + DirectJoinPair::new("scope", "namespace"), + DirectJoinPair::new("object_oid", "oid"), + ] + ); + } + + #[test] + fn direct_join_rejects_non_unique_candidate_key() { + let source = projects(files_rel("workspace_id,path")); + let target = project_files(); + let mut relation = source.relationships[0].clone(); + relation.references = Some("kind".into()); + let error = resolve_direct_join_keys(&source, &relation, &target) + .unwrap_err() + .to_string(); + assert!(error.contains("declared unique key"), "{error}"); + relation.references = Some("missing".into()); + assert!(resolve_direct_join_keys(&source, &relation, &target) + .unwrap_err() + .to_string() + .contains("unknown column")); + } + + #[test] + fn direct_join_rejects_alias_duplicates_and_incompatible_types() { + let mut source = projects(files_rel("workspace_id,path")); + let mut target = project_files(); + let mut relation = source.relationships[0].clone(); + source.columns[0].field_name = "workspace_alias".into(); + relation.references = Some("workspace_alias,workspace_id".into()); + assert!(resolve_direct_join_keys(&source, &relation, &target) + .unwrap_err() + .to_string() + .contains("repeats a physical column")); + relation.references = Some("workspace_id,path".into()); + target.columns[0].column_type = ColumnType::Integer; + assert!(resolve_direct_join_keys(&source, &relation, &target) + .unwrap_err() + .to_string() + .contains("incompatible column types")); + } + + #[test] + fn programmatic_many_to_many_cannot_silently_ignore_references() { + let source = projects(labels_rel(None, None)); + let mut relation = source.relationships[0].clone(); + relation.references = Some("path".into()); + let error = resolve_m2m_join_keys(&source, &relation, &source, &labels()) + .unwrap_err() + .to_string(); + assert!(error.contains("requires a direct relationship"), "{error}"); + } } diff --git a/tests/application_composition.rs b/tests/application_composition.rs index dc2a783bb..3fc3e970d 100644 --- a/tests/application_composition.rs +++ b/tests/application_composition.rs @@ -6,18 +6,21 @@ use distributed::application::{ CommandDefinition, CommandMount, CommandSpec, CommandTypeField, CommandTypeSpec, ContractCompiler, Module, ProjectionSpec, Runtime, RuntimeDialect, SurfaceSpec, }; +use distributed::command::{typed_command, CommandConsistency, Succeeded}; use distributed::graphql::{ build_surface, col, prune_client_manifest, surface_for_application_contract, surface_for_role, - typed_command, ClientCommandPureReduce, ClientProjectionArm, ClientProjectionEventRef, + ClientCommandPureReduce, ClientProjectionArm, ClientProjectionEventRef, ClientProjectionFallback, ClientProjectionMutationKind, ClientProjectionOperation, - ClientProjectionPartition, ClientProjectionProgram, ClientSurfaceIdentity, CommandConsistency, + ClientProjectionPartition, ClientProjectionProgram, ClientSurfaceIdentity, CommandProjectionArmRef, CommandProjectionExtension, CommandProjectionPreviewOccurrence, - DistributedClientSurfaceExport, RoleGrant, Succeeded, Surface, SurfaceOptions, + DistributedClientSurfaceExport, RoleGrant, Surface, SurfaceOptions, }; use distributed::projection::{ PROJECTION_OPERATION_SEMANTICS_VERSION, PROJECTION_PROGRAM_IR_VERSION, }; -use distributed::{ApplicationManifest, GraphqlInput, GraphqlOutput, ReadModel, RelationalReadModel}; +use distributed::{ + ApplicationManifest, CommandInput, CommandOutput, ReadModel, RelationalReadModel, +}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, ReadModel, Serialize)] @@ -38,22 +41,19 @@ struct ChatView { } #[allow(dead_code)] -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct ContractCommandInput { title: String, } -#[derive(Clone, Serialize, GraphqlOutput)] +#[derive(Clone, Serialize, CommandOutput)] struct ContractCommandOutput { id: String, } fn typed_definition(id: &'static str, roles: &[&str]) -> CommandDefinition { - let command = typed_command::< - ContractCommandInput, - Succeeded, - >(id) - .roles(roles.iter().copied()); + let command = typed_command::>(id) + .roles(roles.iter().copied()); CommandDefinition::from_typed_command(command, None) .expect("typed contract should compile without an executable mount") } @@ -141,6 +141,32 @@ fn full_surface() -> Surface { .expect("non-empty Surface should compile") } +#[test] +fn generated_surface_above_opaque_json_budget_round_trips() { + let tables = (0..500) + .map(|index| { + let mut table = TodoView::schema().clone(); + table.model_name = format!("CatalogView{index:03}"); + table.table_name = format!("catalog_view_{index:03}"); + table + }) + .collect::>(); + let surface = build_surface(&tables, &SurfaceOptions::sqlite()).unwrap(); + let spec = SurfaceSpec::from_surface("catalog", &surface).unwrap(); + let contract_bytes = serde_json::to_vec(&spec.contract).unwrap().len(); + assert!( + contract_bytes > distributed::application::MAX_MANIFEST_JSON_BYTES, + "fixture must exceed opaque JSON budget: {contract_bytes}" + ); + let application = Application::try_new("catalog-app", [], [spec]).unwrap(); + let bytes = application.manifest().canonical_bytes().unwrap(); + assert!(bytes.len() <= distributed::application::MAX_APPLICATION_MANIFEST_BYTES); + assert_eq!( + ApplicationManifest::from_canonical_bytes(&bytes).unwrap(), + *application.manifest() + ); +} + fn selected_surface() -> Surface { let full = full_surface(); let grants = BTreeMap::from([( @@ -152,14 +178,8 @@ fn selected_surface() -> Surface { .rows(col("status").eq("open")), )]), )]); - surface_for_application_contract( - &full, - "web", - &["user".into()], - &["user".into()], - &grants, - ) - .expect("role/application-selected Surface should compile") + surface_for_application_contract(&full, "web", &["user".into()], &["user".into()], &grants) + .expect("role/application-selected Surface should compile") } #[test] @@ -229,19 +249,19 @@ fn role_and_application_command_closure_rejects_missing_unauthorized_and_tampere let mut missing = valid.clone(); missing.commands.clear(); missing.contract["commands"] = serde_json::json!([]); - missing.fingerprint = distributed::application::sha256_fingerprint( - &missing.canonical_bytes().unwrap(), - ); + missing.fingerprint = + distributed::application::sha256_fingerprint(&missing.canonical_bytes().unwrap()); let error = Application::try_new("missing-command", [module.clone()], [missing]) .expect_err("missing selected command must fail closed"); - assert!(error.to_string().contains("exact authorized command closure")); + assert!(error + .to_string() + .contains("exact authorized command closure")); let mut tampered = valid.clone(); tampered.commands[0].roles = vec!["admin".into()]; tampered.contract["commands"][0]["roles"] = serde_json::json!(["admin"]); - tampered.fingerprint = distributed::application::sha256_fingerprint( - &tampered.canonical_bytes().unwrap(), - ); + tampered.fingerprint = + distributed::application::sha256_fingerprint(&tampered.canonical_bytes().unwrap()); let error = Application::try_new("tampered-command", [module.clone()], [tampered]) .expect_err("tampered selected command must fail closed"); assert!(error.to_string().contains("not compatible")); @@ -259,18 +279,20 @@ fn role_and_application_command_closure_rejects_missing_unauthorized_and_tampere let forbidden_contract = admin_spec.contract["commands"][0].clone(); let mut unauthorized = valid; unauthorized.commands.push(forbidden_command.clone()); - unauthorized.commands.sort_by(|left, right| left.id.cmp(&right.id)); unauthorized - .contract["commands"] + .commands + .sort_by(|left, right| left.id.cmp(&right.id)); + unauthorized.contract["commands"] .as_array_mut() .expect("surface command contract array") .push(forbidden_contract.clone()); - unauthorized.fingerprint = distributed::application::sha256_fingerprint( - &unauthorized.canonical_bytes().unwrap(), - ); + unauthorized.fingerprint = + distributed::application::sha256_fingerprint(&unauthorized.canonical_bytes().unwrap()); let error = Application::try_new("unauthorized-command", [module], [unauthorized]) .expect_err("unauthorized selected command must fail closed"); - assert!(error.to_string().contains("exact authorized command closure")); + assert!(error + .to_string() + .contains("exact authorized command closure")); let role_surface = surface_for_role(&catalog, "user", &user_grants).unwrap(); let role_valid = SurfaceSpec::from_surface("user", &role_surface).unwrap(); @@ -280,28 +302,27 @@ fn role_and_application_command_closure_rejects_missing_unauthorized_and_tampere role_unauthorized .commands .sort_by(|left, right| left.id.cmp(&right.id)); - role_unauthorized - .contract["commands"] + role_unauthorized.contract["commands"] .as_array_mut() .expect("surface command contract array") .push(forbidden_contract); - role_unauthorized.fingerprint = distributed::application::sha256_fingerprint( - &role_unauthorized.canonical_bytes().unwrap(), - ); + role_unauthorized.fingerprint = + distributed::application::sha256_fingerprint(&role_unauthorized.canonical_bytes().unwrap()); let error = Application::try_new( "unauthorized-role-command", [command_module()], [role_unauthorized], ) .expect_err("unauthorized role command must fail closed"); - assert!(error.to_string().contains("exact authorized command closure")); + assert!(error + .to_string() + .contains("exact authorized command closure")); let mut role_tampered = role_valid.clone(); role_tampered.commands[0].roles = vec!["admin".into()]; role_tampered.contract["commands"][0]["roles"] = serde_json::json!(["admin"]); - role_tampered.fingerprint = distributed::application::sha256_fingerprint( - &role_tampered.canonical_bytes().unwrap(), - ); + role_tampered.fingerprint = + distributed::application::sha256_fingerprint(&role_tampered.canonical_bytes().unwrap()); let error = Application::try_new("tampered-role-command", [command_module()], [role_tampered]) .expect_err("tampered role command must fail closed"); assert!(error.to_string().contains("not compatible")); @@ -309,12 +330,13 @@ fn role_and_application_command_closure_rejects_missing_unauthorized_and_tampere let mut role_missing = role_valid; role_missing.commands.clear(); role_missing.contract["commands"] = serde_json::json!([]); - role_missing.fingerprint = distributed::application::sha256_fingerprint( - &role_missing.canonical_bytes().unwrap(), - ); + role_missing.fingerprint = + distributed::application::sha256_fingerprint(&role_missing.canonical_bytes().unwrap()); let error = Application::try_new("missing-role-command", [command_module()], [role_missing]) .expect_err("role command closure must fail closed"); - assert!(error.to_string().contains("exact authorized command closure")); + assert!(error + .to_string() + .contains("exact authorized command closure")); } #[test] @@ -329,6 +351,87 @@ fn module_identity_is_identical_across_full_and_split_selection() { ); } +#[test] +fn normalized_manifest_fits_without_serializing_duplicate_model_inventory() { + use distributed::application::{ModelFieldSpec, ModelSpec, MAX_APPLICATION_MANIFEST_BYTES}; + let models = (0..64).map(|index| { + ModelSpec::try_new( + format!("Catalog{index:03}"), + format!("catalog_{index:03}"), + (0..400).map(|field| ModelFieldSpec { + name: format!("field_{field:03}_with_a_descriptive_domain_attribute_name"), + scalar: "String".into(), + nullable: false, + }), + ["field_000_with_a_descriptive_domain_attribute_name"], + ) + .unwrap() + }); + let module = Module::new("catalog").models(models).build().unwrap(); + let manifest = ApplicationManifest::try_from_modules("catalog-app", [module], []).unwrap(); + let bytes = manifest.canonical_bytes().unwrap(); + assert!(bytes.len() < MAX_APPLICATION_MANIFEST_BYTES); + let mut redundant = serde_json::from_slice::(&bytes).unwrap(); + redundant["models"] = serde_json::to_value(&manifest.models).unwrap(); + let redundant_bytes = serde_json::to_vec(&redundant).unwrap(); + assert!(redundant_bytes.len() > MAX_APPLICATION_MANIFEST_BYTES); + assert!(ApplicationManifest::from_canonical_bytes(&redundant_bytes).is_err()); + assert_eq!( + ApplicationManifest::from_canonical_bytes(&bytes).unwrap(), + manifest + ); +} + +#[test] +fn manifest_reconstruction_rejects_excessive_inventory_before_cloning() { + let mut manifest = + ApplicationManifest::try_from_modules("app", [command_module()], []).unwrap(); + let mut wire = serde_json::to_value(&manifest).unwrap(); + let command = wire["modules"][0]["commands"][0].clone(); + wire["modules"][0]["commands"] = serde_json::Value::Array(vec![ + command; + distributed::application::MAX_MANIFEST_COLLECTION_ITEMS + + 1 + ]); + let error = serde_json::from_value::(wire).unwrap_err(); + assert!(error.to_string().contains("derived commands"), "{error}"); + manifest.modules[0].commands = vec![ + manifest.commands[0].clone(); + distributed::application::MAX_MANIFEST_COLLECTION_ITEMS + 1 + ]; + let error = manifest.canonical_bytes().unwrap_err(); + assert!(error.to_string().contains("derived commands"), "{error}"); +} + +#[test] +fn manifest_wire_derives_inventories_without_accepting_a_second_authority() { + let manifest = + ApplicationManifest::try_from_modules("normalized-app", [command_module()], []).unwrap(); + let bytes = manifest.canonical_bytes().unwrap(); + let wire: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(wire["schema_version"], 2); + for field in ["commands", "events", "models", "projections"] { + assert!( + wire.get(field).is_none(), + "{field} is derived, not wire authority" + ); + let mut redundant = wire.clone(); + redundant[field] = serde_json::json!([]); + assert!(serde_json::from_value::(redundant).is_err()); + } + assert_eq!( + ApplicationManifest::from_canonical_bytes(&bytes).unwrap(), + manifest + ); + let mut inconsistent = manifest.clone(); + inconsistent.commands.clear(); + assert!(inconsistent.canonical_bytes().is_err()); + + let mut old = wire; + old["schema_version"] = serde_json::json!(1); + assert!(ApplicationManifest::from_canonical_bytes(&serde_json::to_vec(&old).unwrap()).is_err()); +} + #[test] fn application_manifest_is_byte_deterministic_and_contains_no_executable_data() { let spec = command("todo.create"); @@ -383,14 +486,19 @@ fn application_manifest_is_byte_deterministic_and_contains_no_executable_data() .unwrap(); assert_eq!(ui["value"]["literal_url"], "https://domain.example/view"); assert_eq!(ui["value"]["literal_path"], "orders/today"); - assert!(!String::from_utf8(first.clone()).unwrap().contains("handler")); + assert!(!String::from_utf8(first.clone()) + .unwrap() + .contains("handler")); assert!(!String::from_utf8(first.clone()) .unwrap() .contains("application_composition")); assert!(ApplicationManifest::from_canonical_bytes(&first).is_ok()); let mut missing_version = value.clone(); - missing_version.as_object_mut().unwrap().remove("schema_version"); + missing_version + .as_object_mut() + .unwrap() + .remove("schema_version"); let missing_version = serde_json::to_vec(&missing_version).unwrap(); assert!(ApplicationManifest::from_canonical_bytes(&missing_version).is_err()); @@ -417,7 +525,13 @@ fn application_manifest_is_byte_deterministic_and_contains_no_executable_data() .to_string() .contains("does not match compiling framework")); - for field in ["tables", "services", "endpoints", "transport", "observability"] { + for field in [ + "tables", + "services", + "endpoints", + "transport", + "observability", + ] { let mut legacy_owner = value.clone(); legacy_owner[field] = serde_json::json!([]); let legacy_owner = serde_json::to_vec(&legacy_owner).unwrap(); @@ -447,23 +561,20 @@ fn manifest_provenance_separates_logical_and_artifact_identity() { assert_ne!(first.fingerprint().unwrap(), second.fingerprint().unwrap()); let decoded = ApplicationManifest::from_canonical_bytes(&first.canonical_bytes().unwrap()).unwrap(); - assert_eq!(decoded.provenance.source_revision.as_deref(), Some("git:one")); - assert!( - String::from_utf8(first.canonical_bytes().unwrap()) - .unwrap() - .contains("git:one") + assert_eq!( + decoded.provenance.source_revision.as_deref(), + Some("git:one") ); + assert!(String::from_utf8(first.canonical_bytes().unwrap()) + .unwrap() + .contains("git:one")); } #[test] fn contract_compiler_pins_manifest_sdl_and_client_to_one_surface() { let selected = selected_surface(); - let compiler = ContractCompiler::from_surface( - "contract-only", - "web", - Arc::new(selected.clone()), - ) - .unwrap(); + let compiler = + ContractCompiler::from_surface("contract-only", "web", Arc::new(selected.clone())).unwrap(); let manifest = compiler.manifest().unwrap(); let sdl = compiler.graphql_sdl().unwrap(); let client = compiler.client_manifest().unwrap(); @@ -471,7 +582,10 @@ fn contract_compiler_pins_manifest_sdl_and_client_to_one_surface() { assert!(sdl.contains("TodoView")); assert_eq!(manifest.surfaces.len(), 1); assert_eq!(manifest.surfaces[0].selection, "application:web"); - assert!(client.models.iter().any(|model| model.typename == "TodoView")); + assert!(client + .models + .iter() + .any(|model| model.typename == "TodoView")); assert!(ContractCompiler::new("contract-only") .with_surface("web", Arc::new(selected)) .unwrap() @@ -506,7 +620,9 @@ fn explicit_definition_mount_identity_and_missing_pairing_fail_closed() { let other = command("todo.other"); let mount = CommandMount::contract(other); let error = CommandDefinition::with_mount(spec, mount).unwrap_err(); - assert!(error.to_string().contains("definition and executable mount")); + assert!(error + .to_string() + .contains("definition and executable mount")); let duplicate = Module::new("todo") .command_definitions([definition("todo.create"), definition("todo.create")]) @@ -526,7 +642,7 @@ fn nested_fingerprints_and_projection_references_are_fail_closed() { let application = application_with_commands(&["todo.create"]); let bytes = application.manifest().canonical_bytes().unwrap(); let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - value["commands"][0]["fingerprint"] = serde_json::json!(""); + value["modules"][0]["commands"][0]["fingerprint"] = serde_json::json!(""); let malformed: ApplicationManifest = serde_json::from_value(value) .expect("the malformed value should still be structurally deserializable"); assert!(malformed.clone().refresh_fingerprints().is_err()); @@ -535,17 +651,12 @@ fn nested_fingerprints_and_projection_references_are_fail_closed() { ) .is_err()); - let mut projection = ProjectionSpec::try_new( - "todo.list", - std::iter::empty::(), - ["TodoView"], - ) - .unwrap(); + let mut projection = + ProjectionSpec::try_new("todo.list", std::iter::empty::(), ["TodoView"]).unwrap(); projection.dependencies.push("projection:missing".into()); projection.dependencies.sort(); - projection.fingerprint = distributed::application::sha256_fingerprint( - &projection.canonical_bytes().unwrap(), - ); + projection.fingerprint = + distributed::application::sha256_fingerprint(&projection.canonical_bytes().unwrap()); let module = Module::new("todo") .surface( distributed::application::SurfaceSpec::from_surface("web", &selected_surface()) @@ -561,7 +672,8 @@ fn nested_fingerprints_and_projection_references_are_fail_closed() { #[test] fn module_commands_only_keeps_command_identity() { - let projection = ProjectionSpec::try_new("project_todos", ["todo.created"], ["TodoView"]).unwrap(); + let projection = + ProjectionSpec::try_new("project_todos", ["todo.created"], ["TodoView"]).unwrap(); let module = Module::new("todo") .command_definitions([definition("todo.create")]) .projection(projection) @@ -641,8 +753,8 @@ fn runtime_rejects_atomic_with_unrelated_direct_projection() { #[test] fn runtime_from_database_url_rejects_unsupported_schemes() { - let error = Runtime::from_database_url("mysql://localhost/app") - .expect_err("unsupported scheme"); + let error = + Runtime::from_database_url("mysql://localhost/app").expect_err("unsupported scheme"); assert!(error.to_string().contains("unsupported"), "{error}"); } @@ -700,14 +812,9 @@ fn contract_export_prunes_unselected_read_models() { ("ChatView".to_string(), RoleGrant::all_columns()), ]), )]); - let selected = surface_for_application_contract( - &full, - "web", - &["user".into()], - &["user".into()], - &grants, - ) - .unwrap(); + let selected = + surface_for_application_contract(&full, "web", &["user".into()], &["user".into()], &grants) + .unwrap(); let export = DistributedClientSurfaceExport::from_contract("todo-app", selected).unwrap(); let todos_only = prune_client_manifest(export.manifest().unwrap(), ["TodoView"]).unwrap(); assert!(todos_only @@ -754,7 +861,10 @@ fn prune_drops_unselected_command_optimism_and_causal_slots() { attach_todo_and_chat_command_optimism(&mut manifest); let before = serde_json::to_string(&manifest).unwrap(); - assert!(before.contains("ChatView"), "setup must include ChatView optimism"); + assert!( + before.contains("ChatView"), + "setup must include ChatView optimism" + ); assert!(before.contains("TodoView")); let todos_only = prune_client_manifest(manifest, ["TodoView"]).unwrap(); @@ -798,7 +908,9 @@ fn prune_drops_unselected_command_optimism_and_causal_slots() { } } -fn attach_todo_and_chat_command_optimism(manifest: &mut distributed::graphql::DistributedClientManifest) { +fn attach_todo_and_chat_command_optimism( + manifest: &mut distributed::graphql::DistributedClientManifest, +) { let todo_event = ClientProjectionEventRef { id: "todo.completed".into(), name: "todo.completed".into(), @@ -809,14 +921,16 @@ fn attach_todo_and_chat_command_optimism(manifest: &mut distributed::graphql::Di name: "chat.posted".into(), version: 1, }; - manifest.projectors.push(distributed::graphql::ClientProjector { - version: 1, - name: "project_mixed".into(), - facts: vec!["todo.completed".into(), "chat.posted".into()], - models: vec!["TodoView".into(), "ChatView".into()], - dependencies: Vec::new(), - causal_confirmation: false, - }); + manifest + .projectors + .push(distributed::graphql::ClientProjector { + version: 1, + name: "project_mixed".into(), + facts: vec!["todo.completed".into(), "chat.posted".into()], + models: vec!["TodoView".into(), "ChatView".into()], + dependencies: Vec::new(), + causal_confirmation: false, + }); manifest.projection_programs.push(ClientProjectionProgram { version: 2, program_id: "project_todos".into(), @@ -928,10 +1042,7 @@ fn explicit_dispatch_route_map() { .unwrap() .graphql() .dispatch_route("todo.*", "http://commands"); - assert_eq!( - runtime.route_for("todo.create"), - Some("http://commands") - ); + assert_eq!(runtime.route_for("todo.create"), Some("http://commands")); assert!(runtime.starts_graphql()); assert!(!runtime.starts_outbox()); } @@ -942,9 +1053,8 @@ fn application_surface_must_expose_a_schema_role() { distributed::application::SurfaceSpec::from_surface("web", &selected_surface()).unwrap(); surface.schema_roles.clear(); surface.contract["selection"]["schema_roles"] = serde_json::json!([]); - surface.fingerprint = distributed::application::sha256_fingerprint( - &surface.canonical_bytes().unwrap(), - ); + surface.fingerprint = + distributed::application::sha256_fingerprint(&surface.canonical_bytes().unwrap()); let error = Application::try_new("role-owner", [], [surface]) .expect_err("application surfaces without schema roles must fail closed"); assert!(error.to_string().contains("schema role")); diff --git a/tests/application_plans.rs b/tests/application_plans.rs index 258f92bc3..704f9f8c9 100644 --- a/tests/application_plans.rs +++ b/tests/application_plans.rs @@ -5,7 +5,7 @@ use distributed::application::{ CommandTypeSpec, DeploymentPlan, ModelFieldSpec, ModelSpec, Module, MountSelector, ProcessIntent, ProcessPreset, ProjectionSpec, }; -use distributed::graphql::CommandConsistency; +use distributed::command::CommandConsistency; fn portable_command(id: &str, consistency: CommandConsistency) -> CommandSpec { let command = CommandSpec::try_new( @@ -213,12 +213,10 @@ fn atomic_separation_fails_and_eventual_split_succeeds() { let collocated = compile_deployment_plan( "atomic-local", &manifest, - [ProcessIntent::new("writer") - .unwrap() - .mounts([ - MountSelector::command("todo.force").unwrap(), - MountSelector::projector("project_todos_direct").unwrap(), - ])], + [ProcessIntent::new("writer").unwrap().mounts([ + MountSelector::command("todo.force").unwrap(), + MountSelector::projector("project_todos_direct").unwrap(), + ])], ); assert!(collocated.is_ok(), "{collocated:?}"); } diff --git a/tests/causal_public_invoke/main.rs b/tests/causal_public_invoke/main.rs index 40ab8ff24..671ca8399 100644 --- a/tests/causal_public_invoke/main.rs +++ b/tests/causal_public_invoke/main.rs @@ -5,10 +5,10 @@ #![cfg(feature = "graphql")] -use distributed::graphql::{ - typed_command, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, Succeeded, - VerifiedPrincipal, +use distributed::command::{ + typed_command, CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField, Succeeded, }; +use distributed::graphql::VerifiedPrincipal; use distributed::microsvc::{Routes, Service, Session, USER_ID_KEY}; use distributed::{Aggregate, AggregateBuilder, Entity, InMemoryRepository, Snapshot}; use serde::{Deserialize, Serialize}; @@ -51,11 +51,11 @@ struct CompleteInput { id: String, } -impl GraphqlInputType for CompleteInput { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandInputType for CompleteInput { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "CompleteInput", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -73,11 +73,11 @@ struct CompletePayload { id: String, } -impl GraphqlOutputType for CompletePayload { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandOutputType for CompletePayload { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "CompletePayload", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -94,7 +94,9 @@ impl GraphqlOutputType for CompletePayload { 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")) + .typed_command(typed_command::>( + "todo.create", + )) .create() .invoke(|aggregate, input, _owner| { aggregate.record(input.id.clone())?; @@ -103,9 +105,9 @@ async fn public_causal_invoke_returns_receipt_without_sqlx_or_celld() { .succeeded(|aggregate| CompletePayload { id: aggregate.entity().id().to_string(), }) - .typed_command( - typed_command::>("todo.complete"), - ) + .typed_command(typed_command::>( + "todo.complete", + )) .load_by(|input: &CompleteInput| input.id.clone()) .invoke(|aggregate, input, _owner| { aggregate.record(input.id.clone())?; diff --git a/tests/causal_wait_path/main.rs b/tests/causal_wait_path/main.rs index 90b09650d..80875069a 100644 --- a/tests/causal_wait_path/main.rs +++ b/tests/causal_wait_path/main.rs @@ -5,11 +5,11 @@ use std::sync::Arc; use distributed::bus::{Bus, BusConsumer, InMemoryBus, TransportError}; use distributed::cell_host::InternalHttpSecret; +use distributed::command::{ + typed_command, CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField, Succeeded, +}; 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}; @@ -52,11 +52,11 @@ struct IdInput { id: String, } -impl GraphqlInputType for IdInput { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandInputType for IdInput { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "IdInput", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -74,11 +74,11 @@ struct IdPayload { id: String, } -impl GraphqlOutputType for IdPayload { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandOutputType for IdPayload { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "IdPayload", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, diff --git a/tests/celld/README.md b/tests/celld/README.md index 06b54c4cf..83cafaf8a 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -8,41 +8,66 @@ command -> AggregateCell -> domain event -> same-cell outbox ``` The aggregate Worker owns one SQLite Durable Object per Todo or Chat shard. -`AggregateCell::durable_state` assembles the event log, command ledger, -snapshot/sealed state, and outbox into one versioned envelope. The Worker saves -that envelope with one SQLite upsert, so the aggregate mutation and its outbox -are one cell commit. The host attaches `CelldOutbox::from_env(&env, "OUTBOX")` -to the `AggregateCell`, then `persist_and_drain_outbox` owns the complete -persist, watchdog, Queue dispatch, settlement persistence, and alarm-rearming -lifecycle. celld's output gate holds Queue egress until the cell write is -durable. If settlement persistence is interrupted, the same stable event id may -be delivered again; consumers must deduplicate by that id. +The same SQL operations used by native repositories append event rows, maintain +snapshots, fence command receipts and enqueue pending delivery rows. Each +command commits those participants inside one `storage.transactionSync`. +There is no whole-cell JSON value, authoritative in-memory shadow, or host +persistence callback. ```rust,ignore -let cell = AggregateCell::::new_with_snapshots(shard, 1)? +let cell = AggregateCell::::from_state_with_snapshots(state, 100)? .mount(create()) .mount(complete()) .with_celld_outbox(CelldOutbox::from_env(&env, "OUTBOX")?); -let drain = cell.persist_and_drain_outbox(&env, &storage, |state| { - persist_cell_state(&sql, state) -}) -.await?; -for error in drain.deferred { - worker::console_error!("outbox drain deferred: {error}"); +// Dispatch arms the durable watchdog before invoking the command. +let result = cell.dispatch_idempotent(command, &identity, input, session).await?; + +// Optional immediate delivery after commit. A drain error is diagnostic here: +// it cannot turn an already committed command into a rejection. +match cell.drain_outbox(&env).await { + Ok(drain) => { + for error in drain.deferred { + worker::console_error!("outbox drain deferred: {error}"); + } + } + Err(error) => worker::console_error!("outbox drain deferred: {error}"), } + +// The Durable Object alarm handler also calls drain_outbox(&env). ``` -The same `persist_and_drain_outbox` call runs after a command and from the -Durable Object alarm. It persists the full state before Queue egress, arms the -watchdog before publishing, persists any settlements even when a later store -operation errors, and clears the alarm only when no retryable rows remain. -Failure to persist that first state or arm its watchdog rejects the request. -After both are established, the command is durably accepted: Queue, settlement, -or later alarm-operation failures are returned in -`CelldOutboxDrainOutcome::deferred` for diagnostics while the already-armed -watchdog owns retry. Released Queue outcomes likewise stay pending. Neither can -turn an already committed command into an HTTP error. +The Queue lives in another cell: its acceptance is **not** part of the +aggregate's SQLite transaction. celld's output gate orders Queue egress after +the aggregate write becomes durable. The prearmed alarm covers the gap between +that commit and Queue acceptance, including process loss without another request. + +A drain claims rows, sends them, and deletes each matching lease-fenced row +after acceptance. A crash after acceptance but before deletion can deliver the +same stable event ID again; consumers must deduplicate. Queue publication or +settlement failures retain pending/in-flight work for the watchdog. An alarm +keeps a wake scheduled while commands are running, including suspended commands. +An empty drain never deletes another command's alarm; the last scheduled wake +simply finds no work and stops. + +Snapshots use the ordinary repository cache validation, upcasting and tail +loader on both native and cell command paths. A valid snapshot avoids loading +old event payloads; an unusable cache rebuilds from the authoritative events. + +### Breaking storage and host API change + +Workers now open cells with `AggregateCell::from_state` or +`from_state_with_snapshots`, and use `drain_outbox` from their alarm handler. +The whole-state export/restore and `persist_and_drain_outbox` APIs are not +available on the Worker host. Native in-process conformance fixtures are not a +production persistence adapter. + +Existing `cell_state` databases are rejected with an explicit migration-required +error. They are not silently reset or opened through a legacy adapter. Back up +retained development data and migrate it explicitly before changing an existing +fleet; creating a fresh test namespace is appropriate only for disposable data. +The SQL migration inventory and checksums also reject incompatible or newer +schemas. Queue is intentionally not modeled as a full Distributed `Bus`: it has one consumer and no fanout. `CelldQueueRelay` is generic over `MessagePublisher`, @@ -52,6 +77,19 @@ The e2e-celld host wires that one route to `BusPublisher`. Kafka, RabbitMQ, and Knative use the same Queue consumer and relay contract; only the native `BusPublisher` changes. +## Command retry receipts + +The wait-path response gets its projection inputs from +`CellDispatchResult::projection_events()`, stored atomically with the command's +terminal retry receipt. It does not scan the outbox. A retry therefore returns +the same result and projection inputs even when delivery has removed the outbox +rows. Receipt evidence follows the existing command replay retention; event +history belongs in the event store, not in delivery records. + +This changes the internal successful cell replay payload. Previously persisted +successful receipts are not silently accepted as the new format. The public +HTTP response shape is unchanged. + ## Queue naming and sharding The producer binding name is local to the Worker and may be any valid binding @@ -120,6 +158,27 @@ and consumer deployments as separate scripts without changing the relay code. Without `CELLD_URL`, `cargo test --test celld` checks the fixtures and skips the live HTTP round trips. +The independent storage proof owns a temporary celld fleet and deliberately +kills only the processes it starts: + +```sh +(cd tests/celld/worker && worker-build --release --features storage-conformance) +node tests/celld/storage-conformance.mjs +``` + +It requires Node.js, sqlite3, esbuild and celld in addition to the Worker +toolchain. It tests final-write rollback across multi-chunk event/outbox inserts +(the Worker uses at most 100 bound parameters per SQL statement), lease fencing during commit, +Queue-acceptance/delete crash redelivery with stable IDs, prearmed-alarm +recovery without another cell request, and receipt replay after delivery. It +also retains 1,101 unsent messages and more than 8 MiB of event payload in one +snapshot-backed cell, restarts it, and verifies that alarms drain the backlog +without another aggregate request. Events and receipts remain; delivered +outbox rows do not. +It prints and retains its temporary artifact directory. Fault probes are +feature-gated out of ordinary Worker builds. CI runs this proof separately +from the full Queue/NATS/browser profile. + Every non-health aggregate route requires `DISTRIBUTED_INTERNAL_SECRET`. The checked-in value is loopback test data only; production requires a separately provisioned secret plus the normal network policy. diff --git a/tests/celld/main.rs b/tests/celld/main.rs index 4ef7abdc1..4f1af64dd 100644 --- a/tests/celld/main.rs +++ b/tests/celld/main.rs @@ -76,14 +76,15 @@ fn worker_declares_sqlite_todo_and_chat_cells() { 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("CREATE TABLE IF NOT EXISTS cell_state")); - assert!(source.contains("durable_state")); - assert!(source.contains("restore_durable_state")); - assert!(source.contains("ON CONFLICT(id) DO UPDATE SET body = excluded.body")); + assert!(!source.contains("CREATE TABLE IF NOT EXISTS cell_state")); + assert!(!source.contains("durable_state")); + assert!(!source.contains("restore_durable_state")); + assert!(!source.contains("ON CONFLICT(id) DO UPDATE SET body = excluded.body")); assert!(source.contains("dispatch_idempotent")); assert!(source.contains("CelldOutbox::from_env(&env, \"OUTBOX\")")); assert!(source.contains("with_celld_outbox(outbox)")); - assert!(source.contains("persist_and_drain_outbox")); + assert!(source.contains(".drain_outbox(env)")); + assert!(!source.contains("persist_and_drain_outbox")); assert!(!source.contains("CelldQueuePublisher::from_env")); assert!(!source.contains("drain_outbox_to_queue")); assert!(!source.contains("arm_drain_alarm")); @@ -91,15 +92,15 @@ fn worker_declares_sqlite_todo_and_chat_cells() { !source.contains("outcome.released") && !source.contains("outcome.failed"), "retryable Queue outcomes must stay alarm-owned, not fail a committed command" ); - assert!(source.contains("cell_projection_event_evidence")); + assert!(source.contains("dispatch.projection_events()")); assert!(source.contains("\"events\": events")); assert!(!source.contains("CellOutboxWireItem")); assert!(!source.contains("restore_durable_commands")); - assert!(source.contains("sealed_row")); - assert!(source.contains("new_with_snapshots")); + assert!(!source.contains("sealed_row")); + assert!(source.contains("from_state_with_snapshots")); assert!(!source.contains("restore_durable_events")); assert!(!source.contains("restore_durable_snapshots")); - assert!(source.contains("restore_cell_state")); + assert!(!source.contains("restore_cell_state")); } #[test] diff --git a/tests/celld/storage-conformance.mjs b/tests/celld/storage-conformance.mjs new file mode 100644 index 000000000..b13bbab38 --- /dev/null +++ b/tests/celld/storage-conformance.mjs @@ -0,0 +1,261 @@ +// Build the fixture with worker-build --release --features storage-conformance. +// Owns an isolated celld process group and temporary object store. It never +// restarts a caller's fleet or edits its data; artifacts are retained on failure. +import assert from "node:assert/strict"; +import { spawn, execFileSync } from "node:child_process"; +import { once } from "node:events"; +import { createWriteStream } from "node:fs"; +import { access, cp, mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { createServer } from "node:net"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +const here = dirname(fileURLToPath(import.meta.url)); +const artifacts = await mkdtemp(join(tmpdir(), "distributed-cell-storage-")); +const project = join(artifacts, "worker"); +await mkdir(project); +await cp(join(here, "worker/build"), join(project, "build"), { recursive: true }); +const config = JSON.parse(await readFile(join(here, "worker/wrangler.jsonc"), "utf8")); +await writeFile(join(project, "wrangler.jsonc"), JSON.stringify(config, null, 2)); +const server = createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const port = server.address().port; +await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); +const base = `http://127.0.0.1:${port}`; +const headers = { + "content-type": "application/json", + "x-distributed-internal-secret": config.vars.DISTRIBUTED_INTERNAL_SECRET, + "x-distributed-service-id": "cell-storage-conformance", + "x-distributed-principal-partition": "alice", + "x-user-id": "alice", + "x-roles": "user", +}; +let child; +let generation = 0; +let commandSequence = 0; +const results = []; +const commandId = () => `0190a000-0000-7000-8000-${String(++commandSequence).padStart(12, "0")}`; +async function until(description, predicate, timeout = 70_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const value = await predicate(); + if (value) return value; + if (child?.exitCode !== null && child?.exitCode !== undefined) throw new Error("celld exited"); + await delay(200); + } + throw new Error(`timed out: ${description}; artifacts: ${project}`); +} +async function start() { + const log = createWriteStream(join(artifacts, `celld-${++generation}.log`)); + child = spawn("celld", ["dev", project, "--host", "127.0.0.1", "--port", String(port), "--logs"], { + detached: true, + env: { ...process.env, RUST_LOG: "warn" }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.pipe(log, { end: false }); + child.stderr.pipe(log, { end: false }); + child.once("close", () => log.end()); + child.once("error", error => console.error(error)); + await until("isolated Worker readiness", async () => { + try { return (await fetch(base + "/health", { signal: AbortSignal.timeout(1000) })).ok; } + catch { return false; } + }); +} +async function crash() { + const owned = child; + child = undefined; + if (!owned || owned.exitCode !== null) return; + const exited = once(owned, "close"); + // celld dev creates a separate process group for its runtime child. Freeze + // our launcher while resolving its exact descendants so it cannot respawn. + process.kill(owned.pid, "SIGSTOP"); + const processes = execFileSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" }) + .trim().split("\n").map(line => line.trim().split(/\s+/).map(Number)); + const descendants = []; + function visit(parent) { + for (const [pid, ppid] of processes) if (ppid === parent) { + visit(pid); + descendants.push(pid); + } + } + visit(owned.pid); + for (const pid of [...descendants, owned.pid]) { + try { process.kill(pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + await exited; +} +async function post(id, path, body, extraHeaders = {}) { + return fetch(`${base}/todo/${id}/${path}`, { + method: "POST", headers: { ...headers, ...extraHeaders }, + body: JSON.stringify(body), signal: AbortSignal.timeout(20_000), + }); +} +async function probe(id, operation) { + const response = await post(id, "__storage_test", { operation }); + assert.equal(response.status, 200, `${operation}: ${await response.clone().text()}`); + return operation === "inspect" ? response.json() : response.text(); +} +async function command(id, name, input, command = commandId(), extraHeaders = {}) { + const response = await post(id, name, { commandId: command, input }, extraHeaders); + const body = await response.json(); + assert.equal(response.status, name === "todo.create" ? 201 : 200, JSON.stringify(body)); + return body; +} +async function queueRows(countOnly = false) { + const runtime = join(project, ".celld/dev/runtime"); + try { + const queue = (await readdir(runtime)).find(name => name.startsWith("__Queue:")); + if (!queue) return []; + const ltx = join(runtime, queue, "ltx"); + const epochs = (await readdir(ltx)).filter(name => /^e[0-9]+$/.test(name)) + .sort((a, b) => Number(b.slice(1)) - Number(a.slice(1))); + if (!epochs.length) return []; + const database = join(ltx, epochs[0], "db.sqlite"); + // Recovery creates the epoch directory before materializing its database. + // Observe only the newest epoch; never mistake a stale epoch for delivery. + await access(database); + let rows; + try { + rows = execFileSync("sqlite3", ["-readonly", "-json", database, + countOnly ? "SELECT COUNT(*) AS count FROM __queue_messages" : + "SELECT seq, hex(body) AS body FROM __queue_messages ORDER BY seq"], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 }); + } catch (error) { + // A retired epoch may disappear between discovery and the read. + // Missing files are retryable observations; SQL errors are not. + await access(database); + throw error; + } + return rows.trim() ? JSON.parse(rows) : []; + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } +} +async function record(name, evidence) { + results.push({ name, evidence }); + console.log(JSON.stringify({ name, evidence })); + await writeFile(join(artifacts, "results.json"), JSON.stringify(results, null, 2)); +} + +try { + console.log(`Storage proof artifacts: ${artifacts}`); + await start(); + for (const fault of ["fail-completion", "expire-at-commit"]) { + const id = fault; + await probe(id, fault); + const cid = commandId(); + const failed = await post(id, "todo.create", { commandId: cid, input: { title: fault } }); + assert.ok(failed.status >= 400, await failed.text()); + const rollback = (await probe(id, "inspect")).counts; + assert.equal(rollback.events, 0); + assert.equal(rollback.snapshots, 0); + assert.equal(rollback.completed, 0); + assert.equal(rollback.outbox, 0); + await probe(id, "clear-faults"); + await command(id, "todo.create", { title: fault }, cid); + const recovered = (await probe(id, "inspect")).counts; + assert.equal(recovered.events, 1); + assert.equal(recovered.snapshots, 1); + assert.equal(recovered.completed, 1); + await record(fault, { rollback, recovered }); + } + + // One command crosses both event and outbox SQL insert chunk boundaries. + await command("batch", "todo.create", { title: "batch" }); + const batchId = commandId(); + await probe("batch", "fail-completion"); + const failedBatch = await post("batch", "todo.test_batch", { + commandId: batchId, input: { title: "batch" }, + }); + assert.ok(failedBatch.status >= 400, await failedBatch.text()); + const batchRollback = (await probe("batch", "inspect")).counts; + assert.equal(batchRollback.events, 1); + assert.equal(batchRollback.snapshotVersion, 1); + assert.equal(batchRollback.completed, 1); + assert.equal(batchRollback.outbox, 0); + await probe("batch", "clear-faults"); + const batchResult = await command("batch", "todo.test_batch", { title: "batch" }, batchId); + assert.equal(batchResult.events.length, 32); + const batchCommit = (await probe("batch", "inspect")).counts; + assert.equal(batchCommit.events, 33); + assert.equal(batchCommit.snapshotVersion, 33); + assert.equal(batchCommit.completed, 2); + assert.equal(batchCommit.outbox, 0); + await record("multi-chunk atomic command", { batchRollback, batchCommit }); + + // The acceptance/delete gap: the test Worker clears its fault on activation. + // Only the retained lease and alarm may cause the duplicate send. + const before = (await queueRows()).length; + await probe("settlement-crash", "fail-settlement"); + const cid = commandId(); + const original = await command("settlement-crash", "todo.create", { title: "delivery proof" }, cid); + const accepted = await queueRows(); + assert.equal(accepted.length, before + 1); + const claimed = await probe("settlement-crash", "inspect"); + assert.equal(claimed.outbox[0].status, "in_flight"); + await crash(); + await start(); + const retried = await until("alarm redelivers after acceptance/delete crash, without a cell request", async () => { + const rows = await queueRows(); + return rows.length >= before + 2 && rows; + }); + assert.equal(retried[before].body, retried[before + 1].body, "stable delivery envelope survives restart"); + const replay = await command("settlement-crash", "todo.create", { title: "delivery proof" }, cid); + assert.equal(replay.receipt.replayed, true); + assert.deepEqual(replay.payload, original.payload); + assert.deepEqual(replay.events, original.events); + const settled = (await probe("settlement-crash", "inspect")).counts; + assert.equal(settled.outbox, 0); + assert.equal(settled.events, 1); + await record("accepted-before-delete restart", settled); + + const beforeDeferred = (await queueRows()).length; + await command("commit-crash", "todo.create", { title: "watchdog proof" }, commandId(), { + "x-distributed-test-defer-drain": "1", + }); + await crash(); + await start(); + await until("prearmed alarm publishes a committed row without another cell request", async () => + (await queueRows()).length > beforeDeferred); + await record("committed-before-send restart", (await probe("commit-crash", "inspect")).counts); + + // Grow event history AND an unsent outbox beyond the former single-value + // ceiling. Claim failures cannot reject an already committed command. + const beforeGrowth = (await queueRows(true))[0].count; + await probe("growth", "fail-claim"); + const payload = "x".repeat(8192); + const firstId = commandId(); + const first = await command("growth", "todo.create", { title: "0 " + payload }, firstId); + for (let i = 1; i <= 1100; i++) { + await command("growth", "todo.rename", { title: i + " " + payload }); + if (i % 100 === 0) console.log(`Growth proof: ${i} appended commands`); + } + const grown = (await probe("growth", "inspect")).counts; + assert.equal(grown.events, 1101); + assert.ok(grown.eventBytes > 8 * 1024 * 1024); + assert.equal(grown.snapshotVersion, 1101); + assert.equal(grown.snapshots, 1); + assert.equal(grown.completed, 1101); + assert.equal(grown.outbox, 1101); + assert.equal(grown.wholeStateTables, 0); + await crash(); + await start(); + await until("large pending outbox drains from alarms without a cell request", async () => + (await queueRows(true))[0]?.count >= beforeGrowth + 1101, 120_000); + const oldRetry = await command("growth", "todo.create", { title: "0 " + payload }, firstId); + assert.equal(oldRetry.receipt.replayed, true); + assert.deepEqual(oldRetry.events, first.events); + await command("growth", "todo.rename", { title: "after restart" }); + const afterRestart = (await probe("growth", "inspect")).counts; + assert.equal(afterRestart.events, 1102); + assert.equal(afterRestart.snapshotVersion, 1102); + assert.equal(afterRestart.outbox, 0); + await record("growth and snapshot-tail restart", { grown, afterRestart }); +} finally { + await crash(); + console.log(`Retained storage proof artifacts: ${artifacts}`); +} diff --git a/tests/celld/worker/Cargo.toml b/tests/celld/worker/Cargo.toml index c928ead9d..582f25024 100644 --- a/tests/celld/worker/Cargo.toml +++ b/tests/celld/worker/Cargo.toml @@ -12,6 +12,10 @@ description = "workers-rs Todo and Chat cells: AggregateCell + domain handles" [lib] crate-type = ["cdylib"] +[features] +# Only the isolated crash/rollback harness builds these authenticated probes. +storage-conformance = [] + [dependencies] console_error_panic_hook = "0.1" distributed = { path = "../../..", default-features = false, features = ["workers-rs"] } diff --git a/tests/celld/worker/src/lib.rs b/tests/celld/worker/src/lib.rs index d1cb36f47..dd287ff38 100644 --- a/tests/celld/worker/src/lib.rs +++ b/tests/celld/worker/src/lib.rs @@ -1,44 +1,35 @@ //! 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 +//! `{ commandId, input }`) plus GET of the aggregate state. GraphQL and //! projectors are not methods on this class (`PCH-REQ-005`). Chat `@live` //! stays on the GraphQL host. use chat_domain::{post, ChatMessage, ChatMessageState}; use distributed::cell_host::{ - cell_projection_event_evidence, AggregateCell, CellCommandIdentity, CellDispatchError, - CellDispatchResult, CellWaitPathRequest, CelldOutbox, DurableAggregateCellState, - InternalHttpSecret, CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, + AggregateCell, CellCommandIdentity, CellDispatchError, CellDispatchResult, CellWaitPathRequest, + CelldOutbox, InternalHttpSecret, CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, CELL_PRINCIPAL_PARTITION_HEADER, CELL_SERVICE_ID_HEADER, }; use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; 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; +#[cfg(feature = "storage-conformance")] +mod storage_conformance; -const STATE_DDL: &str = "CREATE TABLE IF NOT EXISTS cell_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - body TEXT NOT NULL -)"; +const MAX_CELL_REQUEST_BYTES: usize = 2 * 1024 * 1024; -async fn persist_and_drain_cell( - sql: &SqlStorage, - storage: &Storage, - env: &Env, - cell: &AggregateCell, -) -> Result<()> +async fn drain_cell(env: &Env, cell: &AggregateCell) -> Result<()> where A: distributed::Aggregate + Send + Sync + 'static, { let outcome = cell - .persist_and_drain_outbox(env, storage, |state| persist_cell_state(sql, state)) + .drain_outbox(env) .await .map_err(|error| Error::RustError(error.to_string()))?; for error in outcome.deferred { @@ -51,23 +42,43 @@ where Ok(()) } +async fn drain_after_command(env: &Env, cell: &AggregateCell, request: &Request) +where + A: distributed::Aggregate + Send + Sync + 'static, +{ + #[cfg(feature = "storage-conformance")] + if request + .headers() + .get("x-distributed-test-defer-drain") + .ok() + .flatten() + .as_deref() + == Some("1") + { + return; + } + #[cfg(not(feature = "storage-conformance"))] + let _ = request; + if let Err(error) = drain_cell(env, cell).await { + worker::console_error!("post-commit Queue drain deferred: {}", error); + } +} + #[durable_object] pub struct TodoCell { cell: AggregateCell, + #[cfg(feature = "storage-conformance")] sql: SqlStorage, - storage: Storage, env: Env, } 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(STATE_DDL, None).expect("create cell_state"); - let shard = state.id().name().unwrap_or_else(|| "todo".to_string()); + #[cfg(feature = "storage-conformance")] + let sql = state.storage().sql(); let outbox = CelldOutbox::from_env(&env, "OUTBOX").expect("OUTBOX Queue binding"); - let cell = AggregateCell::::new_with_snapshots(shard.clone(), 1) + let cell = AggregateCell::::from_state_with_snapshots(state, 1) .expect("todo cell identity") .mount(create()) .mount(rename()) @@ -77,11 +88,14 @@ impl DurableObject for TodoCell { .mount(force_archive()) .mount(purge()) .with_celld_outbox(outbox); - let _ = restore_cell_state(&sql, &cell); + #[cfg(feature = "storage-conformance")] + storage_conformance::reset_faults_on_activation(&sql).expect("reset test faults"); + #[cfg(feature = "storage-conformance")] + let cell = cell.mount(storage_conformance::test_batch()); Self { cell, + #[cfg(feature = "storage-conformance")] sql, - storage, env, } } @@ -90,9 +104,6 @@ impl DurableObject for TodoCell { if let Err(error) = authenticate_internal_request(&req, &self.env) { return internal_auth_error(error); } - if let Err(error) = restore_cell_state(&self.sql, &self.cell) { - return json_status(json!({ "error": error }), 500); - } let url = req.url()?; let parts: Vec = url .path() @@ -105,18 +116,22 @@ impl DurableObject for TodoCell { _ => return json_status(json!({ "error": "missing todo id" }), 400), }; + #[cfg(feature = "storage-conformance")] + if parts.get(2).map(String::as_str) == Some("__storage_test") { + return match storage_conformance::handle(&self.sql, &mut req).await { + Ok(response) => Ok(response), + Err(error) => Response::error(error.to_string(), 500), + }; + } + match (req.method(), parts.get(2).map(String::as_str)) { (Method::Get, None) => get_todo(&self.cell, &id).await, + #[cfg(feature = "storage-conformance")] + (Method::Post, Some("todo.test_batch")) => { + transition_todo(&self.env, &self.cell, &id, "todo.test_batch", &mut req).await + } (Method::Post, Some("todo.create")) => { - create_todo( - &self.sql, - &self.storage, - &self.env, - &self.cell, - &id, - &mut req, - ) - .await + create_todo(&self.env, &self.cell, &id, &mut req).await } (Method::Post, Some(command)) if matches!( @@ -129,26 +144,14 @@ impl DurableObject for TodoCell { | "todo.purge" ) => { - transition_todo( - &self.sql, - &self.storage, - &self.env, - &self.cell, - &id, - command, - &mut req, - ) - .await + transition_todo(&self.env, &self.cell, &id, command, &mut req).await } _ => json_status(json!({ "error": "not found" }), 404), } } async fn alarm(&self) -> Result { - if let Err(error) = restore_cell_state(&self.sql, &self.cell) { - return json_status(json!({ "error": error }), 500); - } - persist_and_drain_cell(&self.sql, &self.storage, &self.env, &self.cell).await?; + drain_cell(&self.env, &self.cell).await?; Response::ok("ok") } } @@ -156,39 +159,24 @@ impl DurableObject for TodoCell { #[durable_object] pub struct ChatCell { cell: AggregateCell, - sql: SqlStorage, - storage: Storage, env: Env, } 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(STATE_DDL, None).expect("create cell_state"); - let shard = state.id().name().unwrap_or_else(|| "chat".to_string()); let outbox = CelldOutbox::from_env(&env, "OUTBOX").expect("OUTBOX Queue binding"); - let cell = AggregateCell::::new(shard.clone()) + let cell = AggregateCell::::from_state(state) .expect("chat cell identity") .mount(post()) .with_celld_outbox(outbox); - let _ = restore_cell_state(&sql, &cell); - Self { - cell, - sql, - storage, - env, - } + Self { cell, env } } 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_cell_state(&self.sql, &self.cell) { - return json_status(json!({ "error": error }), 500); - } let url = req.url()?; let parts: Vec = url .path() @@ -204,25 +192,14 @@ impl DurableObject for ChatCell { 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 + post_chat(&self.env, &self.cell, &id, &mut req).await } _ => json_status(json!({ "error": "not found" }), 404), } } async fn alarm(&self) -> Result { - if let Err(error) = restore_cell_state(&self.sql, &self.cell) { - return json_status(json!({ "error": error }), 500); - } - persist_and_drain_cell(&self.sql, &self.storage, &self.env, &self.cell).await?; + drain_cell(&self.env, &self.cell).await?; Response::ok("ok") } } @@ -318,9 +295,6 @@ fn request_session(req: &Request) -> Session { } 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), @@ -329,8 +303,6 @@ async fn get_chat(cell: &AggregateCell, id: &str) -> Result, id: &str, @@ -359,13 +331,12 @@ async fn post_chat( .await { Ok(dispatch) => { - seal_chat_from_load(cell).await; - persist_and_drain_cell(sql, storage, env, cell).await?; - let events = projection_events_wire(cell, dispatch.causation_id())?; + drain_after_command(env, cell, req).await; + let events = serde_json::to_value(dispatch.projection_events())?; wait_path_ok(dispatch.payload().clone(), &dispatch, 201, events) } Err(error) => { - persist_and_drain_cell(sql, storage, env, cell).await?; + drain_after_command(env, cell, req).await; map_cell_error(error, cell) } } @@ -381,30 +352,7 @@ fn http_chat(state: &ChatMessageState) -> Value { }) } -fn restore_cell_state( - sql: &SqlStorage, - cell: &AggregateCell, -) -> std::result::Result<(), String> -where - A: distributed::Aggregate + Send + Sync + 'static, -{ - if let Some(state) = load_cell_state(sql).map_err(|error| error.to_string())? { - cell.restore_durable_state(state) - .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))); - } -} - 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), @@ -462,22 +410,7 @@ fn wait_path_ok( ) } -fn projection_events_wire(cell: &AggregateCell, causation_id: &str) -> Result -where - A: distributed::Aggregate + Send + Sync + 'static, -{ - let rows = cell - .durable_outbox() - .map_err(|error| Error::RustError(error.to_string()))?; - serde_json::to_value( - cell_projection_event_evidence(&rows, causation_id).map_err(Error::RustError)?, - ) - .map_err(|error| Error::RustError(error.to_string())) -} - async fn create_todo( - sql: &SqlStorage, - storage: &Storage, env: &Env, cell: &AggregateCell, id: &str, @@ -510,9 +443,8 @@ async fn create_todo( .await { Ok(dispatch) => { - seal_from_load(cell).await; - persist_and_drain_cell(sql, storage, env, cell).await?; - let events = projection_events_wire(cell, dispatch.causation_id())?; + drain_after_command(env, cell, req).await; + let events = serde_json::to_value(dispatch.projection_events())?; wait_path_ok( http_from_command(id, dispatch.payload(), &title), &dispatch, @@ -521,15 +453,13 @@ async fn create_todo( ) } Err(error) => { - persist_and_drain_cell(sql, storage, env, cell).await?; + drain_after_command(env, cell, req).await; map_cell_error(error, cell) } } } async fn transition_todo( - sql: &SqlStorage, - storage: &Storage, env: &Env, cell: &AggregateCell, id: &str, @@ -562,9 +492,8 @@ async fn transition_todo( .await { Ok(dispatch) => { - seal_from_load(cell).await; - persist_and_drain_cell(sql, storage, env, cell).await?; - let events = projection_events_wire(cell, dispatch.causation_id())?; + drain_after_command(env, cell, req).await; + let events = serde_json::to_value(dispatch.projection_events())?; let title = cell .load() .await @@ -580,7 +509,7 @@ async fn transition_todo( ) } Err(error) => { - persist_and_drain_cell(sql, storage, env, cell).await?; + drain_after_command(env, cell, req).await; map_cell_error(error, cell) } } @@ -648,36 +577,3 @@ async fn bounded_json( } serde_json::from_slice(&bytes).map_err(|_| "invalid cell request JSON") } - -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_cell_state(sql: &SqlStorage, state: &DurableAggregateCellState) -> Result<()> { - let body = serde_json::to_string(state).map_err(|error| Error::RustError(error.to_string()))?; - sql.exec( - "INSERT INTO cell_state (id, body) VALUES (1, ?) \ - ON CONFLICT(id) DO UPDATE SET body = excluded.body", - Some(vec![body.into()]), - )?; - Ok(()) -} - -fn load_cell_state(sql: &SqlStorage) -> Result> { - let rows: Vec = sql - .exec("SELECT body FROM cell_state WHERE id = 1", 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 StateRow { - body: String, -} diff --git a/tests/celld/worker/src/storage_conformance.rs b/tests/celld/worker/src/storage_conformance.rs new file mode 100644 index 000000000..4daaf8b5b --- /dev/null +++ b/tests/celld/worker/src/storage_conformance.rs @@ -0,0 +1,136 @@ +//! Fault probes for the isolated test harness, absent from ordinary builds. +//! The Worker authenticates the internal request before reaching this module. +//! Operations are finite and fixed; this is not an arbitrary-SQL endpoint. + +use serde_json::{json, Value}; +use worker::*; + +#[derive(serde::Deserialize, distributed::CommandInput)] +pub struct BatchInput { + pub todo_id: String, + pub title: String, +} + +#[derive(serde::Serialize, distributed::CommandOutput)] +pub struct BatchPayload { + pub title: String, +} + +async fn handle_batch( + ctx: &distributed::microsvc::CausalCommandContext<'_, todo_domain::Todo>, + input: BatchInput, +) -> std::result::Result< + distributed::command::PreparedCommand>, + distributed::microsvc::HandlerError, +> { + use distributed::microsvc::HandlerError; + let principal = ctx.user_id()?; + let repo = ctx.repo(); + let mut todo = repo + .get(&input.todo_id) + .await? + .ok_or_else(|| HandlerError::Rejected("missing batch fixture".into()))?; + for index in 0..32 { + todo.rename(principal, &format!("{} {index}", input.title)) + .map_err(|error| HandlerError::Rejected(error.to_string()))?; + } + let title = todo_domain::TodoState::from(&*todo).title; + repo.publish_events() + .commit(todo)? + .eventual(BatchPayload { title }) +} + +distributed::portable_command! { + name: "todo.test_batch", + transition: todo_domain::domain_commands::Rename, + aggregate: todo_domain::Todo, + input: BatchInput, + outcome: distributed::command::Eventual, + shard: |input| input.todo_id.clone(), + roles: ["user"], + field: "test_batch", + handle: handle_batch, +} + +pub async fn handle(sql: &SqlStorage, request: &mut Request) -> Result { + if request.method() != Method::Post { + return Response::error("method not allowed", 405); + } + let body: Value = request.json().await?; + match body.get("operation").and_then(Value::as_str) { + Some("inspect") => { + let rows: Vec = sql.exec( + "SELECT + (SELECT COUNT(*) FROM aggregate_events) AS events, + (SELECT COALESCE(SUM(length(payload)), 0) FROM aggregate_events) AS eventBytes, + (SELECT COALESCE(MAX(sequence), 0) FROM aggregate_events) AS version, + (SELECT COUNT(*) FROM aggregate_snapshots) AS snapshots, + (SELECT COALESCE(MAX(version), 0) FROM aggregate_snapshots) AS snapshotVersion, + (SELECT COUNT(*) FROM command_ledger) AS receipts, + (SELECT COUNT(*) FROM command_ledger WHERE completed_at IS NOT NULL) AS completed, + (SELECT COUNT(*) FROM outbox_messages) AS outbox, + (SELECT COUNT(*) FROM sqlite_master WHERE name = 'cell_state') AS wholeStateTables", + None, + )?.to_array()?; + let outbox: Vec = sql + .exec( + "SELECT message_id, status, attempts FROM outbox_messages ORDER BY message_id", + None, + )? + .to_array()?; + Response::from_json(&json!({ "counts": rows[0], "outbox": outbox })) + } + Some("fail-completion") => { + sql.exec( + "CREATE TRIGGER test_fail_completion BEFORE UPDATE ON command_ledger + WHEN NEW.state = 'succeeded' + BEGIN SELECT RAISE(ABORT, 'test receipt write failure'); END", + None, + )?; + Response::ok("armed") + } + Some("expire-at-commit") => { + sql.exec( + "CREATE TRIGGER test_expire_attempt AFTER INSERT ON aggregate_events + BEGIN UPDATE command_ledger SET lease_expires_at = 0 WHERE state = 'in_progress'; END", + None, + )?; + Response::ok("armed") + } + Some("fail-settlement") => { + sql.exec( + "CREATE TRIGGER test_fail_settlement BEFORE DELETE ON outbox_messages + BEGIN SELECT RAISE(ABORT, 'test settlement failure'); END", + None, + )?; + Response::ok("armed") + } + Some("fail-claim") => { + sql.exec( + "CREATE TRIGGER test_fail_claim BEFORE UPDATE ON outbox_messages + WHEN NEW.status = 'in_flight' + BEGIN SELECT RAISE(ABORT, 'test claim failure'); END", + None, + )?; + Response::ok("armed") + } + Some("clear-faults") => { + reset_faults_on_activation(sql)?; + Response::ok("cleared") + } + _ => Response::error("unknown probe", 400), + } +} + +// celld intentionally forbids TEMP schema objects. These fixed test triggers +// are activation-scoped by explicit cleanup on the next test Worker activation. +pub fn reset_faults_on_activation(sql: &SqlStorage) -> Result<()> { + sql.exec( + "DROP TRIGGER IF EXISTS test_fail_completion; + DROP TRIGGER IF EXISTS test_expire_attempt; + DROP TRIGGER IF EXISTS test_fail_settlement; + DROP TRIGGER IF EXISTS test_fail_claim;", + None, + )?; + Ok(()) +} diff --git a/tests/core_command_contract.rs b/tests/core_command_contract.rs new file mode 100644 index 000000000..d62c0c63a --- /dev/null +++ b/tests/core_command_contract.rs @@ -0,0 +1,125 @@ +#![allow(dead_code)] + +// Derives in this package's integration targets resolve through the crate root. +pub use distributed::{command, graphql}; + +use distributed::application::{CommandDefinition, Module}; +use distributed::command::{typed_command, CommandInputType, CommandOutputType, Succeeded}; +use distributed::graphql::{build_surface, graphql_sdl_from_surface, SurfaceOptions}; +use serde::{Deserialize, Serialize}; + +mod neutral { + use super::*; + use distributed::{CommandInput, CommandOutput}; + + #[derive(Deserialize, CommandInput)] + #[serde(rename_all = "camelCase")] + pub struct ContractInput { + record_id: String, + count: i64, + nested: Option>>, + } + #[derive(Deserialize, CommandInput)] + pub struct NestedInput { + enabled: bool, + } + #[derive(Serialize, CommandOutput)] + pub struct ContractOutput { + record_id: String, + value: f64, + } +} + +fn artifact() -> serde_json::Value +where + I: CommandInputType + serde::de::DeserializeOwned + Send + 'static, + O: CommandOutputType + Serialize + Send + Sync + 'static, +{ + let command = typed_command::>("contract.test") + .roles(["user"]) + .field_name("run_contract"); + let spec = command.spec().unwrap(); + let module = Module::new("contract") + .command_definition(CommandDefinition::from_typed_command(command, None).unwrap()) + .build() + .unwrap(); + let surface = build_surface(&[], &SurfaceOptions::sqlite()) + .unwrap() + .with_module(&module) + .unwrap(); + serde_json::json!({ "spec": spec, "sdl": graphql_sdl_from_surface(&surface).unwrap() }) +} + +#[test] +fn neutral_declarations_preserve_the_pre_extraction_artifact() { + // Captured from d188010d before changing the command implementation. + let expected: serde_json::Value = + serde_json::from_str(include_str!("fixtures/core-command-contract-v1.json")).unwrap(); + assert_eq!( + artifact::(), + expected + ); +} + +#[derive(Deserialize, distributed::CommandInput)] +#[serde(rename_all(deserialize = "kebab-case", serialize = "camelCase"))] +struct NonGraphqlInput { + record_id: String, +} + +#[test] +fn graphql_representability_is_checked_only_when_exposing_the_command() { + let command = typed_command::>("core.only"); + assert_eq!(command.spec().unwrap().input.fields[0].name, "record-id"); + let module = Module::new("core-only") + .command_definition(CommandDefinition::from_typed_command(command, None).unwrap()) + .build() + .unwrap(); + let result = build_surface(&[], &SurfaceOptions::sqlite()) + .unwrap() + .with_module(&module); + assert!(result.unwrap_err().contains("record-id")); +} + +#[derive(Deserialize, distributed::CommandInput)] +#[serde(rename_all(deserialize = "camelCase", serialize = "SCREAMING_SNAKE_CASE"))] +struct DirectionalInput { + record_id: String, + #[serde(rename(deserialize = "inputID", serialize = "OUTPUT_ID"))] + alternate_id: String, + values: Option>>, +} + +#[derive(Serialize, distributed::CommandOutput)] +#[serde(rename_all(deserialize = "camelCase", serialize = "SCREAMING_SNAKE_CASE"))] +struct DirectionalOutput { + record_id: String, + #[serde(rename(deserialize = "inputID", serialize = "OUTPUT_ID"))] + alternate_id: String, + values: Option>>, +} + +#[test] +fn neutral_shapes_follow_serde_direction_and_preserve_item_nullability() { + for (definition, names) in [ + ( + DirectionalInput::command_type(), + ["recordId", "inputID", "values"], + ), + ( + DirectionalOutput::command_type(), + ["RECORD_ID", "OUTPUT_ID", "VALUES"], + ), + ] { + assert_eq!( + definition + .fields + .iter() + .map(|f| f.name.as_str()) + .collect::>(), + names + ); + let list = &definition.fields[2]; + assert!(list.list && list.nullable && list.item_nullable); + } +} diff --git a/tests/distributed_read_model/checkout_saga_service/service.rs b/tests/distributed_read_model/checkout_saga_service/service.rs index 43a8d309d..ef1822fba 100644 --- a/tests/distributed_read_model/checkout_saga_service/service.rs +++ b/tests/distributed_read_model/checkout_saga_service/service.rs @@ -5,11 +5,15 @@ use distributed::microsvc::{Routes, Service}; use super::{handlers, CheckoutRepo}; pub fn service(repo: CheckoutRepo) -> Arc { - Arc::new(Service::new().with_http_command_routes().routes(distributed::routes!( - Routes::new().with_repo(repo), - command handlers::start, - event handlers::record_seat_reserved, - ))) + Arc::new( + Service::new() + .with_http_command_routes() + .routes(distributed::routes!( + Routes::new().with_repo(repo), + command handlers::start, + event handlers::record_seat_reserved, + )), + ) } #[cfg(feature = "http")] diff --git a/tests/durable_enqueue_sqlite/main.rs b/tests/durable_enqueue_sqlite/main.rs index 3b3a49308..eb07fb074 100644 --- a/tests/durable_enqueue_sqlite/main.rs +++ b/tests/durable_enqueue_sqlite/main.rs @@ -8,12 +8,13 @@ use serde_json::{json, Value}; -use distributed::bus::{Bus, InMemoryBus, RunOptions}; +use distributed::bus::{Bus, BusConsumer, Handlers, InMemoryBus, Message, RunOptions}; use distributed::microsvc::{Context, HandlerError, HasOutboxStore, Routes, Service, Session}; use distributed::{ sourced, AggregateBuilder, AggregateRepository, Entity, OutboxMessage, OutboxMessageStatus, OutboxStore, Queueable, QueuedRepository, SqliteRepository, }; +use std::sync::{Arc, Mutex}; #[derive(Default)] struct Counter { @@ -49,16 +50,19 @@ async fn service() -> Repo { .aggregate::() } -async fn wait_until_published(store: &impl OutboxStore, count: usize) { +async fn assert_published_and_drained(store: &impl OutboxStore, bus: &InMemoryBus) { tokio::time::timeout(std::time::Duration::from_secs(1), async { loop { - if store - .messages_by_status(OutboxMessageStatus::Published, usize::MAX) - .await - .unwrap() - .len() - >= count - { + let mut remaining = 0; + for status in [ + OutboxMessageStatus::Pending, + OutboxMessageStatus::InFlight, + OutboxMessageStatus::Failed, + OutboxMessageStatus::Published, + ] { + remaining += store.messages_by_status(status, 8).await.unwrap().len(); + } + if remaining == 0 { break; } tokio::task::yield_now().await; @@ -66,10 +70,24 @@ async fn wait_until_published(store: &impl OutboxStore, count: usize) { }) .await .expect("immediate publish should settle outbox rows"); + let delivered = Arc::new(Mutex::new(Vec::new())); + let record = delivered.clone(); + let handlers = Handlers::new().on_event("counter.touched", move |message: &Message| { + record + .lock() + .unwrap() + .push(message.id().unwrap().to_string()); + async { Ok(()) } + }); + bus.subscribe(Arc::new(handlers), RunOptions::idempotent()) + .await + .unwrap(); + assert_eq!(*delivered.lock().unwrap(), vec!["evt-c1".to_string()]); } #[tokio::test] async fn commit_publishes_immediately_over_sqlite() { + let bus = InMemoryBus::new(); let repo = service().await; let store = repo.outbox_store(); let service = Service::new() @@ -79,7 +97,7 @@ async fn commit_publishes_immediately_over_sqlite() { .command("counter.touch") .handle(handle_touch), ) - .with_bus(InMemoryBus::new()); + .with_bus(bus.clone()); // Command completion returns at durable commit; the bounded worker // claims the pending row and publishes it. @@ -88,13 +106,7 @@ async fn commit_publishes_immediately_over_sqlite() { .await .unwrap(); - wait_until_published(&store, 1).await; - let published = store - .messages_by_status(OutboxMessageStatus::Published, usize::MAX) - .await - .unwrap(); - assert_eq!(published.len(), 1, "row should be published immediately"); - assert_eq!(published[0].id(), "evt-c1"); + assert_published_and_drained(&store, &bus).await; assert!( store.pending(usize::MAX).await.unwrap().is_empty(), "nothing should be left for the poller" @@ -120,11 +132,5 @@ 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 - .unwrap(); - assert_eq!(published.len(), 1); - assert_eq!(published[0].id(), "evt-c1"); + assert_published_and_drained(&store, &bus).await; } diff --git a/tests/e2e-celld/Cargo.toml b/tests/e2e-celld/Cargo.toml index 4f6f78f6c..5e63dc5b5 100644 --- a/tests/e2e-celld/Cargo.toml +++ b/tests/e2e-celld/Cargo.toml @@ -32,7 +32,7 @@ license = "MIT" publish = false [workspace.dependencies] -distributed = { path = "../..", features = ["sqlite", "postgres", "http", "graphql", "metrics", "nats"] } +distributed = { path = "../..", features = ["sqlite", "postgres", "http", "graphql", "gateway-graphql-native", "metrics", "nats"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } diff --git a/tests/e2e-celld/Makefile b/tests/e2e-celld/Makefile index c2360603e..a49d1564e 100644 --- a/tests/e2e-celld/Makefile +++ b/tests/e2e-celld/Makefile @@ -12,7 +12,9 @@ BIND ?= 127.0.0.1:8791 API_PORT ?= 8791 UI_PORT ?= 5180 UI_HOST ?= localhost -UI_URL ?= http://localhost:5180 +PUBLIC_ORIGIN ?= http://localhost:8791 +UI_INTERNAL_ORIGIN ?= http://localhost:$(UI_PORT) +UI_URL ?= $(PUBLIC_ORIGIN) ENV_FILE ?= ../e2e-ui/e2e-ui.env CELLD_HTTP_PORT ?= 18080 CELLD_URL ?= http://127.0.0.1:$(CELLD_HTTP_PORT) @@ -64,6 +66,8 @@ run: $(if $(filter 1,$(WATCH_WORKER)),ensure-watch) export BIND="$${BIND:-127.0.0.1:8791}"; \ export UI_BIND="$${UI_BIND:-$(UI_HOST)}"; \ export UI_PORT="$${UI_PORT:-$(UI_PORT)}"; \ + export PUBLIC_ORIGIN="$${PUBLIC_ORIGIN:-$(PUBLIC_ORIGIN)}"; \ + export UI_INTERNAL_ORIGIN="$${UI_INTERNAL_ORIGIN:-$(UI_INTERNAL_ORIGIN)}"; \ export UI_URL="$${UI_URL:-$(UI_URL)}"; \ export AUTH_URL="$${AUTH_URL:-$$UI_URL}"; \ export AUTH_USE_SECURE_COOKIES="false"; \ diff --git a/tests/e2e-celld/README.md b/tests/e2e-celld/README.md index 565f46591..e49637200 100644 --- a/tests/e2e-celld/README.md +++ b/tests/e2e-celld/README.md @@ -54,7 +54,7 @@ make up # Zitadel + Postgres (read models + login) make up-celld-nats # celld 0.4 local store + NATS cd ../e2e-celld -make run # GraphQL :8791 + UI :5180 (watches sources) +make run # public gateway :8791 + internal UI :5180 (watches sources) ``` Eventual projectors and `@live` use **Postgres** from `e2e-ui.env` @@ -78,7 +78,7 @@ The Cargo workspace declares `../e2e-ui/ui` as its UI component. Both `distributed build` and `distributed dev` resolve it within the repository; CI and users do not launch a second unmanaged `npm run dev` process. -Open `http://localhost:5180`. The navbar shows a **celld** badge. Sign in +Open `http://localhost:8791`. 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. diff --git a/tests/e2e-celld/crates/graphql-service/src/host.rs b/tests/e2e-celld/crates/graphql-service/src/host.rs index 38644def3..8e328d149 100644 --- a/tests/e2e-celld/crates/graphql-service/src/host.rs +++ b/tests/e2e-celld/crates/graphql-service/src/host.rs @@ -27,6 +27,8 @@ const BUS_GROUP: &str = "e2e-celld"; pub struct HostOptions { pub bind: String, + pub public_origin: String, + pub ui_origin: String, pub identity: IdentityConfig, pub celld_url: String, pub nats_url: String, @@ -100,6 +102,8 @@ async fn run_postgres( service, host, &options.bind, + &options.public_origin, + &options.ui_origin, queue_relay, options.internal_secret, ) diff --git a/tests/e2e-celld/crates/graphql-service/src/http.rs b/tests/e2e-celld/crates/graphql-service/src/http.rs index a52a171f7..cf4923579 100644 --- a/tests/e2e-celld/crates/graphql-service/src/http.rs +++ b/tests/e2e-celld/crates/graphql-service/src/http.rs @@ -16,7 +16,8 @@ use axum::{Json, Router}; use distributed::bus::{CelldQueueEnvelope, CelldQueueRelayHandler, CELLD_QUEUE_RELAY_PATH}; use distributed::cell_host::{InternalHttpSecret, CELL_INTERNAL_SECRET_HEADER}; use distributed::command_dispatch::SharedCommandHost; -use distributed::graphql::graphql_router_with_host; +use distributed::gateway::{native::*, *}; +use distributed::graphql::identity::OidcGatewayProvider; use distributed::microsvc::{HandlerError, Service, Session}; use serde_json::{json, Value}; @@ -91,6 +92,8 @@ pub async fn serve( service: Arc, host: SharedCommandHost, addr: &str, + public_origin: &str, + ui_origin: &str, queue_relay: CelldQueueRelayHandler, internal_secret: InternalHttpSecret, ) -> Result<(), std::io::Error> { @@ -110,15 +113,13 @@ pub async fn serve( "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 app = Router::new().route( + "/health", + get(move || { + let body = health_body.clone(); + async move { Json(body) } + }), + ); let mut internal = Router::new() .route( "/zitadel.ingress.v1", @@ -158,6 +159,91 @@ pub async fn serve( require_internal, ))); + let capabilities = GraphqlCapabilities { + queries: true, + commands: true, + live: true, + }; + let mut routes = vec![ + Route::new("graphql", RoutePath::prefix("/graphql"), "graphql"), + Route::new("ui", RoutePath::prefix("/"), "ui"), + ]; + for path in [ + "/health", + "/zitadel.ingress.v1", + "/zitadel.scrape.v1", + CELLD_QUEUE_RELAY_PATH, + ] { + routes.push(Route::new( + format!("service-{}", routes.len()), + RoutePath::exact(path), + "service", + )); + } + for command in service.command_names() { + let path = RoutePath::exact(format!("/{command}")); + if !routes.iter().any(|route| route.path == path) { + routes.push(Route::new( + format!("closed-{}", routes.len()), + path, + "closed", + )); + } + } + let gateway = GatewayConfig { + bindings: vec![ + Binding::new( + "graphql", + BindingKind::Graphql { + executor: GraphqlExecutor::Embedded, + capabilities, + delivery: DeliveryCapabilities::default(), + schema_extensions: vec![], + }, + ), + Binding::new( + "ui", + BindingKind::UiProxy { + origin: ui_origin.into(), + }, + ), + Binding::new("service", BindingKind::Handler), + Binding::new("closed", BindingKind::Handler), + ], + routes, + } + .build() + .map_err(std::io::Error::other)?; + let auth = if let Some(config) = engine.identity_config().oidc.clone() { + let provider = Arc::new(OidcGatewayProvider::new(config, "e2e-celld-oidc-v1")); + NativeAuth::new(move |credentials| { + let provider = provider.clone(); + async move { provider.authenticate(&credentials).await } + }) + } else { + NativeAuth::anonymous() + }; + // The remote command host and secret-protected Queue relay keep their + // existing owners. UI, auth, HMR and lifecycle requests use this public edge. + let app = NativeGateway::new( + gateway, + NativeOptions::new(public_origin), + [ + ( + "graphql".into(), + NativeBinding::Graphql(GraphqlBinding::Embedded( + EmbeddedGraphql::new(engine, Some(host), capabilities) + .map_err(std::io::Error::other)?, + )), + ), + ("ui".into(), NativeBinding::UiProxy { websocket: true }), + ("service".into(), NativeBinding::Handler(app)), + ("closed".into(), NativeBinding::Handler(Router::new())), + ], + auth, + ) + .map_err(std::io::Error::other)? + .router(); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app).await } diff --git a/tests/e2e-celld/crates/runner/src/main.rs b/tests/e2e-celld/crates/runner/src/main.rs index 2ee27e7b4..e4697db77 100644 --- a/tests/e2e-celld/crates/runner/src/main.rs +++ b/tests/e2e-celld/crates/runner/src/main.rs @@ -33,6 +33,10 @@ async fn main() -> Result<(), Box> { &database_url, HostOptions { bind, + public_origin: env::var("PUBLIC_ORIGIN") + .unwrap_or_else(|_| "http://localhost:8791".into()), + ui_origin: env::var("UI_INTERNAL_ORIGIN") + .unwrap_or_else(|_| "http://localhost:5180".into()), identity: identity_from_env(), celld_url, nats_url, diff --git a/tests/e2e-ui/Cargo.toml b/tests/e2e-ui/Cargo.toml index cd0e2a16e..d1f979d48 100644 --- a/tests/e2e-ui/Cargo.toml +++ b/tests/e2e-ui/Cargo.toml @@ -22,7 +22,7 @@ license = "MIT" publish = false [workspace.dependencies] -distributed = { path = "../..", features = ["sqlite", "postgres", "http", "graphql", "metrics"] } +distributed = { path = "../..", features = ["sqlite", "postgres", "http", "graphql", "metrics", "gateway-graphql-native", "gateway-delivery"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 3b672aee0..b8f53fb4b 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -19,7 +19,9 @@ API_PORT ?= 8791 BASE_URL ?= http://127.0.0.1:8791 UI_PORT ?= 5180 # localhost required for Auth.js cookies over HTTP (SvelteKit Secure default) -UI_URL ?= http://localhost:5180 +PUBLIC_ORIGIN ?= http://localhost:8791 +UI_INTERNAL_ORIGIN ?= http://localhost:$(UI_PORT) +UI_URL ?= $(PUBLIC_ORIGIN) UI_HOST ?= localhost ENV_FILE ?= e2e-ui.env NPM ?= npm @@ -56,7 +58,8 @@ down: ## Coherent application build + actual API/UI supervision. ## Vite still owns Svelte, CSS, and GraphQL hot updates. run: - cargo run --quiet --manifest-path ../../Cargo.toml -p distributed_cli --bin distributed -- dev . + @if [ -f $(ENV_FILE) ]; then set -a; . ./$(ENV_FILE); set +a; fi; \ + PUBLIC_ORIGIN="$${PUBLIC_ORIGIN:-$(PUBLIC_ORIGIN)}" UI_URL="$${PUBLIC_ORIGIN:-$(PUBLIC_ORIGIN)}" UI_INTERNAL_ORIGIN="$${UI_INTERNAL_ORIGIN:-$(UI_INTERNAL_ORIGIN)}" cargo run --quiet --manifest-path ../../Cargo.toml -p distributed_cli --bin distributed -- dev . run-api: @if [ -f $(ENV_FILE) ]; then set -a; . ./$(ENV_FILE); set +a; fi; \ diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index a87a08185..a26891050 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -46,8 +46,13 @@ make up # once: writes e2e-ui.env distributed dev ``` -The UI is at `http://localhost:5180`; GraphQL is at -`http://127.0.0.1:8791/graphql`. The CLI loads `e2e-ui.env` when it exists. +The public UI is at `http://localhost:8791`; GraphQL is at +`http://localhost:8791/graphql`. The backend hosts the application gateway and +proxies UI/auth requests to internal SvelteKit on port 5180. `PUBLIC_ORIGIN` +selects the public URL and `UI_INTERNAL_ORIGIN` selects the UI upstream. Vite +does not proxy API requests back to the gateway. Set `GATEWAY_DELIVERY=all` to +opt into bounded query snapshots, coalescing and shared live delivery; the +default `none` allocates none of those coordinators. The CLI loads `e2e-ui.env` when it exists. Demo users are `alice`, `bob`, and `admin` with password `Password1!`. `make run` is a convenience alias for the same zero-config command. Before @@ -80,6 +85,15 @@ transition, browser rejection before GraphQL dispatch, direct API rejection while generations disagree, URL/app-state retention, compatible replica restoration, and incompatible replica revalidation. +The same proof covers two consecutive UI-only edits retaining the API process. +After each transition it submits a real Todo command, checks the command receipt +and active generation, waits for the exact persisted row through GraphQL, then +reloads the page and checks its rendered data independently of browser optimism. +Todo creation is Eventual: its receipt is not a projection-completion barrier. +Because the Todos query is `@load`, not `@live`, an immediate reload can capture +pre-projection data that will not update by itself. The test therefore waits on +authoritative data, not a fixed sleep or a longer DOM timeout. + This is the **default one-process playground**. Optional celld: ```bash diff --git a/tests/e2e-ui/api/.gitops/local/README.md b/tests/e2e-ui/api/.gitops/local/README.md index e3c7ef903..32c2ec5ca 100644 --- a/tests/e2e-ui/api/.gitops/local/README.md +++ b/tests/e2e-ui/api/.gitops/local/README.md @@ -3,8 +3,11 @@ This cluster-development chart renders one Deployment running `distributed dev` for the complete editable application. The lifecycle owns the Rust API, SvelteKit UI, generated clients, linked framework JavaScript, -and generation activation. Two Services expose ports 8791 and 5180 from that -one lifecycle participant set. +and generation activation. Both Services enter the backend gateway on 8791: +the public UI Service retains port 5180 and the API Service uses 8791. The +gateway forwards UI/auth to same-pod SvelteKit at 127.0.0.1:5180; it never +uses the public UI Service as its upstream. PUBLIC_ORIGIN and Auth.js use +the public UI Service URL, so existing OIDC callbacks stay on that origin. Hops supplies the source mount or sync delivery. A Node init container places the pinned Node/npm toolchain beside the Rust toolchain without a custom local diff --git a/tests/e2e-ui/api/.gitops/local/templates/deployment.yaml b/tests/e2e-ui/api/.gitops/local/templates/deployment.yaml index 1b9126c15..aac3a2c06 100644 --- a/tests/e2e-ui/api/.gitops/local/templates/deployment.yaml +++ b/tests/e2e-ui/api/.gitops/local/templates/deployment.yaml @@ -69,7 +69,7 @@ spec: ports: - containerPort: {{ .Values.service.targetPort }} name: api - - containerPort: {{ .Values.uiService.targetPort }} + - containerPort: {{ .Values.env.UI_PORT | int }} name: ui env: {{- include "e2e-ui-api.oidcClientEnv" . | nindent 12 }} @@ -77,8 +77,8 @@ spec: {{- if eq $k "AUTH_URL" }} - name: AUTH_URL value: {{ printf "http://e2e-ui-ui.%s.svc.cluster.local:5180" (include "e2e-ui-api.releaseNamespace" $) | quote }} - {{- else if eq $k "UI_URL" }} - - name: UI_URL + {{- else if or (eq $k "UI_URL") (eq $k "PUBLIC_ORIGIN") }} + - name: {{ $k }} value: {{ printf "http://e2e-ui-ui.%s.svc.cluster.local:5180" (include "e2e-ui-api.releaseNamespace" $) | quote }} {{- else if and $v (ne $v "") }} - name: {{ $k }} diff --git a/tests/e2e-ui/api/.gitops/local/values.yaml b/tests/e2e-ui/api/.gitops/local/values.yaml index 06d2f28a7..1340c77f8 100644 --- a/tests/e2e-ui/api/.gitops/local/values.yaml +++ b/tests/e2e-ui/api/.gitops/local/values.yaml @@ -27,13 +27,15 @@ service: uiService: name: e2e-ui-ui port: 5180 - targetPort: 5180 + targetPort: 8791 env: CARGO_TARGET_DIR: "/workspace/tests/e2e-ui/target" BIND: "0.0.0.0:8791" UI_BIND: "0.0.0.0" UI_PORT: "5180" + UI_INTERNAL_ORIGIN: "http://127.0.0.1:5180" + PUBLIC_ORIGIN: "http://e2e-ui-ui.default.svc.cluster.local:5180" UI_URL: "http://e2e-ui-ui.default.svc.cluster.local:5180" AUTH_URL: "http://e2e-ui-ui.default.svc.cluster.local:5180" AUTH_TRUST_HOST: "true" 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 index 947ad45d2..5faf5a815 100644 --- a/tests/e2e-ui/crates/blob-domain/src/commands/move_dir.rs +++ b/tests/e2e-ui/crates/blob-domain/src/commands/move_dir.rs @@ -1,4 +1,4 @@ -use distributed::graphql::{Atomic, CommandProjectionPureReduce, PreparedCommand}; +use distributed::command::{Atomic, CommandProjectionPureReduce, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use distributed::portable_command; use e2e_readmodels::BlobGames; @@ -7,7 +7,7 @@ use serde::Deserialize; use super::support::{authenticated_user, principal, rejected, sealed_row}; use crate::{domain_commands, BlobGame, Direction}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct BlobMoveInput { pub game_id: String, pub direction: String, diff --git a/tests/e2e-ui/crates/blob-domain/src/commands/start.rs b/tests/e2e-ui/crates/blob-domain/src/commands/start.rs index 5409b4203..de17a6983 100644 --- a/tests/e2e-ui/crates/blob-domain/src/commands/start.rs +++ b/tests/e2e-ui/crates/blob-domain/src/commands/start.rs @@ -1,4 +1,4 @@ -use distributed::graphql::{Atomic, PreparedCommand}; +use distributed::command::{Atomic, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use distributed::portable_command; use e2e_readmodels::BlobGames; @@ -7,7 +7,7 @@ use serde::Deserialize; use super::support::{authenticated_user, principal, rejected, sealed_row}; use crate::{domain_commands, BlobGame}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct BlobStartInput { pub game_id: String, } 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 index 77f6e9ce6..7472d310f 100644 --- a/tests/e2e-ui/crates/blob-domain/src/commands/start_level.rs +++ b/tests/e2e-ui/crates/blob-domain/src/commands/start_level.rs @@ -1,4 +1,4 @@ -use distributed::graphql::{Atomic, PreparedCommand}; +use distributed::command::{Atomic, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use distributed::portable_command; use e2e_readmodels::BlobGames; @@ -7,7 +7,7 @@ use serde::Deserialize; use super::support::{authenticated_user, principal, rejected, sealed_row}; use crate::{domain_commands, BlobGame}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct BlobStartLevelInput { pub game_id: String, } diff --git a/tests/e2e-ui/crates/chat-domain/src/commands/post.rs b/tests/e2e-ui/crates/chat-domain/src/commands/post.rs index 1b0bb5251..cf65fa5b1 100644 --- a/tests/e2e-ui/crates/chat-domain/src/commands/post.rs +++ b/tests/e2e-ui/crates/chat-domain/src/commands/post.rs @@ -1,4 +1,4 @@ -use distributed::graphql::{Eventual, PreparedCommand}; +use distributed::command::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use distributed::portable_command; use serde::{Deserialize, Serialize}; @@ -17,7 +17,7 @@ fn authenticated_user(ctx: &CausalCommandContext<'_, ChatMessage>) -> bool { ctx.session().user_id().is_some_and(|id| !id.is_empty()) } -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct ChatPostInput { pub message_id: String, pub body: String, @@ -27,7 +27,7 @@ pub struct ChatPostInput { pub created_at: String, } -#[derive(Debug, Serialize, distributed::GraphqlOutput)] +#[derive(Debug, Serialize, distributed::CommandOutput)] pub struct ChatPostPayload { pub message_id: String, pub room_id: String, diff --git a/tests/e2e-ui/crates/runner/src/main.rs b/tests/e2e-ui/crates/runner/src/main.rs index 5fb400293..abb34c346 100644 --- a/tests/e2e-ui/crates/runner/src/main.rs +++ b/tests/e2e-ui/crates/runner/src/main.rs @@ -21,6 +21,19 @@ async fn main() -> Result<(), Box> { HostOptions { bind, identity: identity_from_env(), + public_origin: env::var("PUBLIC_ORIGIN") + .unwrap_or_else(|_| "http://localhost:8791".into()), + ui_origin: env::var("UI_INTERNAL_ORIGIN") + .unwrap_or_else(|_| "http://localhost:5180".into()), + delivery: match env::var("GATEWAY_DELIVERY").as_deref().unwrap_or("none") { + "none" => Default::default(), + "all" => distributed::gateway::DeliveryCapabilities { + snapshots: true, + coalescing: true, + live_sharing: true, + }, + _ => return Err("GATEWAY_DELIVERY must be none or all".into()), + }, }, ) .await diff --git a/tests/e2e-ui/crates/service/src/host.rs b/tests/e2e-ui/crates/service/src/host.rs index d16589d09..ff1e43bb3 100644 --- a/tests/e2e-ui/crates/service/src/host.rs +++ b/tests/e2e-ui/crates/service/src/host.rs @@ -19,8 +19,8 @@ use distributed::microsvc::{ use distributed::{PostgresLockManager, PostgresRepository, SqliteLockManager, SqliteRepository}; use crate::{ - build_graphql_engine, build_service, distributed_manifest, serve, spawn_scrape_loop, - ZitadelScrapeConfig, E2E_UI_APPLICATION, + build_service, distributed_manifest, serve, spawn_scrape_loop, ZitadelScrapeConfig, + E2E_UI_APPLICATION, }; const BUS_GROUP: &str = "e2e-ui"; @@ -29,6 +29,9 @@ const BUS_GROUP: &str = "e2e-ui"; pub struct HostOptions { pub bind: String, pub identity: IdentityConfig, + pub public_origin: String, + pub ui_origin: String, + pub delivery: distributed::gateway::DeliveryCapabilities, } /// Start the e2e-ui full-local process for SQLite or Postgres from `DATABASE_URL`. @@ -63,7 +66,30 @@ async fn run_sqlite( let change_rx = repo.read_model_changes(); let service = build_service(repo.clone(), locks.clone(), repo.clone()) .with_bus(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)); - let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let versions = if options.delivery != distributed::gateway::DeliveryCapabilities::default() { + Some( + distributed::graphql::delivery::GatewayVersionStore::install( + &distributed::graphql::GraphqlPool::from(repo.pool().clone()), + "e2e-ui-gateway-v1", + [ + "todos".into(), + "chat_messages".into(), + "blob_games".into(), + "auth_users".into(), + ], + ) + .await?, + ) + } else { + None + }; + let gql = crate::modules::graphql::build_graphql_engine_with_delivery( + &repo, + &service, + options.identity.clone(), + Some(change_rx), + versions, + )?; let service = Arc::new(service.try_with_graphql(gql)?); let _dispatcher = Arc::new(LocalCommandDispatcher::new(Arc::clone(&service))); @@ -87,7 +113,7 @@ async fn run_sqlite( spawn_zitadel_scrape(repo.clone()); eprintln!("e2e-ui (sqlite) listening on http://{}", options.bind); - serve(service, &options.bind).await?; + serve(service, &options).await?; Ok(()) } @@ -107,7 +133,30 @@ async fn run_postgres( let change_rx = repo.read_model_changes(); let service = build_service(repo.clone(), locks.clone(), repo.clone()) .with_bus(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)); - let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let versions = if options.delivery != distributed::gateway::DeliveryCapabilities::default() { + Some( + distributed::graphql::delivery::GatewayVersionStore::install( + &distributed::graphql::GraphqlPool::from(repo.pool().clone()), + "e2e-ui-gateway-v1", + [ + "todos".into(), + "chat_messages".into(), + "blob_games".into(), + "auth_users".into(), + ], + ) + .await?, + ) + } else { + None + }; + let gql = crate::modules::graphql::build_graphql_engine_with_delivery( + &repo, + &service, + options.identity.clone(), + Some(change_rx), + versions, + )?; let service = Arc::new(service.try_with_graphql(gql)?); let _dispatcher = Arc::new(LocalCommandDispatcher::new(Arc::clone(&service))); @@ -131,7 +180,7 @@ async fn run_postgres( spawn_zitadel_scrape(repo.clone()); eprintln!("e2e-ui (postgres) listening on http://{}", options.bind); - serve(service, &options.bind).await?; + serve(service, &options).await?; Ok(()) } diff --git a/tests/e2e-ui/crates/service/src/http.rs b/tests/e2e-ui/crates/service/src/http.rs index e47c00ec7..c0afafec1 100644 --- a/tests/e2e-ui/crates/service/src/http.rs +++ b/tests/e2e-ui/crates/service/src/http.rs @@ -58,11 +58,129 @@ async fn dispatch_named( } } -/// 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) +/// Compose the application's explicit public gateway without opening a listener. +pub fn gateway_router( + service: Arc, + options: &crate::HostOptions, +) -> Result { + use distributed::application::{MountSelector, Runtime}; + use distributed::command_dispatch::LocalCommandHost; + use distributed::gateway::{delivery::*, native::*, *}; + use distributed::graphql::identity::OidcGatewayProvider; + let engine = service + .graphql_engine() + .ok_or_else(|| std::io::Error::other("application GraphQL engine missing"))?; + let capabilities = GraphqlCapabilities { + queries: true, + commands: true, + live: true, + }; + let mut routes = vec![ + Route::new("graphql", RoutePath::prefix("/graphql"), "graphql"), + Route::new("auth", RoutePath::prefix("/auth"), "ui"), + Route::new("auth-api", RoutePath::prefix("/api/auth"), "ui"), + Route::new("ui", RoutePath::prefix("/"), "ui"), + ]; + for path in [ + "/zitadel.ingress.v1", + "/zitadel.scrape.v1", + "/health", + "/healthz", + "/metrics", + "/graphiql", + ] { + routes.push(Route::new( + format!("http-{}", routes.len()), + RoutePath::prefix(path), + "service", + )); + } + // Old HTTP command URLs retain API ownership and cannot become UI HTML. + for command in service.command_specs().map_err(std::io::Error::other)? { + let path = format!("/{}", command.id); + if !routes + .iter() + .any(|route| route.path == RoutePath::prefix(&path)) + { + routes.push(Route::new( + format!("command-{}", routes.len()), + RoutePath::exact(path), + "closed", + )); + } + } + let gateway = GatewayConfig { + bindings: vec![ + Binding::new( + "graphql", + BindingKind::Graphql { + executor: GraphqlExecutor::Embedded, + capabilities, + delivery: options.delivery, + schema_extensions: vec![], + }, + ), + Binding::new( + "ui", + BindingKind::UiProxy { + origin: options.ui_origin.clone(), + }, + ), + Binding::new("service", BindingKind::Handler), + Binding::new("closed", BindingKind::Handler), + ], + routes, + } + .build() + .map_err(std::io::Error::other)?; + let surface = crate::application_manifest() + .surfaces + .into_iter() + .next() + .ok_or_else(|| std::io::Error::other("application surface missing"))?; + let application = service + .application(crate::E2E_UI_APPLICATION, surface) + .map_err(std::io::Error::other)? + .with_gateway("public", &gateway) + .map_err(std::io::Error::other)?; + let selector = MountSelector::gateway("public").map_err(std::io::Error::other)?; + let runtime = Runtime::default() + .mount_gateway(&application, selector.clone(), gateway) + .map_err(std::io::Error::other)?; + runtime + .bind_gateway(&selector, |gateway| { + let graphql = GraphqlBinding::Embedded( + EmbeddedGraphql::new( + engine.clone(), + Some(Arc::new(LocalCommandHost::new(service.clone()))), + capabilities, + ) + .map_err(std::io::Error::other)?, + ); + let graphql = if options.delivery == DeliveryCapabilities::default() { + NativeBinding::Graphql(graphql) + } else { + let delivery = NativeDelivery::new(NativeDeliveryOptions { + snapshots: options.delivery.snapshots.then(SnapshotLimits::default), + coalescing: options.delivery.coalescing.then(FlightLimits::default), + live: options.delivery.live_sharing.then(LiveLimits::default), + }) + .map_err(std::io::Error::other)?; + NativeBinding::GraphqlWithDelivery(graphql, Arc::new(delivery)) + }; + let auth = if let Some(config) = engine.identity_config().oidc.clone() { + let provider = Arc::new(OidcGatewayProvider::new(config, "e2e-ui-oidc-v1")); + NativeAuth::new(move |credentials| { + let provider = provider.clone(); + async move { provider.authenticate(&credentials).await } + }) + } else { + NativeAuth::anonymous() + }; + + let ingress = service.clone(); + let scrape = service.clone(); + let service_routes = distributed::microsvc::router(service) .route( "/zitadel.ingress.v1", post(move |headers: HeaderMap, Json(input): Json| { @@ -78,6 +196,29 @@ pub async fn serve(service: Arc, addr: &str) -> Result<(), std::io::Err }), ); - let listener = tokio::net::TcpListener::bind(addr).await?; + NativeGateway::new( + gateway.clone(), + NativeOptions::new(&options.public_origin), + [ + ("graphql".into(), graphql), + ("ui".into(), NativeBinding::UiProxy { websocket: true }), + ("service".into(), NativeBinding::Handler(service_routes)), + ("closed".into(), NativeBinding::Handler(axum::Router::new())), + ], + auth, + ) + .map(NativeGateway::router) + .map_err(std::io::Error::other) + }) + .map(|adapter| adapter.expect("explicitly selected gateway")) +} + +/// One backend owns the public listener; SvelteKit remains its UI/auth backend. +pub async fn serve( + service: Arc, + options: &crate::HostOptions, +) -> Result<(), std::io::Error> { + let app = gateway_router(service, options)?; + let listener = tokio::net::TcpListener::bind(&options.bind).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 9b3a0685c..5db126ac8 100644 --- a/tests/e2e-ui/crates/service/src/lib.rs +++ b/tests/e2e-ui/crates/service/src/lib.rs @@ -19,15 +19,15 @@ 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, + DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, + DISTRIBUTED_PUBLIC_CLIENT_SURFACE, E2E_UI_APPLICATION, E2E_UI_MODULE_IDS, }; pub use e2e_readmodels::distributed_manifest; 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 http::{gateway_router, serve}; pub use modules::compose::build_service; pub use modules::graphql::{ application_manifest, build_graphql_engine, dev_identity, distributed_admin_client_surface, diff --git a/tests/e2e-ui/crates/service/src/modules/graphql.rs b/tests/e2e-ui/crates/service/src/modules/graphql.rs index 46429157a..0956415fa 100644 --- a/tests/e2e-ui/crates/service/src/modules/graphql.rs +++ b/tests/e2e-ui/crates/service/src/modules/graphql.rs @@ -49,6 +49,34 @@ pub(crate) fn build_graphql_engine_with_graphiql( identity: IdentityConfig, change_rx: Option>, graphiql: bool, +) -> Result { + build_engine(pool, service, identity, change_rx, graphiql, None) +} + +pub(crate) fn build_graphql_engine_with_delivery( + pool: impl Into, + service: &Service, + identity: IdentityConfig, + change_rx: Option>, + versions: Option, +) -> Result { + build_engine( + pool, + service, + identity, + change_rx, + graphiql_enabled(), + versions, + ) +} + +fn build_engine( + pool: impl Into, + service: &Service, + identity: IdentityConfig, + change_rx: Option>, + graphiql: bool, + versions: Option, ) -> Result { let projections = projections::projection_owners(); let mut b = GraphqlEngine::builder(pool) @@ -80,6 +108,9 @@ pub(crate) fn build_graphql_engine_with_graphiql( if let Some(rx) = change_rx { b = b.change_stream(rx); } + if let Some(versions) = versions { + b = b.gateway_versions(versions); + } b.build().map_err(|e| e.to_string()) } diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/archive.rs b/tests/e2e-ui/crates/todo-domain/src/commands/archive.rs index 08e80658d..16d72272d 100644 --- a/tests/e2e-ui/crates/todo-domain/src/commands/archive.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/archive.rs @@ -1,11 +1,11 @@ -use distributed::graphql::Eventual; +use distributed::command::Eventual; use distributed::portable_command; use serde::Deserialize; use super::TodoStatusPayload; use crate::{domain_commands, Todo}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct TodoArchiveInput { pub todo_id: String, } diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/complete.rs b/tests/e2e-ui/crates/todo-domain/src/commands/complete.rs index 70e420dda..a722a45c0 100644 --- a/tests/e2e-ui/crates/todo-domain/src/commands/complete.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/complete.rs @@ -1,16 +1,16 @@ -use distributed::graphql::Eventual; +use distributed::command::Eventual; use distributed::portable_command; use serde::{Deserialize, Serialize}; use crate::{domain_commands, Todo, TodoState}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct TodoCompleteInput { pub todo_id: String, } /// Shared complete / archive / reopen payload. -#[derive(Debug, Serialize, distributed::GraphqlOutput)] +#[derive(Debug, Serialize, distributed::CommandOutput)] pub struct TodoStatusPayload { pub todo_id: String, pub status: String, diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/create.rs b/tests/e2e-ui/crates/todo-domain/src/commands/create.rs index 31ee6da4d..fea665347 100644 --- a/tests/e2e-ui/crates/todo-domain/src/commands/create.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/create.rs @@ -1,5 +1,5 @@ +use distributed::command::{Eventual, PreparedCommand}; use distributed::command_input_defaults; -use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use distributed::portable_command; use serde::{Deserialize, Serialize}; @@ -7,13 +7,13 @@ use serde::{Deserialize, Serialize}; use super::support::{authenticated_user, principal, rejected}; use crate::{domain_commands, Todo, TodoState}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct TodoCreateInput { pub todo_id: String, pub title: String, } -#[derive(Debug, Serialize, distributed::GraphqlOutput)] +#[derive(Debug, Serialize, distributed::CommandOutput)] pub struct TodoCreatePayload { pub todo_id: String, pub owner_id: String, diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/force_archive.rs b/tests/e2e-ui/crates/todo-domain/src/commands/force_archive.rs index b8229618e..490daccdb 100644 --- a/tests/e2e-ui/crates/todo-domain/src/commands/force_archive.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/force_archive.rs @@ -1,4 +1,4 @@ -use distributed::graphql::{Eventual, PreparedCommand}; +use distributed::command::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use distributed::portable_command; use serde::{Deserialize, Serialize}; @@ -6,12 +6,12 @@ use serde::{Deserialize, Serialize}; use super::support::{admin_user, principal, rejected}; use crate::{domain_commands, Todo, TodoState}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct TodoForceArchiveInput { pub todo_id: String, } -#[derive(Debug, Serialize, distributed::GraphqlOutput)] +#[derive(Debug, Serialize, distributed::CommandOutput)] pub struct TodoForceArchivePayload { pub todo_id: String, pub owner_id: String, diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/purge.rs b/tests/e2e-ui/crates/todo-domain/src/commands/purge.rs index 03b8efb9b..aef92e171 100644 --- a/tests/e2e-ui/crates/todo-domain/src/commands/purge.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/purge.rs @@ -1,15 +1,15 @@ -use distributed::graphql::Eventual; +use distributed::command::Eventual; use distributed::portable_command; use serde::{Deserialize, Serialize}; use crate::{domain_commands, Todo}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct TodoPurgeInput { pub todo_id: String, } -#[derive(Debug, Serialize, distributed::GraphqlOutput)] +#[derive(Debug, Serialize, distributed::CommandOutput)] pub struct TodoPurgePayload { pub todo_id: String, pub purged: bool, diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/rename.rs b/tests/e2e-ui/crates/todo-domain/src/commands/rename.rs index aee608247..be8d462a2 100644 --- a/tests/e2e-ui/crates/todo-domain/src/commands/rename.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/rename.rs @@ -1,16 +1,16 @@ -use distributed::graphql::Eventual; +use distributed::command::Eventual; use distributed::portable_command; use serde::{Deserialize, Serialize}; use crate::{domain_commands, Todo, TodoState}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct TodoRenameInput { pub todo_id: String, pub title: String, } -#[derive(Debug, Serialize, distributed::GraphqlOutput)] +#[derive(Debug, Serialize, distributed::CommandOutput)] pub struct TodoRenamePayload { pub todo_id: String, pub title: String, diff --git a/tests/e2e-ui/crates/todo-domain/src/commands/reopen.rs b/tests/e2e-ui/crates/todo-domain/src/commands/reopen.rs index 3ce683c85..5b5ac9b62 100644 --- a/tests/e2e-ui/crates/todo-domain/src/commands/reopen.rs +++ b/tests/e2e-ui/crates/todo-domain/src/commands/reopen.rs @@ -1,11 +1,11 @@ -use distributed::graphql::Eventual; +use distributed::command::Eventual; use distributed::portable_command; use serde::Deserialize; use super::TodoStatusPayload; use crate::{domain_commands, Todo}; -#[derive(Debug, Deserialize, distributed::GraphqlInput)] +#[derive(Debug, Deserialize, distributed::CommandInput)] pub struct TodoReopenInput { pub todo_id: String, } 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 89d67e926..60f6a9d09 100644 --- a/tests/e2e-ui/crates/todo-domain/src/models/todo.rs +++ b/tests/e2e-ui/crates/todo-domain/src/models/todo.rs @@ -255,8 +255,8 @@ mod tests { #[test] fn domain_commands_create_matches_created_event_contract() { + use distributed::command::CommandEventSet; use distributed::domain_event::DomainEventContract; - use distributed::graphql::CommandEventSet; let from_transition = domain_commands::Create::command_event_set(); let from_event = distributed::events![TodoCreatedDomainEvent]; diff --git a/tests/e2e-ui/e2e/chat.user.spec.ts b/tests/e2e-ui/e2e/chat.user.spec.ts index cabbb349c..f1103f313 100644 --- a/tests/e2e-ui/e2e/chat.user.spec.ts +++ b/tests/e2e-ui/e2e/chat.user.spec.ts @@ -88,7 +88,11 @@ test.describe('chat (alice)', () => { await expect(msg).toBeVisible({ timeout: 20_000 }); await expect(msg.locator('.ch-body')).toHaveText(body); - // Reload — message should still be there (RM + SSR) + // The first row is optimistic. Wait for the command receipt and + // projection before navigation can cancel the pending HTTP request. + await expect(page.locator('.ch-msg-block', { has: msg }).locator('.ch-status-footer')).toHaveText('Delivered'); + + // Reload — the confirmed message should still be there (RM + SSR) await page.reload(); await expect(page.locator('.ch-msg', { hasText: body })).toBeVisible({ timeout: 20_000 diff --git a/tests/e2e-ui/e2e/unique-key-runtime.anon.spec.ts b/tests/e2e-ui/e2e/unique-key-runtime.anon.spec.ts new file mode 100644 index 000000000..7c1f9ea78 --- /dev/null +++ b/tests/e2e-ui/e2e/unique-key-runtime.anon.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; +import { build } from '../../../js/node_modules/esbuild/lib/main.js'; +import { createDistributedReplica } from '../../../js/dist/replica/index.js'; +import { + createDistributedSvelteKitServer, + defineDistributedBoundaryBinding, + defineDistributedBoundaryOperation +} from '../../../js/dist/sveltekit/index.js'; +import { artifact, frame } from '../../../js/tests/fixtures/unique-key-artifact.mjs'; +import { fileURLToPath } from 'node:url'; + +// SvelteKit loader-to-browser contract proof. GraphQL transport is controlled; +// this does not claim to exercise database-backed live delivery. +test('candidate-key artifact hydrates and changes relationships in Chromium', async ({ page }) => { + const boundary = defineDistributedBoundaryOperation({ + operation: 'UniqueKeyBridge', route: '/unique-key', kind: 'page', discovery: 'route_document' + }, artifact, defineDistributedBoundaryBinding(artifact, {})); + const server = createDistributedSvelteKitServer({ + boundaries: [boundary], getSession: async () => null, getRole: () => 'user' + }); + let serverFetches = 0; + const loaded = await server.load({ + locals: {}, route: { id: '/unique-key' }, url: new URL('https://example.test/unique-key'), + async fetch(_url, init) { + serverFetches++; + expect(JSON.parse(init.body).query).toBe(artifact.document); + return new Response(JSON.stringify(frame('1', 'target-one', 'first')), { + headers: { 'content-type': 'application/json' } + }); + } + }); + expect(loaded.gqlError).toBeNull(); + expect(serverFetches).toBe(1); + const seed = loaded.distributed.state; + const serverResult = createDistributedReplica(); + expect(serverResult.hydrate(seed, seed.scope)).toBe(true); + const initial = serverResult.read(artifact, {}); + const runtimePath = fileURLToPath(new URL('../../../js/dist/replica/index.js', import.meta.url)); + const bundle = await build({ + stdin: { contents: ` + import { createDistributedReplica } from ${JSON.stringify(runtimePath)}; + globalThis.startUniqueKeyProof = (artifact, seed) => { + let observer; + const state = { fetches: 0, subscriptions: 0, closed: false }; + const replica = createDistributedReplica({ transport: { + fetch() { state.fetches++; throw new Error('unexpected hydration fetch'); }, + subscribe(request, next) { + state.subscriptions++; observer = next; + return () => { state.closed = true; }; + } + } }); + if (!replica.hydrate(seed, seed.scope)) throw new Error('hydration rejected'); + const watch = replica.watch(artifact, {}, { live: true }); + const unsubscribe = watch.subscribe(snapshot => { + document.querySelector('output').textContent = JSON.stringify(snapshot.data); + }); + return { state, update(frame) { observer.next(frame); }, + close() { unsubscribe(); watch.destroy(); } }; + }; + `, resolveDir: process.cwd(), loader: 'js' }, + bundle: true, write: false, platform: 'browser', format: 'iife' + }); + await page.setContent(''); + await page.getByLabel('Query result').evaluate((element, data) => { + element.textContent = JSON.stringify(data); + }, initial.data); + await expect(page.getByLabel('Query result')).toContainText('target-one'); + await page.addScriptTag({ content: bundle.outputFiles[0].text }); + await page.evaluate(({ artifact, seed }) => { + (globalThis as any).proof = (globalThis as any).startUniqueKeyProof(artifact, seed); + }, { artifact, seed }); + await expect(page.getByLabel('Query result')).toHaveText(JSON.stringify(initial.data)); + expect(await page.evaluate(() => (globalThis as any).proof.state)).toEqual({ + fetches: 0, subscriptions: 1, closed: false + }); + await page.evaluate(value => (globalThis as any).proof.update(value), frame('2', 'target-two', 'second')); + await expect(page.getByLabel('Query result')).toHaveText(JSON.stringify({ todos: [ + { id: 'source-id', title: 'source', owner: { id: 'target-two', title: 'second' } } + ] })); + await page.evaluate(value => (globalThis as any).proof.update(value), frame('3', null, null)); + await expect(page.getByLabel('Query result')).toHaveText(JSON.stringify({ todos: [ + { id: 'source-id', title: 'source', owner: null } + ] })); + await page.evaluate(() => (globalThis as any).proof.close()); + expect(await page.evaluate(() => (globalThis as any).proof.state)).toEqual({ + fetches: 0, subscriptions: 1, closed: true + }); +}); diff --git a/tests/e2e-ui/gateway/.gitignore b/tests/e2e-ui/gateway/.gitignore new file mode 100644 index 000000000..d4f588edf --- /dev/null +++ b/tests/e2e-ui/gateway/.gitignore @@ -0,0 +1 @@ +artifacts/ diff --git a/tests/e2e-ui/gateway/README.md b/tests/e2e-ui/gateway/README.md new file mode 100644 index 000000000..41b7460e2 --- /dev/null +++ b/tests/e2e-ui/gateway/README.md @@ -0,0 +1,20 @@ +# Public application gateway browser fixture + +Run `npm ci --prefix tests/gateway-auth`, install that fixture's Playwright +Chromium, then `node tests/e2e-ui/gateway/run.mjs` from the repository root. +Rust stable, wasm32-unknown-unknown and wasm-pack are required. The runner uses +`distributed build` to generate a coherent application and production SvelteKit +bundle; `GATEWAY_SKIP_BUILD=1` reuses an already successful build for iteration. + +Each delivery mode (`none`, `all`) starts a disposable SQLite backend, production +SvelteKit and an isolated standards OIDC provider on free loopback ports. There +are no external users, secrets, databases or cluster resources. Only processes +and temporary data created by this runner are stopped/removed. + +Assertions cover public-origin callback, trusted UI/API identity, cookie flags, +real refresh and failed-refresh denial, logout, API ownership, Todo Eventual +optimism before its held receipt, Blob Atomic response painting without HTTP +refetch, an old HTTP/cache response arriving after another move, and a real old +live frame replayed after a confirmed Chat command. Mutation observers detect +transient regressions as well as the final state. Logs omit session payloads and +are saved under ignored `artifacts/`. diff --git a/tests/e2e-ui/gateway/refresh.mjs b/tests/e2e-ui/gateway/refresh.mjs new file mode 100644 index 000000000..cdab9ca85 --- /dev/null +++ b/tests/e2e-ui/gateway/refresh.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import {expect} from '../../gateway-auth/node_modules/@playwright/test/index.mjs'; + +// Exercise actual background OIDC rotation, including the route seed and the +// follow-up SvelteKit invalidation. A final DOM assertion alone misses a flash. +export async function verifySessionRefreshContinuity(page, origin) { + for (const [route, selector] of [['todos', '[data-todo-id]'], ['chat', '.ch-msg-block']]) { + await page.goto(origin+'/'+route); + await expect(page.locator(selector).first()).toBeVisible(); + await page.waitForFunction(()=>globalThis.__distributedReloadState!==undefined); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + const navigations=[]; + const request=r=>{if(r.isNavigationRequest())navigations.push(r.url());}; + page.on('request',request); + await page.evaluate(selector=>{ + const rows=[...document.querySelectorAll(selector)]; + globalThis.__refreshContinuity={lost:false,events:[],rows,observer:new MutationObserver(records=>{ + if(rows.some(row=>!row.isConnected)||records.some(record=>[...record.removedNodes].some(node=>rows.some(row=>node===row||node.contains(row))))){globalThis.__refreshContinuity.lost=true;globalThis.__refreshContinuity.events.push({time:performance.now(),remaining:rows.filter(row=>row.isConnected).length});} + })}; + globalThis.__refreshContinuity.observer.observe(document.body,{childList:true,subtree:true}); + },selector); + let releaseRefresh, seedArrived; + const release = new Promise(resolve=>releaseRefresh=resolve); + const seed = new Promise(resolve=>seedArrived=resolve); + let hold = route==='todos'; + const refreshRoute = async request=>{ + if(!hold)return request.continue(); + hold=false; + const response=await request.fetch(); + seedArrived();await release;await request.fulfill({response}); + }; + await page.route('**/api/auth/refresh',refreshRoute); + try { + for(let rotation=0;rotation<2;rotation++) { + const responses = Promise.all([ + page.waitForResponse(r=>r.url()===origin+'/api/auth/refresh'&&r.request().method()==='POST',{timeout:20000}), + page.waitForResponse(r=>r.url().includes('/'+route+'/__data.json'),{timeout:20000}) + ]); + if(route==='todos'&&rotation===0) { + await seed; + // The auth snapshot is now old: confirm a command before delivering it. + const title='confirmed after auth snapshot '+Date.now(); + await page.locator('#todo-title').fill(title); + await page.getByRole('button',{name:/^add$/i}).click(); + const todo=page.locator('[data-todo-id]').filter({hasText:title}); + await expect(todo).toBeVisible(); + await expect(todo.locator('.pending-state')).toHaveCount(0); + await todo.evaluate(row=>globalThis.__refreshContinuity.rows.push(row)); + releaseRefresh(); + } + const [refresh, data] = await responses; + assert.equal(refresh.status(),200); + const refreshed = await refresh.json(); + assert.ok(refreshed.pageData?.distributedAuthority); + assert.equal(data.status(),200); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + } + assert.deepEqual(navigations,[],'session refresh must not navigate the document'); + assert.equal(await page.evaluate(()=>globalThis.__refreshContinuity.lost),false,route+' rows were removed during token refresh: '+JSON.stringify(await page.evaluate(()=>globalThis.__refreshContinuity.events))); + } finally { + releaseRefresh(); + await page.unroute('**/api/auth/refresh',refreshRoute); + page.off('request',request); + await page.evaluate(()=>globalThis.__refreshContinuity?.observer.disconnect()); + } + } + console.log('PASS Todo and Chat retain DOM rows through OIDC rotation and a late auth snapshot'); +} diff --git a/tests/e2e-ui/gateway/run.mjs b/tests/e2e-ui/gateway/run.mjs new file mode 100644 index 000000000..92b693963 --- /dev/null +++ b/tests/e2e-ui/gateway/run.mjs @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createServer} from 'node:http'; +import {once} from 'node:events'; +import {mkdtemp,mkdir,readFile,writeFile,rm} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {randomBytes} from 'node:crypto'; +import {chromium,expect} from '../../gateway-auth/node_modules/@playwright/test/index.mjs'; +import {startProvider} from '../../gateway-auth/provider.mjs'; +import {verifySessionRefreshContinuity} from './refresh.mjs'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +const devMode=process.env.GATEWAY_DEV==='1'; +const environment={PATH:process.env.PATH,HOME:process.env.HOME,RUSTUP_TOOLCHAIN:process.env.RUSTUP_TOOLCHAIN||'stable',NODE_ENV:'production'}; +async function freePort(){const server=createServer();server.listen(0,'127.0.0.1');await once(server,'listening');const port=server.address().port;await new Promise(r=>server.close(r));return port;} +function launch(program,args,{cwd=root,env=environment}={}){ + let log='';const child=spawn(program,args,{cwd,env,stdio:['ignore','pipe','pipe']}); + child.on('error',error=>log+='process launch failed: '+error.code); + child.stdout.on('data',chunk=>log=(log+chunk).slice(-60000));child.stderr.on('data',chunk=>log=(log+chunk).slice(-60000)); + return {child,logs:()=>log,stop:async()=>{if(child.exitCode===null){child.kill('SIGINT');await once(child,'exit');}}}; +} +async function ready(url,process){for(let i=0;i<3600;i++){if(process.child.exitCode!==null)throw Error(process.logs());try{const response=await fetch(url);if(response.ok)return;}catch{}await new Promise(r=>setTimeout(r,100));}throw Error('readiness timeout: '+process.logs());} +const artifacts=path.join(root,'gateway/artifacts');await mkdir(artifacts,{recursive:true}); +if(process.env.GATEWAY_SKIP_BUILD!=='1'){ + const {NODE_ENV,...buildEnvironment}=environment; + const build=launch('cargo',['run','--quiet','--manifest-path',path.resolve(root,'../../Cargo.toml'),'-p','distributed_cli','--bin','distributed','--','build',root],{env:buildEnvironment}); + const [code]=await once(build.child,'exit');await writeFile(path.join(artifacts,'ui-build.log'),build.logs());assert.equal(code,0,build.logs()); +} +const temporary=await mkdtemp(path.join(os.tmpdir(),'gateway-app-')); +try{ + for(const delivery of (process.env.GATEWAY_LIFECYCLE==='1'?['none']:['none','all'])){ + const apiPort=await freePort(),uiPort=await freePort(),issuer=`http://127.0.0.1:${await freePort()}`; + const publicOrigin=`http://127.0.0.1:${apiPort}`; + const idp=await startProvider(issuer,publicOrigin,{jwtAudience:'gateway-fixture',accessTokenTtl:65}); + let api,ui,browser; + try{ + if(devMode){ + const {NODE_ENV,...devEnvironment}=environment; + api=launch(path.resolve(root,'../../target/debug/distributed'),['dev',root],{env:{...devEnvironment,DATABASE_URL:`sqlite:${temporary}/${delivery}.db?mode=rwc`,BIND:`127.0.0.1:${apiPort}`,PUBLIC_ORIGIN:publicOrigin,UI_INTERNAL_ORIGIN:`http://127.0.0.1:${uiPort}`,UI_PORT:String(uiPort),UI_BIND:'127.0.0.1',GATEWAY_DELIVERY:delivery,OIDC_ISSUER:issuer,OIDC_AUDIENCE:'gateway-fixture',OIDC_CLIENT_ID:'gateway-fixture',OIDC_CLIENT_SECRET:'local-fixture-only',AUTH_URL:publicOrigin,AUTH_SECRET:randomBytes(32).toString('hex'),AUTH_USE_SECURE_COOKIES:'false',GRAPHIQL:'0'}}); + ui={logs:api.logs,stop:async()=>{}}; + }else{ + api=launch(path.join(root,'target/debug/e2e-ui'),[],{env:{...environment,DATABASE_URL:`sqlite:${temporary}/${delivery}.db?mode=rwc`,BIND:`127.0.0.1:${apiPort}`,PUBLIC_ORIGIN:publicOrigin,UI_INTERNAL_ORIGIN:`http://127.0.0.1:${uiPort}`,GATEWAY_DELIVERY:delivery,OIDC_ISSUER:issuer,OIDC_AUDIENCE:'gateway-fixture',OIDC_CLIENT_ID:'gateway-fixture',GRAPHIQL:'0'}}); + await ready(publicOrigin+'/health',api); + ui=launch(process.execPath,['build/index.js'],{cwd:path.join(root,'ui'),env:{...environment,HOST:'127.0.0.1',PORT:String(uiPort),PUBLIC_ORIGIN:publicOrigin,ORIGIN:publicOrigin,AUTH_URL:publicOrigin,AUTH_SECRET:randomBytes(32).toString('hex'),AUTH_USE_SECURE_COOKIES:'false',OIDC_ISSUER:issuer,OIDC_CLIENT_ID:'gateway-fixture',OIDC_CLIENT_SECRET:'local-fixture-only',OIDC_AUDIENCE:'gateway-fixture',E2E_API_ORIGIN:publicOrigin}}); + } + await ready(publicOrigin,api); + if(devMode){ + const participant='gateway_ci_lifecycle_probe'; + const response=await fetch(publicOrigin+'/__distributed/lifecycle',{headers:{'x-distributed-participant':participant}}); + assert.equal(response.status,200,'gateway must route lifecycle to the UI'); + assert.equal((await response.json()).phase,'active'); + const heartbeat=JSON.parse(await readFile(path.join(root,'.distributed/lifecycle/dev-control/participants',participant+'.json'),'utf8')); + assert.ok(Date.now()-heartbeat.seenAtUnixMs<5000); + const ack=await fetch(publicOrigin+'/__distributed/lifecycle',{method:'POST',headers:{origin:publicOrigin,'content-type':'application/json'},body:JSON.stringify({participantId:participant,transitionId:'gateway_ci_no_transition',ok:true})}); + assert.equal(ack.status,409,'same-origin acknowledgement reaches lifecycle state validation'); + } + browser=await chromium.launch();const context=await browser.newContext();const page=await context.newPage(); + const errors=[];page.on('pageerror',error=>errors.push(error.message)); + await page.goto(publicOrigin);await page.getByRole('link',{name:/log in|sign in/i}).first().click(); + await page.getByRole('button',{name:'Continue as Alice'}).click(); + await page.waitForURL(url=>url.origin===publicOrigin&&!url.pathname.startsWith('/auth')&&!url.pathname.startsWith('/login')); + const session=await(await context.request.get(publicOrigin+'/auth/session')).json();assert.equal(session.user.id,'alice'); + await page.goto(publicOrigin+'/todos');await expect(page.getByRole('heading',{name:/todos/i})).toBeVisible(); + const unauthenticated=await fetch(publicOrigin+'/graphql',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({query:'query { todos { todo_id } }'})}); + assert.match(unauthenticated.headers.get('content-type'),/json/); + assert.ok((await unauthenticated.json()).errors?.length); + assert.equal((await context.request.post(publicOrigin+'/todo.create',{data:{}})).status(),404); + let releaseCommand,commandReady; + const commandBarrier=new Promise(resolve=>releaseCommand=resolve),commandArrived=new Promise(resolve=>commandReady=resolve); + await page.route('**/graphql',async route=>{ + if(!(route.request().postData()||'').includes('todos_create'))return route.continue(); + const response=await route.fetch();commandReady();await commandBarrier;await route.fulfill({response}); + }); + const title='gateway todo '+delivery;await page.locator('input').first().fill(title);await page.getByRole('button',{name:/^add$/i}).click(); + const todo=page.locator('[data-todo-id]').filter({hasText:title});await expect(todo).toBeVisible();await commandArrived;await expect(todo.locator('.pending-state')).toHaveText('Saving…');releaseCommand();await expect(todo.locator('.pending-state')).toHaveCount(0,{timeout:20000}); + await page.unroute('**/graphql'); + await todo.getByRole('button',{name:/^done$/i}).click();await expect(page.locator('.panel').filter({has:page.getByRole('heading',{name:/^done$/i})}).getByText(title)).toBeVisible(); + await page.goto(publicOrigin+'/blob');await expect(page.getByTestId('blob-start-game')).toBeEnabled();await page.getByTestId('blob-start-game').click();await expect(page.locator('.blob-board')).toBeVisible({timeout:20000}); + await verifyBlobRace(page); + await verifyLiveRace(page,publicOrigin); + await verifySessionRefreshContinuity(page,publicOrigin); + if(process.env.GATEWAY_LIFECYCLE==='1'){ + assert.ok(devMode,'full reload proof requires the CLI dev host'); + const storage=path.join(temporary,'reload-auth.json');await context.storageState({path:storage}); + // The lifecycle fixture owns the browser participants during source edits. + await browser.close();browser=undefined; + const reload=launch(process.execPath,['scripts/lifecycle-reload.mjs'],{env:{...environment,E2E_UI_ORIGIN:publicOrigin,E2E_API_ORIGIN:publicOrigin,E2E_RELOAD_STORAGE_STATE:storage}}); + reload.child.stdout.on('data',chunk=>process.stdout.write(chunk)); + const [code]=await once(reload.child,'exit'); + await writeFile(path.join(artifacts,'lifecycle-reload.log'),reload.logs()); + assert.equal(code,0,reload.logs()); + console.log('PASS complete controlled browser lifecycle through the public gateway'); + }else await verifyAuth(context,idp,publicOrigin); + assert.deepEqual(errors,[]);console.log('PASS actual public-origin application login, Todo Eventual and Blob Atomic with delivery '+delivery); + }catch(error){await writeFile(path.join(artifacts,delivery+'-failure.txt'),String(error));throw error;} + finally{await browser?.close();await ui?.stop();await api?.stop();await new Promise(r=>idp.server.close(r));if(api)await writeFile(path.join(artifacts,delivery+'-api.log'),api.logs());if(ui)await writeFile(path.join(artifacts,delivery+'-ui.log'),ui.logs());} + } +}finally{await rm(temporary,{recursive:true,force:true});} + +// Delay a real old HTTP envelope while an Atomic response advances the replica. +// Delivery-enabled requests exercise the cache path; the body/proof are unchanged. +async function verifyBlobRace(page){ + await expect(page.locator('[data-blob-hydrated="1"]')).toBeVisible(); + const player=page.locator('.blob-board .tile-player'); + let queries=0;const count=request=>{if((request.postData()||'').includes('query BlobGames'))queries++;}; + page.on('request',count); + const move=async(key,label)=>{ + const result=page.waitForResponse(response=>(response.request().postData()||'').includes('blob_games_move')); + await page.keyboard.press(key);assert.ok((await result).ok());await expect(player).toHaveAttribute('aria-label',label); + }; + await move('ArrowRight','r0 c1'); + assert.equal(queries,0,'Atomic direct response should paint without an HTTP refetch'); + let release,arrived;const barrier=new Promise(resolve=>release=resolve),ready=new Promise(resolve=>arrived=resolve); + let held=false; + await page.route('**/graphql',async route=>{ + if(held||!(route.request().postData()||'').includes('query BlobGames'))return route.continue(); + held=true;const response=await route.fetch();assert.ok((await response.json()).data?.blob_games?.length);arrived();await barrier;await route.fulfill({response}); + }); + const refetch=()=>page.evaluate(()=>globalThis.__distributedBlobRefetch()); + const oldRequest=refetch();await ready; + const hole=(await page.locator('.cell[aria-label="r0 c2"]').getAttribute('class')).includes('tile-hole'); + const label=hole?'r1 c1':'r0 c2';await move(hole?'ArrowDown':'ArrowRight',label); + await page.evaluate(()=>{ + globalThis.__gatewaySamples=[]; + globalThis.__gatewayObserver=new MutationObserver(()=>globalThis.__gatewaySamples.push(document.querySelector('.blob-board .tile-player')?.getAttribute('aria-label')??'missing')); + globalThis.__gatewayObserver.observe(document.querySelector('.blob-page'),{attributes:true,childList:true,subtree:true,characterData:true}); + }); + release();await oldRequest;await refetch();await expect(player).toHaveAttribute('aria-label',label); + const samples=await page.evaluate(()=>{globalThis.__gatewayObserver.disconnect();return globalThis.__gatewaySamples;}); + assert.ok(samples.every(sample=>sample===label),'late HTTP/cache observation regressed the Atomic board'); + page.off('request',count);await page.unroute('**/graphql'); + await page.reload();await expect(player).toHaveAttribute('aria-label',label); +} +async function verifyAuth(context,idp,origin){ + const cookies=(await context.cookies()).filter(cookie=>cookie.name.startsWith('authjs.session-token')); + assert.ok(cookies.length&&cookies.every(cookie=>cookie.httpOnly&&cookie.sameSite==='Lax'&&cookie.path==='/')); + await new Promise(resolve=>setTimeout(resolve,6000)); + const response=await context.request.post(origin+'/api/auth/refresh',{headers:{origin}}); + assert.equal(response.status(),200);assert.equal((await response.json()).authenticated,true);assert.ok(idp.refreshes()>0); + idp.failRefresh();await new Promise(resolve=>setTimeout(resolve,6000)); + const failed=await context.request.post(origin+'/api/auth/refresh',{headers:{origin}}); + assert.equal(failed.status(),401);assert.equal((await failed.json()).error,'RefreshAccessTokenError'); + const denied=await context.request.get(origin+'/todos',{maxRedirects:0});assert.equal(denied.status(),303); + const signedOut=await context.request.get(origin+'/signout',{maxRedirects:0});assert.equal(signedOut.status(),303); + assert.ok(!(await context.cookies()).some(cookie=>cookie.name.startsWith('authjs.session-token'))); +} + +async function verifyLiveRace(page,origin){ + let oldFrame,downstream,liveUpdates=0; + await page.routeWebSocket('**/graphql/ws',socket=>{ + const upstream=socket.connectToServer(); + upstream.onMessage(message=>{ + let frame;try{frame=JSON.parse(String(message));}catch{} + if(frame?.type==='next'&&Array.isArray(frame.payload?.data?.chat_messages)){ + liveUpdates++;if(!oldFrame){oldFrame=message;downstream=socket;} + } + socket.send(message); + }); + }); + await page.goto(origin+'/chat');await expect.poll(()=>liveUpdates,{timeout:20000}).toBeGreaterThan(0); + const body='gateway live nonregression'; + await page.locator('#chat-body').fill(body);await page.getByRole('button',{name:/send/i}).click(); + await expect(page.getByText(body,{exact:true})).toBeVisible(); + await expect.poll(()=>liveUpdates,{timeout:20000}).toBeGreaterThan(1); + await page.evaluate(text=>{ + globalThis.__gatewayLiveSamples=[]; + globalThis.__gatewayLiveObserver=new MutationObserver(()=>globalThis.__gatewayLiveSamples.push(document.body.innerText.includes(text))); + globalThis.__gatewayLiveObserver.observe(document.body,{childList:true,subtree:true,characterData:true}); + },body); + downstream.send(oldFrame); + // Give the transport and Svelte render queue an observation window. + await page.waitForTimeout(150); + await expect(page.getByText(body,{exact:true})).toBeVisible(); + const samples=await page.evaluate(()=>{globalThis.__gatewayLiveObserver.disconnect();return globalThis.__gatewayLiveSamples;}); + assert.ok(samples.every(Boolean),'late live observation removed the confirmed message'); + const message=page.locator('.ch-msg',{hasText:body}); + await expect(page.locator('.ch-msg-block',{has:message}).locator('.ch-status-footer')).toHaveText('Delivered'); + await page.reload(); + await expect(page.locator('.ch-msg',{hasText:body})).toBeVisible(); +} diff --git a/tests/e2e-ui/package.json b/tests/e2e-ui/package.json index 9b0f3b3c5..6b1d08ac5 100644 --- a/tests/e2e-ui/package.json +++ b/tests/e2e-ui/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "test:browser": "playwright test", - "test:lifecycle-reload": "node scripts/lifecycle-reload.mjs", + "test:lifecycle-reload": "node --test scripts/lifecycle-command-proof.test.mjs && node scripts/lifecycle-reload.mjs", "test:browser:ui": "playwright test --ui", "test:browser:headed": "playwright test --headed", "test:browser:report": "playwright show-report" diff --git a/tests/e2e-ui/playwright.config.ts b/tests/e2e-ui/playwright.config.ts index 4882bc601..1b3de976b 100644 --- a/tests/e2e-ui/playwright.config.ts +++ b/tests/e2e-ui/playwright.config.ts @@ -27,7 +27,7 @@ function loadEnvFile(file: string) { loadEnvFile(path.join(root, 'e2e-ui.env')); -const baseURL = process.env.E2E_UI_ORIGIN || process.env.UI_URL || 'http://localhost:5180'; +const baseURL = process.env.PUBLIC_ORIGIN || process.env.E2E_UI_ORIGIN || process.env.UI_URL || 'http://localhost:8791'; /** * Browser tests for e2e-ui. diff --git a/tests/e2e-ui/scripts/lifecycle-command-proof.mjs b/tests/e2e-ui/scripts/lifecycle-command-proof.mjs new file mode 100644 index 000000000..89312cf18 --- /dev/null +++ b/tests/e2e-ui/scripts/lifecycle-command-proof.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { setTimeout as delay } from 'node:timers/promises'; + +// Eventual command receipts prove the aggregate commit, not projector completion. +// Query outside the browser replica before asking a fresh SSR page to prove it. +export async function waitForProjectedTodo(query, expected, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + let attempts = 0; + while (Date.now() < deadline) { + const body = await query(Math.max(1, deadline - Date.now())); + attempts += 1; + assert.ok(!body.errors?.length, 'authoritative Todo query must not return GraphQL errors'); + assert.ok(Array.isArray(body.data?.todos), 'authoritative Todo query must return rows'); + const row = body.data.todos.find((todo) => todo.todo_id === expected.todo_id); + if (row) { + assert.deepEqual(row, expected, 'projected Todo must match the committed command payload'); + return attempts; + } + await delay(Math.min(50, Math.max(0, deadline - Date.now()))); + } + throw new Error(`Todo ${expected.todo_id} was accepted but not projected after ${attempts} queries`); +} diff --git a/tests/e2e-ui/scripts/lifecycle-command-proof.test.mjs b/tests/e2e-ui/scripts/lifecycle-command-proof.test.mjs new file mode 100644 index 000000000..5f0ed78c3 --- /dev/null +++ b/tests/e2e-ui/scripts/lifecycle-command-proof.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { waitForProjectedTodo } from './lifecycle-command-proof.mjs'; + +const todo = { todo_id: 'proof-1', owner_id: 'alice', title: 'Persisted', status: 'open' }; + +test('an empty SSR-era read is retried until the exact authoritative row exists', async () => { + let calls = 0; + const attempts = await waitForProjectedTodo(async () => ({ + data: { todos: ++calls < 3 ? [] : [todo] } + }), todo); + assert.equal(attempts, 3); +}); + +test('matching title on another aggregate does not prove this command', async () => { + await assert.rejects(waitForProjectedTodo(async () => ({ + data: { todos: [{ ...todo, todo_id: 'other' }] } + }), todo, 20), /accepted but not projected/); +}); + +test('a stalled projector fails instead of falling back to the receipt or optimism', async () => { + await assert.rejects(waitForProjectedTodo(async () => ({ data: { todos: [] } }), todo, 20), + /accepted but not projected/); +}); + +test('a wrong persisted value fails rather than merely checking row presence', async () => { + await assert.rejects(waitForProjectedTodo(async () => ({ + data: { todos: [{ ...todo, title: 'Wrong' }] } + }), todo), /must match the committed command payload/); +}); + +test('GraphQL failures are not retried as eventual projection lag', async () => { + await assert.rejects(waitForProjectedTodo(async () => ({ errors: [{ message: 'Denied' }] }), todo), + /must not return GraphQL errors/); +}); diff --git a/tests/e2e-ui/scripts/lifecycle-reload.mjs b/tests/e2e-ui/scripts/lifecycle-reload.mjs index 9315477d0..77c823738 100644 --- a/tests/e2e-ui/scripts/lifecycle-reload.mjs +++ b/tests/e2e-ui/scripts/lifecycle-reload.mjs @@ -4,13 +4,15 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; +import { waitForProjectedTodo } from './lifecycle-command-proof.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const frameworkRoot = resolve(root, '../../js'); const application = resolve(root, 'crates/todo-domain/src/commands/force_archive.rs'); +const clientPage = resolve(root, 'ui/src/routes/todos/+page.svelte'); const framework = resolve(frameworkRoot, 'src/sveltekit/lifecycle.ts'); const lifecycleFile = resolve(root, '.distributed/lifecycle/dev.json'); -const baseURL = process.env.E2E_UI_ORIGIN || 'http://127.0.0.1:5180'; +const baseURL = process.env.PUBLIC_ORIGIN || process.env.E2E_UI_ORIGIN || 'http://localhost:8791'; const apiURL = process.env.E2E_API_ORIGIN || 'http://127.0.0.1:8791'; const timeoutMs = 120_000; const lifecycleBuildTimeoutMs = 300_000; @@ -85,6 +87,7 @@ const fixtureIO = kubeWorkload }; const original = fixtureIO.read(application); const frameworkOriginal = fixtureIO.read(framework); +const clientOriginal = fixtureIO.read(clientPage); async function waitFor(predicate, label, timeout = timeoutMs) { const started = Date.now(); @@ -206,7 +209,18 @@ async function transition(page, path, source, expectedReplicaRestore, assertGate }, 'controlled browser reload restoration', lifecycleBuildTimeoutMs - ); + ).catch(async (error) => { + const browserState = await page.evaluate(() => { + const capsule = JSON.parse(sessionStorage.getItem('@hops-ops/distributed/reload-capsule/v1') || 'null'); + return { + generation: document.querySelector('meta[name="distributed-generation"]')?.getAttribute('content'), + capsulePhase: capsule?.phase, + capsuleTarget: capsule?.to?.generationId, + restoredEvents: globalThis.__distributedReloadEvents?.length + }; + }).catch(() => undefined); + throw new Error(`${error.message}; browser=${JSON.stringify(browserState)}`, { cause: error }); + }); assert.equal(restored.replicaCaptured, true, 'authenticated replica must participate'); assert.equal(restored.replicaRestored, expectedReplicaRestore); assert.equal( @@ -229,11 +243,59 @@ async function transition(page, path, source, expectedReplicaRestore, assertGate } page.off('framenavigated', onNavigation); page.off('request', onRequest); + // Readability alone does not prove the retained API reopened its write gate. + const title = `after-reload-${Date.now()}`; + const response = page.waitForResponse((result) => result.request().method() === 'POST' && + (result.request().postData() ?? '').includes('todos_create')); + await page.locator('#todo-title').fill(title); + await page.getByRole('button', { name: /^add$/i }).click(); + const mutation = await response; + assert.equal(mutation.status(), 200, 'active generation must accept commands'); + const body = await mutation.json(); + assert.ok(!body.errors?.length); + assert.equal(body.extensions.distributed.generation.generationId, after.active.generationId); + assert.equal(body.extensions.distributed.generation.releaseId, after.active.releaseId); + assert.ok(body.extensions.distributed.command, 'actual command receipt required'); + const todo = body.data.todos_create; + assert.equal(todo.title, title); + assert.equal(typeof todo.todo_id, 'string'); + const requestHeaders = await mutation.request().allHeaders(); + const headers = Object.fromEntries( + ['authorization', 'x-user-id', 'x-roles'] + .filter((name) => requestHeaders[name] !== undefined) + .map((name) => [name, requestHeaders[name]]) + ); + const attempts = await waitForProjectedTodo(async (remainingMs) => { + const result = await page.request.post(mutation.url(), { + headers, + timeout: remainingMs, + data: { + query: `query ReloadTodoProof($id: String!) { + todos(where: { todo_id: { _eq: $id } }, limit: 1) { + todo_id owner_id title status + } + }`, + variables: { id: todo.todo_id } + } + }); + assert.equal(result.status(), 200, 'authoritative Todo query must succeed'); + return result.json(); + }, todo, timeoutMs); + console.log(`lifecycle-reload: ${relative(root, path)} command accepted and projected (${attempts} queries)`); + // @load is not @live. Reloading before projection can seed an empty result + // that never changes, even though the projector subsequently commits the row. + // Now discard browser optimism and prove fresh SSR + hydration independently. + await page.reload({waitUntil: 'domcontentloaded'}); + await page.locator('[data-todo-id]').filter({hasText: title}).waitFor({timeout: timeoutMs}); + console.log(`lifecycle-reload: ${relative(root, path)} fresh page confirmed ${todo.todo_id}`); + await waitFor(() => page.evaluate(() => globalThis.__distributedReloadState !== undefined), 'hydration after command proof'); + await page.evaluate(() => { globalThis.__distributedReloadState.value = 'preserve-me'; }); + await waitForBrowserParticipant(page); } const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ - storageState: resolve(root, 'e2e/.auth/alice.json') + storageState: process.env.E2E_RELOAD_STORAGE_STATE || resolve(root, 'e2e/.auth/alice.json') }); await context.addInitScript(() => { globalThis.__distributedReloadEvents = []; @@ -267,6 +329,11 @@ try { await page.evaluate(() => { globalThis.__distributedReloadState.value = 'preserve-me'; }); + for (const suffix of ['first', 'second']) { + await transition(page, clientPage, `${clientOriginal}\n\n`, true); + assert.deepEqual((await lifecycleState()).members.api, baseline.members.api, + 'client-only reload must retain the same verified API instance'); + } const compatible = `${original}\n// lifecycle-compatible source-only rebuild\n`; await transition(page, application, compatible, true, true); @@ -280,10 +347,11 @@ try { ); assert.notEqual(incompatible, compatible, 'incompatible fixture edit must apply'); await transition(page, application, incompatible, false); - console.log('lifecycle-reload: application + framework + incompatible transitions OK'); + console.log('lifecycle-reload: client-only + application + framework + incompatible transitions accept commands'); } finally { fixtureIO.write(application, original); fixtureIO.write(framework, frameworkOriginal); + fixtureIO.write(clientPage, clientOriginal); await waitFor(async () => { const state = await lifecycleState(); return state?.phase === 'active' && diff --git a/tests/e2e-ui/scripts/unique-key-connected.mjs b/tests/e2e-ui/scripts/unique-key-connected.mjs new file mode 100644 index 000000000..2d05eae6e --- /dev/null +++ b/tests/e2e-ui/scripts/unique-key-connected.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { build, transform } from '../../../js/node_modules/esbuild/lib/main.js'; +import { chromium, expect } from '../node_modules/@playwright/test/index.mjs'; +import { createDistributedSvelteKitServer, defineDistributedBoundaryBinding, defineDistributedBoundaryOperation } from '../../../js/dist/sveltekit/index.js'; +import { fileURLToPath } from 'node:url'; + +const origin = process.argv[2]; +assert.match(origin, /^http:\/\/127\.0\.0\.1:\d+$/); +const fixture = await (await fetch(`${origin}/fixture`)).json(); +const compiled = await transform(fixture.module, { loader: 'ts', format: 'esm' }); +const artifact = (await import(`data:text/javascript;base64,${Buffer.from(compiled.code).toString('base64')}`))[fixture.exportName]; +const boundary = defineDistributedBoundaryOperation({ operation: 'References', route: '/references', kind: 'page', discovery: 'route_document' }, artifact, defineDistributedBoundaryBinding(artifact, {})); +const server = createDistributedSvelteKitServer({ boundaries: [boundary], getSession: async () => null, getRole: () => 'user' }); +let ssrFetches = 0; +const loaded = await server.load({ locals: {}, route: { id: '/references' }, url: new URL(`${origin}/references`), + fetch(url, init) { ssrFetches++; return fetch(new URL(url, origin), init); } }); +assert.equal(loaded.gqlError, null); +assert.equal(ssrFetches, 1); +const runtime = fileURLToPath(new URL('../../../js/dist/sveltekit/index.js', import.meta.url)); +const bundle = await build({ stdin: { contents: `import { createDistributedSvelteKit, defineDistributedBoundaryOperation, defineDistributedBoundaryBinding } from ${JSON.stringify(runtime)}; +globalThis.mountProof = (artifact, loaded) => { + const boundary = defineDistributedBoundaryOperation({ operation: 'References', route: '/references', kind: 'page', discovery: 'route_document' }, artifact, defineDistributedBoundaryBinding(artifact, {})); + const client = createDistributedSvelteKit({ boundaries: [boundary], hydration: loaded.distributed, + authority: loaded.distributedAuthority, session: { getAuth: () => ({}) } }); + const view = client.operation(artifact).use(); + const unsubscribe = view.subscribe(snapshot => { document.querySelector('output').textContent = JSON.stringify(snapshot.data); }); + globalThis.closeProof = () => { unsubscribe(); client.destroy(); }; +};`, loader: 'js', resolveDir: process.cwd() }, bundle: true, write: false, platform: 'browser', format: 'iife' }); +const browser = await chromium.launch(); +try { + const page = await browser.newPage(); + const browserErrors = []; + page.on('pageerror', error => browserErrors.push(error.message)); + let graphqlFetches = 0; + const sockets = []; + let liveFrames = 0; + let closed = false; + page.on('request', request => { if (request.url() === `${origin}/graphql`) graphqlFetches++; }); + page.on('websocket', socket => { + sockets.push(socket); + socket.on('socketerror', error => browserErrors.push(String(error))); + socket.on('framereceived', event => { if (JSON.parse(String(event.payload)).type === 'next') liveFrames++; }); + socket.on('close', () => { closed = true; }); + }); + await page.goto(`${origin}/references`); + await page.addScriptTag({ content: bundle.outputFiles[0].text }); + await page.evaluate(({ artifact, loaded }) => globalThis.mountProof(artifact, loaded), { artifact, loaded }); + await page.waitForFunction(() => document.querySelector('output').textContent.includes('opaque-one')); + assert.equal(graphqlFetches, 0, 'SSR seed must avoid browser mount fetch'); + await expect.poll(() => liveFrames).toBeGreaterThan(0); + assert.equal(sockets.length, 1, 'real GraphQL WebSocket opened'); + const published = await fetch(`${origin}/publish`, { method: 'POST' }); + assert.equal(published.status, 200); + await page.waitForFunction(() => document.querySelector('output').textContent.includes('opaque-two')); + assert.deepEqual(JSON.parse(await page.locator('output').textContent()), { reference_views: [ + { id: 'reference-stable', target: { id: 'opaque-two', body: 'Content two' } } + ] }); + assert.equal(graphqlFetches, 0, 'live update must arrive via WebSocket'); + await page.evaluate(() => globalThis.closeProof()); + await expect.poll(() => closed).toBe(true); + assert.deepEqual(browserErrors, []); + console.log('Connected SQLite/projector → GraphQL HTTP SSR → Chromium → WebSocket update passed'); +} finally { await browser.close(); } diff --git a/tests/e2e-ui/scripts/up.sh b/tests/e2e-ui/scripts/up.sh index 57097eed6..b3edaebcc 100755 --- a/tests/e2e-ui/scripts/up.sh +++ b/tests/e2e-ui/scripts/up.sh @@ -10,7 +10,7 @@ ZITADEL_HOST="${ZITADEL_HOST:-http://localhost:18080}" OUT="${E2E_UI_ENV:-$ROOT/e2e-ui.env}" # Prefer localhost (not 127.0.0.1): SvelteKit only skips Secure cookies for # hostname === "localhost" over http. 127.0.0.1 forces Secure and breaks OIDC. -UI_ORIGIN="${E2E_UI_ORIGIN:-http://localhost:5180}" +UI_ORIGIN="${PUBLIC_ORIGIN:-${E2E_UI_ORIGIN:-http://localhost:8791}}" API_ORIGIN="${E2E_API_ORIGIN:-http://127.0.0.1:8791}" PROJECT_NAME="${E2E_OIDC_PROJECT:-e2e-ui}" APP_NAME="${E2E_OIDC_APP:-e2e-ui-web}" @@ -298,8 +298,8 @@ if [[ -z "$APP_ID" ]]; then redirectUris: [ ($ui + "/auth/callback/oidc"), ($ui + "/auth/callback"), - "http://127.0.0.1:5180/auth/callback/oidc", - "http://localhost:5180/auth/callback/oidc" + "http://127.0.0.1:8791/auth/callback/oidc", + "http://localhost:8791/auth/callback/oidc" ], responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], grantTypes: [ @@ -311,10 +311,10 @@ if [[ -z "$APP_ID" ]]; then postLogoutRedirectUris: [ ($ui + "/"), $ui, - "http://127.0.0.1:5180/", - "http://127.0.0.1:5180", - "http://localhost:5180/", - "http://localhost:5180" + "http://127.0.0.1:8791/", + "http://127.0.0.1:8791", + "http://localhost:8791/", + "http://localhost:8791" ], version: "OIDC_VERSION_1_0", devMode: true, @@ -341,8 +341,8 @@ else redirectUris: [ ($ui + "/auth/callback/oidc"), ($ui + "/auth/callback"), - "http://127.0.0.1:5180/auth/callback/oidc", - "http://localhost:5180/auth/callback/oidc" + "http://127.0.0.1:8791/auth/callback/oidc", + "http://localhost:8791/auth/callback/oidc" ], responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], grantTypes: [ @@ -354,10 +354,10 @@ else postLogoutRedirectUris: [ ($ui + "/"), $ui, - "http://127.0.0.1:5180/", - "http://127.0.0.1:5180", - "http://localhost:5180/", - "http://localhost:5180" + "http://127.0.0.1:8791/", + "http://127.0.0.1:8791", + "http://localhost:8791/", + "http://localhost:8791" ], devMode: true, accessTokenType: "OIDC_TOKEN_TYPE_JWT", @@ -602,6 +602,8 @@ ZITADEL_PROJECT_ID="$(_dq "$PROJECT_ID")" # Server-only: custom /login uses Session API + CreateCallback (never expose to browser). ZITADEL_SERVICE_USER_TOKEN="$(_dq "$LOGIN_PAT")" LOGIN_V2_BASE_URI="$(_dq "$LOGIN_V2_BASE_URI")" +PUBLIC_ORIGIN="$(_dq "$UI_ORIGIN")" +UI_INTERNAL_ORIGIN="http://localhost:5180" E2E_UI_ORIGIN="$(_dq "$UI_ORIGIN")" E2E_API_ORIGIN="$(_dq "$API_ORIGIN")" E2E_MACHINE_USER_KEY="$(_dq "$USER_M_KEY")" diff --git a/tests/e2e-ui/ui/src/auth.ts b/tests/e2e-ui/ui/src/auth.ts index 4ff763233..49e888ad0 100644 --- a/tests/e2e-ui/ui/src/auth.ts +++ b/tests/e2e-ui/ui/src/auth.ts @@ -1,4 +1,5 @@ import { SvelteKitAuth } from '@auth/sveltekit'; +import type { Handle } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { cleanEnvValue } from '$lib/clean-env'; import { oidcAudience, oidcScopes } from '$lib/server/oidc-scopes'; @@ -223,7 +224,7 @@ function userClaims(token: TokenRecord) { return decodeJwtPayload(token.idToken) ?? decodeJwtPayload(token.accessToken) ?? {}; } -export const { handle, signIn, signOut } = SvelteKitAuth({ +const { handle: authHandle, signIn, signOut } = SvelteKitAuth({ providers: [ { id: 'oidc', @@ -240,7 +241,7 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ scope: oidcScopes() } }, - checks: ['pkce', 'state'], + checks: ['pkce', 'state', 'nonce'], profile(profile: Record) { const groupClaims = envCsv('OIDC_GROUP_CLAIMS', DEFAULT_GROUP_CLAIMS); const name = @@ -262,7 +263,10 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ ], callbacks: { async jwt({ token, account, user, profile }) { + // Auth.js may assign an internal user UUID. UI and API identity must + // use the same OIDC subject established by the provider callback. if (account) { + token.sub = account.providerAccountId; token.accessToken = account.access_token; token.refreshToken = account.refresh_token; token.idToken = account.id_token; @@ -293,7 +297,7 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ if (groups.length) refreshed.groups = groups; return refreshed; } catch (error) { - console.error('Token refresh failed:', error); + console.error('Token refresh failed'); token.error = 'RefreshAccessTokenError'; return token; } @@ -347,10 +351,8 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ signIn: '/', error: '/' }, - // HARD false for this local fixture. Auth.js defaults secure from request - // protocol / AUTH_URL; on plain http://127.0.0.1 Secure cookies are dropped - // by the browser and every protected page looks broken (session never sticks). - // Production HTTPS deploys must set AUTH_USE_SECURE_COOKIES=true (or AUTH_URL=https…). + // Cookie overrides and Auth.js defaults share the configured public-origin + // policy. Local HTTP fixtures remain usable; HTTPS sets Secure throughout. useSecureCookies: useSecureCookies(), cookies: { sessionToken: { @@ -359,7 +361,7 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ httpOnly: true, sameSite: authSessionCookieSameSite(), path: '/', - secure: false + secure: useSecureCookies() } }, callbackUrl: { @@ -368,7 +370,7 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ httpOnly: true, sameSite: authSessionCookieSameSite(), path: '/', - secure: false + secure: useSecureCookies() } }, csrfToken: { @@ -377,7 +379,7 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ httpOnly: true, sameSite: authSessionCookieSameSite(), path: '/', - secure: false + secure: useSecureCookies() } }, pkceCodeVerifier: { @@ -386,7 +388,7 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ httpOnly: true, sameSite: authSessionCookieSameSite(), path: '/', - secure: false, + secure: useSecureCookies(), maxAge: 60 * 15 } }, @@ -396,7 +398,7 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ httpOnly: true, sameSite: authSessionCookieSameSite(), path: '/', - secure: false, + secure: useSecureCookies(), maxAge: 60 * 15 } } @@ -474,3 +476,19 @@ async function oidcTokenEndpoint() { } export { engineRoleFromGroups } from './lib/roles'; + +// Auth.js session renewal parses Set-Cookie before calling event.cookies.set. +// An absent Secure attribute becomes undefined, which SvelteKit defaults to +// true on HTTP 127.0.0.1. Preserve our explicit policy during that delegation, +// including cookies renewed by locals.auth() on UI/API requests. +export const handle: Handle = ({ event, resolve }) => { + const setCookie = event.cookies.set.bind(event.cookies); + event.cookies.set = (name, value, options) => { + if (name.startsWith('authjs.')) { + return setCookie(name, value, { ...options, secure: useSecureCookies() }); + } + return setCookie(name, value, options); + }; + return authHandle({ event, resolve }); +}; +export { signIn, signOut }; diff --git a/tests/e2e-ui/ui/src/lib/components/shared/AuthRefresh.svelte b/tests/e2e-ui/ui/src/lib/components/shared/AuthRefresh.svelte index 274b05be8..0f32da8b8 100644 --- a/tests/e2e-ui/ui/src/lib/components/shared/AuthRefresh.svelte +++ b/tests/e2e-ui/ui/src/lib/components/shared/AuthRefresh.svelte @@ -2,6 +2,9 @@ import { browser } from '$app/environment'; import { invalidateAll } from '$app/navigation'; import { page } from '$app/state'; + import type { SveltekitDistributedPageData } from '@hops-ops/distributed/sveltekit'; + + let { onRefresh }: { onRefresh: (data: SveltekitDistributedPageData) => void } = $props(); const MIN_REFRESH_DELAY_MS = 5_000; const RETRY_DELAY_MS = 30_000; @@ -23,10 +26,21 @@ method: 'POST', credentials: 'same-origin', headers: { - accept: 'application/json' - } + accept: 'application/json', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + id: page.route.id, + path: page.url.pathname + page.url.search, + params: page.params + }) }); + if (response.ok) { + const result = await response.json(); + if (result.pageData) onRefresh(result.pageData); + } + if (response.ok || response.status === 401) { await invalidateAll(); return; diff --git a/tests/e2e-ui/ui/src/lib/server/auth-refresh.ts b/tests/e2e-ui/ui/src/lib/server/auth-refresh.ts new file mode 100644 index 000000000..e0e393a32 --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/server/auth-refresh.ts @@ -0,0 +1,28 @@ +import { json, type RequestEvent, type RequestHandler } from '@sveltejs/kit'; +import { isCurrentSession } from '$lib/server/require-auth'; + +type CurrentSession = NonNullable>>; +type PageDataLoader = (event: RequestEvent, session: CurrentSession) => Promise; + +/** Share auth refresh independently of an application's optional GraphQL loader. */ +export function createAuthRefreshHandler(loadPageData?: PageDataLoader): RequestHandler { + return async (event) => { + const session = await event.locals.auth(); + if (!isCurrentSession(session)) { + return json({ authenticated: false, error: session?.error }, { status: 401 }); + } + + const pageData = await loadPageData?.(event, session); + return json({ + pageData, + authenticated: true, + expires: session.expires, + expiresAt: session.expiresAt, + refreshAfter: session.refreshAfter, + hasAccessToken: session.hasAccessToken, + hasRefreshToken: session.hasRefreshToken, + hasIdToken: session.hasIdToken, + error: session.error + }, { headers: { 'cache-control': 'no-store' } }); + }; +} diff --git a/tests/e2e-ui/ui/src/lib/server/distributed.ts b/tests/e2e-ui/ui/src/lib/server/distributed.ts new file mode 100644 index 000000000..a29285b6c --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/server/distributed.ts @@ -0,0 +1,25 @@ +import { + createDistributedSvelteKitServer, + type SveltekitServerLoadEventLike +} from '@hops-ops/distributed/sveltekit'; + +import { DISTRIBUTED_BOUNDARY_OPERATIONS } from '$distributed'; +import { engineRoleFromGroups } from '$lib/roles'; +import { graphqlHttpUrl } from '$lib/server/graphql'; + +type LoadEvent = SveltekitServerLoadEventLike; +type Session = NonNullable< + Awaited> +>; + +/** + * One root loader owns every compiler-discovered user-safe `@load` operation. + * A fresh replica is created per request and no GraphQL work runs for routes + * absent from the generated registry. + */ +export const distributed = createDistributedSvelteKitServer({ + boundaries: DISTRIBUTED_BOUNDARY_OPERATIONS, + getSession: (event) => event.locals.auth(), + getRole: (session) => engineRoleFromGroups(session?.user?.groups), + getUrl: graphqlHttpUrl +}); diff --git a/tests/e2e-ui/ui/src/lib/server/require-auth.ts b/tests/e2e-ui/ui/src/lib/server/require-auth.ts index a6682d2b9..e2096c330 100644 --- a/tests/e2e-ui/ui/src/lib/server/require-auth.ts +++ b/tests/e2e-ui/ui/src/lib/server/require-auth.ts @@ -5,16 +5,23 @@ */ import { redirect } from '@sveltejs/kit'; +type AuthSession = { user?: unknown; error?: string; expiresAt?: number }; type AuthLocals = { - auth: () => Promise<{ user?: unknown } | null>; + auth: () => Promise; }; +/** Share the current-session check between protected UI and refresh/API routes. */ +export function isCurrentSession(session: AuthSession | null): session is AuthSession & { user: NonNullable } { + return !!session?.user && !session.error && + (session.expiresAt === undefined || session.expiresAt > Date.now() / 1000); +} + export async function requireAuth( event: { locals: AuthLocals; url: URL }, options?: { fallbackPath?: string } ): Promise>>> { const session = await event.locals.auth(); - if (session?.user) { + if (isCurrentSession(session)) { return session; } diff --git a/tests/e2e-ui/ui/src/routes/+layout.server.ts b/tests/e2e-ui/ui/src/routes/+layout.server.ts index 524e8d8ed..12995514b 100644 --- a/tests/e2e-ui/ui/src/routes/+layout.server.ts +++ b/tests/e2e-ui/ui/src/routes/+layout.server.ts @@ -1,28 +1,6 @@ -import { createDistributedSvelteKitServer } from '@hops-ops/distributed/sveltekit'; - -import { DISTRIBUTED_BOUNDARY_OPERATIONS } from '$distributed'; -import { engineRoleFromGroups } from '$lib/roles'; -import { graphqlHttpUrl } from '$lib/server/graphql'; - +import { distributed } from '$lib/server/distributed'; import type { LayoutServerLoad } from './$types'; -type LoadEvent = Parameters[0]; -type Session = NonNullable< - Awaited> ->; - -/** - * One root loader owns every compiler-discovered user-safe `@load` operation. - * A fresh replica is created per request and no GraphQL work runs for routes - * absent from the generated registry. - */ -const distributed = createDistributedSvelteKitServer({ - boundaries: DISTRIBUTED_BOUNDARY_OPERATIONS, - getSession: (event) => event.locals.auth(), - getRole: (session) => engineRoleFromGroups(session?.user?.groups), - getUrl: graphqlHttpUrl -}); - /** * Unauthenticated traffic skips the portable user GraphQL surface (no Bearer / * empty identity cannot open e2e-ui). Lobby chat SSR uses the nested public diff --git a/tests/e2e-ui/ui/src/routes/+layout.svelte b/tests/e2e-ui/ui/src/routes/+layout.svelte index bcb6ce59d..e8425bb0a 100644 --- a/tests/e2e-ui/ui/src/routes/+layout.svelte +++ b/tests/e2e-ui/ui/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import type { Snippet } from 'svelte'; import { createPageDataSessionSource, + type SveltekitDistributedPageData, type SveltekitReplicaHydration } from '@hops-ops/distributed/sveltekit'; import { DISTRIBUTED_BOUNDARY_OPERATIONS, provideDistributed } from '$distributed'; @@ -19,7 +20,7 @@ let { data, children }: { data: LayoutData; children: Snippet } = $props(); const initialData = untrack(() => data); - const pageData = createPageDataSessionSource(initialData); + const pageData = createPageDataSessionSource(initialData); let appliedHydration: SveltekitReplicaHydration | undefined = initialData.distributed; let hydrationTimer: ReturnType | undefined; @@ -59,17 +60,17 @@ : {}) }); - $effect(() => { - pageData.set(data); + function applyPageData(next: SveltekitDistributedPageData) { + pageData.set(next); if ( - data.distributed === undefined || - data.distributedAuthority === undefined || - data.distributed === appliedHydration + next.distributed === undefined || + next.distributedAuthority === undefined || + next.distributed === appliedHydration ) { return; } - appliedHydration = data.distributed; + appliedHydration = next.distributed; if (hydrationTimer !== undefined) clearTimeout(hydrationTimer); // Session listeners fence an old credential in the microtask queue. // Apply the separately-authorized navigation seed after that fence. @@ -77,9 +78,11 @@ // confirmed keys omitted from this route seed are retained. hydrationTimer = setTimeout(() => { hydrationTimer = undefined; - client.hydrate(data.distributed!, data.distributedAuthority!); + client.hydrate(next.distributed!, next.distributedAuthority!); }, 0); - }); + } + + $effect(() => applyPageData(data)); $effect(() => { if (!browser || !data.session?.user) return; @@ -121,7 +124,8 @@ - + + pageData.set(next)} />
{@render children()} diff --git a/tests/e2e-ui/ui/src/routes/api/auth/refresh/+server.ts b/tests/e2e-ui/ui/src/routes/api/auth/refresh/+server.ts index 33ef13f0f..539993895 100644 --- a/tests/e2e-ui/ui/src/routes/api/auth/refresh/+server.ts +++ b/tests/e2e-ui/ui/src/routes/api/auth/refresh/+server.ts @@ -1,21 +1,45 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; +import { error } from '@sveltejs/kit'; +import { distributed } from '$lib/server/distributed'; +import { createAuthRefreshHandler } from '$lib/server/auth-refresh'; -export const POST: RequestHandler = async ({ locals }) => { - const session = await locals.auth(); +export const POST = createAuthRefreshHandler(async (event, session) => { + const { locals } = event; + // A refresh may change the bearer credential without changing effective + // permissions. Supply a new, independently authorized route seed so the + // browser can prove same-scope reuse before fencing the old credential. + // Route selection is input, never authority: the generated user surface + // and GraphQL executor still authorize every selected operation. - if (!session?.user) { - return json({ authenticated: false }, { status: 401 }); + if (event.request.headers.get('content-type')?.includes('application/json')) { + let route; + try { + route = await event.request.json(); + } catch { + error(400, 'Invalid route'); + } + if ( + typeof route?.id !== 'string' || + typeof route?.path !== 'string' || + !route.path.startsWith('/') || + route.path.startsWith('//') || + route.params === null || + typeof route.params !== 'object' || + Array.isArray(route.params) || + Object.values(route.params).some(value => typeof value !== 'string') + ) { + error(400, 'Invalid route'); + } + const url = new URL(route.path, event.url.origin); + if (url.origin !== event.url.origin) { + error(400, 'Invalid route'); + } + return distributed.load({ + ...event, + locals: { ...locals, auth: async () => session }, + isDataRequest: false, + url, + route: { id: route.id }, + params: route.params + }); } - - return json({ - authenticated: true, - expires: session.expires, - expiresAt: session.expiresAt, - refreshAfter: session.refreshAfter, - hasAccessToken: session.hasAccessToken, - hasRefreshToken: session.hasRefreshToken, - hasIdToken: session.hasIdToken, - error: session.error - }); -}; +}); diff --git a/tests/e2e-ui/ui/vite.config.ts b/tests/e2e-ui/ui/vite.config.ts index 885b77610..2cc0e23bb 100644 --- a/tests/e2e-ui/ui/vite.config.ts +++ b/tests/e2e-ui/ui/vite.config.ts @@ -1,13 +1,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; -import { - distributedGraphqlProxy, - distributedSvelteKit -} from '@hops-ops/distributed/sveltekit/vite'; +import { distributedSvelteKit } from '@hops-ops/distributed/sveltekit/vite'; import { defineConfig } from 'vite'; import { distributedViteOptions } from './distributed.config.js'; -const api = process.env.E2E_API_ORIGIN || process.env.E2E_BASE_URL || 'http://127.0.0.1:8791'; const uiPort = Number(process.env.UI_PORT || '5180'); if (!Number.isSafeInteger(uiPort) || uiPort < 1 || uiPort > 65_535) { throw new TypeError('UI_PORT must be an integer from 1 through 65535'); @@ -25,9 +21,9 @@ export default defineConfig({ port: uiPort, // hops local cluster-DNS mode uses svc.ns.svc.cluster.local Host headers. host: true, - allowedHosts: true, - // GraphQL-only public API (commands are mutations, not POST /todo.*). - proxy: distributedGraphqlProxy(api) + allowedHosts: true + // Public UI/API traffic enters the backend gateway. This internal UI + // server has no reverse API proxy, so it cannot loop back into ingress. }, optimizeDeps: { exclude: ['$lib/blob/pkg/blob_wasm.js'] diff --git a/tests/e2e_ui_celld_nats_profile/main.rs b/tests/e2e_ui_celld_nats_profile/main.rs index 1be55cb69..d06f30526 100644 --- a/tests/e2e_ui_celld_nats_profile/main.rs +++ b/tests/e2e_ui_celld_nats_profile/main.rs @@ -77,11 +77,12 @@ mod live { CelldCommandHost, CelldRoute, InternalHttpSecret, CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, }; - use distributed::command_dispatch::SharedCommandHost; - use distributed::graphql::{ - read, typed_command, GraphqlEngine, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, - GraphqlTypeField, ModelPermissions, Succeeded, VerifiedPrincipal, + use distributed::command::{ + typed_command, CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField, + Succeeded, }; + use distributed::command_dispatch::SharedCommandHost; + use distributed::graphql::{read, GraphqlEngine, ModelPermissions, VerifiedPrincipal}; use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; use distributed::{ Aggregate, AggregateBuilder, Entity, InMemoryRepository, ReadModel, Snapshot, @@ -156,12 +157,12 @@ mod live { title: String, } - impl GraphqlInputType for CreateInput { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( + impl CommandInputType for CreateInput { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "CreateInput", vec![ - GraphqlTypeField { + CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -169,7 +170,7 @@ mod live { item_nullable: false, nested: None, }, - GraphqlTypeField { + CommandTypeField { name: "title".into(), type_name: "String".into(), nullable: false, @@ -188,11 +189,11 @@ mod live { id: String, } - impl GraphqlOutputType for IdPayload { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( + impl CommandOutputType for IdPayload { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "IdPayload", - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, diff --git a/tests/edge_query_delivery.rs b/tests/edge_query_delivery.rs new file mode 100644 index 000000000..697d3cd3a --- /dev/null +++ b/tests/edge_query_delivery.rs @@ -0,0 +1,415 @@ +#![cfg(feature = "gateway-delivery")] +use distributed::gateway::delivery::*; +use serde_json::json; +fn identity(subject: &str) -> OriginIdentity { + OriginIdentity { + application: "todo".into(), + endpoint: "api".into(), + schema_hash: "schema-1".into(), + protocol_hash: "protocol-1".into(), + authorization_generation: "policy-1".into(), + cache_scope: subject.into(), + } +} +fn context() -> FreshnessContext { + FreshnessContext::parse(&json!({"version":1,"schemaHash":"schema-1","protocolHash":"protocol-1","authorizationGeneration":"policy-1","cacheScope":"alice","pending":[],"minimum":[]})).unwrap() +} +fn models(names: &[&str]) -> Dependencies { + Dependencies { + complete: true, + models: names.iter().map(|v| v.to_string()).collect(), + relationships: Default::default(), + } +} +fn index(scope: &str, position: &str) -> Minimum { + Minimum::Index { + projection: "todos".into(), + scope_token: scope.into(), + position: position.into(), + } +} +#[test] +fn origin_identity_canonical_variables_and_selected_operation() { + let alice = identity("alice"); + let bob = identity("bob"); + let a = json!({"query":"query A($filter: Input) { todos(filter: $filter) { title } } query B { count }", "operationName":"A", "variables":{"filter":{"a":1,"b":[2,3]}}}); + let mut b = a.clone(); + b["variables"] = json!({"filter":{"b":[2,3],"a":1}}); + assert_eq!( + OperationKey::from_origin(&alice, &a), + OperationKey::from_origin(&alice, &b) + ); + assert_ne!( + OperationKey::from_origin(&alice, &a), + OperationKey::from_origin(&bob, &a) + ); + b["operationName"] = "B".into(); + assert_ne!( + OperationKey::from_origin(&alice, &a), + OperationKey::from_origin(&alice, &b) + ); + b["operationName"] = "Absent".into(); + assert!(OperationKey::from_origin(&alice, &b).is_err()); + for document in [ + "mutation { edit }", + "query { commandStatus(commandId: \"one\") { state } }", + "query { todos { id } commandStatus(commandId: \"one\") { state } }", + ] { + assert!(OperationKey::from_origin(&alice, &json!({"query":document})).is_err()); + } + let mut changed = alice.clone(); + changed.authorization_generation = "policy-2".into(); + assert_ne!( + OperationKey::from_origin(&alice, &a), + OperationKey::from_origin(&changed, &a) + ); + let mut order = a.clone(); + order["variables"]["filter"]["b"] = json!([3, 2]); + assert_ne!( + OperationKey::from_origin(&alice, &a), + OperationKey::from_origin(&alice, &order) + ); +} +#[test] +fn eventual_pending_and_confirmed_minimum() { + let mut request = context(); + request.pending.push(models(&["Todo"])); + for dependency in [ + models(&["Todo"]), + models(&["Count", "Todo"]), + models(&["Todo", "Owner"]), + Dependencies::default(), + ] { + assert_eq!( + read_target(ReadConsistency::StaleTolerant, &dependency, Some(&request)), + ReadTarget::Primary + ); + } + assert_eq!( + read_target( + ReadConsistency::StaleTolerant, + &models(&["Blob"]), + Some(&request) + ), + ReadTarget::Replica + ); + request.observe([index("scope-1", "2")]).unwrap(); + request.pending.clear(); + assert!(!request.satisfied_by(&[index("scope-1", "1")])); + assert!(request.satisfied_by(&[index("scope-1", "2")])); + assert_eq!( + read_target( + ReadConsistency::StaleTolerant, + &models(&["Todo"]), + Some(&request) + ), + ReadTarget::Primary + ); + request + .observe([index("scope-1", "3"), index("incomparable", "1")]) + .unwrap(); + assert_eq!(request.minimum.len(), 2); + assert!(!request.satisfied_by(&[index("scope-1", "999")])); +} +#[test] +fn atomic_minimum_survives_confirmation() { + let mut request = context(); + let record = |incarnation: &str, revision: &str| Minimum::Record { + model: "Blob".into(), + scope_token: "record-1".into(), + incarnation: incarnation.into(), + revision: revision.into(), + }; + request.observe([record("1", "9")]).unwrap(); + assert!(request.pending.is_empty()); + assert!(!request.satisfied_by(&[record("1", "8")])); + request.observe([record("2", "1")]).unwrap(); + assert_eq!(request.minimum, [record("2", "1")]); + assert!(!request.satisfied_by(&[record("1", "999")])); + assert!(request.satisfied_by(&[record("2", "1")])); + assert_eq!( + request.bind(&identity("bob")), + Err(DeliveryError::ScopeChanged) + ); +} +#[test] +fn invalid_context_never_weakens_routing() { + let value = serde_json::to_value(context()).unwrap(); + for field in ["version", "cacheScope", "minimum", "pending"] { + let mut forged = value.clone(); + forged[field] = json!(-1); + assert!(FreshnessContext::parse(&forged).is_err()); + } + let mut large = value.clone(); + large["minimum"] = json!(vec![ + serde_json::to_value(index("scope", "1")).unwrap(); + 257 + ]); + assert!(FreshnessContext::parse(&large).is_err()); + assert!(index("scope", "18446744073709551616").validate().is_err()); + assert!(index("scope", "01").validate().is_err()); + assert_eq!( + read_target(ReadConsistency::Current, &models(&[]), None), + ReadTarget::Primary + ); +} + +fn admission(validator: &str) -> OriginAdmission { + let identity = identity("alice"); + OriginAdmission { + key: OperationKey::from_origin(&identity, &json!({"query":"{ todos { title } }"})).unwrap(), + identity, + operation: "document-fingerprint".into(), + validator: validator.into(), + validated_at: 100, + expires_at: 200, + policy: SnapshotPolicy::Current, + } +} +fn snapshot(admission: &OriginAdmission) -> SnapshotResponse { + SnapshotResponse { status: 200, headers: vec![("content-type".into(), "application/json".into())], body: serde_json::to_vec(&json!({ + "data":{"todos":[]}, "extensions": { + "gatewayDelivery":{"validator":admission.validator}, + "distributed": {"protocolVersion":1,"schemaHash":admission.identity.schema_hash, + "authorizationGeneration":admission.identity.authorization_generation,"cacheScope":admission.identity.cache_scope, + "operation":admission.operation,"snapshot":{"recordsComplete":true,"indexesComparable":true,"records":[], + "indexes":[{"projection":"todos","scopeToken":"scope","position":"2"}]}} + } + })).unwrap() } +} +#[test] +fn private_validation_public_age_and_late_fill_fence() { + let mut cache = SnapshotCache::new(SnapshotLimits::default()).unwrap(); + let first = admission("v1"); + let body = snapshot(&first); + let ticket = cache.begin_fill(&first, 100).unwrap(); + assert!(cache + .install(ticket, first.clone(), body.clone(), 100) + .unwrap()); + assert_eq!( + cache.lookup(&first, None, 101).unwrap().unwrap().body, + body.body + ); + let newer = admission("v2"); + assert!(cache.lookup(&newer, None, 101).unwrap().is_none()); + assert_eq!(cache.metrics().hits, 1); + assert_eq!(cache.metrics().misses, 1); + assert_eq!(cache.metrics().stale_rejections, 1); + let ticket = cache.begin_fill(&first, 100).unwrap(); + assert!( + !cache + .install(ticket, newer.clone(), body.clone(), 101) + .unwrap(), + "old bytes cannot acquire new version" + ); + let late = cache.begin_fill(&first, 100).unwrap(); + cache.invalidate_all(); + assert!(!cache + .install(late, first.clone(), body.clone(), 101) + .unwrap()); + assert!(cache.is_empty()); + assert_eq!(cache.metrics().invalidations, 1); + assert_eq!(cache.metrics().fill_bypasses, 2); + let labels = serde_json::to_value(cache.metrics()).unwrap(); + assert_eq!(labels.as_object().unwrap().len(), 5); + assert!(labels + .as_object() + .unwrap() + .values() + .all(serde_json::Value::is_u64)); + let mut public = first.clone(); + public.policy = SnapshotPolicy::Public { + max_age_seconds: 10, + }; + let ticket = cache.begin_fill(&public, 100).unwrap(); + assert!(cache + .install(ticket, public.clone(), body.clone(), 100) + .unwrap()); + let mut current = public.clone(); + current.validator = "v2".into(); + current.validated_at = 108; + assert!(cache.lookup(¤t, None, 108).unwrap().is_some()); + current.validated_at = 111; + assert!( + cache.lookup(¤t, None, 111).unwrap().is_none(), + "fresh admission cannot renew old public age" + ); + assert!( + cache.lookup(¤t, None, 200).is_err(), + "expired consumer cannot reuse public entry" + ); + current.policy = SnapshotPolicy::Current; + assert!(cache.lookup(¤t, None, 112).unwrap().is_none()); +} +#[test] +fn cache_envelope_eligibility_freshness_and_capacity() { + let admission = admission("v1"); + let valid = snapshot(&admission); + let mut floor = context(); + floor.observe([index("scope", "3")]).unwrap(); + assert!(!valid.satisfies(&admission, Some(&floor))); + let mut floor = context(); + floor.observe([index("scope", "2")]).unwrap(); + assert!(valid.satisfies(&admission, Some(&floor))); + for (name, value) in [ + ("Set-Cookie", "session=secret"), + ("Cache-Control", "private, no-store"), + ("Vary", "*"), + ] { + let mut response = valid.clone(); + response.headers.push((name.into(), value.into())); + assert!(!response.satisfies(&admission, None)); + } + for path in ["errors", "partial", "command", "protocol", "null"] { + let mut response = valid.clone(); + let mut value: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + match path { + "errors" => value["errors"] = json!([{"message":"denied"}]), + "partial" => { + value["extensions"]["distributed"]["snapshot"]["recordsComplete"] = false.into() + } + "command" => value["extensions"]["distributed"]["command"] = json!({}), + "protocol" => value["extensions"]["distributed"]["protocolVersion"] = 2.into(), + _ => value["data"] = serde_json::Value::Null, + } + response.body = serde_json::to_vec(&value).unwrap(); + assert!(!response.satisfies(&admission, None), "{path}"); + } + let mut cache = SnapshotCache::new(SnapshotLimits { + entries: 1, + bytes: 4096, + entry_bytes: 2048, + }) + .unwrap(); + let ticket = cache.begin_fill(&admission, 100).unwrap(); + assert!(cache + .install(ticket, admission.clone(), valid.clone(), 100) + .unwrap()); + let mut other = admission.clone(); + other.identity.cache_scope = "bob".into(); + other.key = OperationKey::from_origin(&other.identity, &json!({"query":"{ todos { title } }"})) + .unwrap(); + let ticket = cache.begin_fill(&other, 100).unwrap(); + assert!(cache + .install(ticket, other.clone(), snapshot(&other), 100) + .unwrap()); + assert_eq!(cache.len(), 1); + assert!(cache.lookup(&admission, None, 100).unwrap().is_none()); + let mut oversized = snapshot(&other); + oversized.body.resize(4096, b' '); + let ticket = cache.begin_fill(&other, 100).unwrap(); + assert!(!cache.install(ticket, other, oversized, 100).unwrap()); +} + +#[test] +fn flight_admission_limits_freshness_and_generation_fences() { + let request = json!({"query":"{ todos { title } }"}); + let admission = admission("v1"); + let key = FlightKey::admitted(&admission, &request, None, 100).unwrap(); + let mut stronger = context(); + stronger.observe([index("scope", "3")]).unwrap(); + assert_ne!( + key, + FlightKey::admitted(&admission, &request, Some(&stronger), 100).unwrap() + ); + let mut later = admission.clone(); + later.validator = "v2".into(); + assert_ne!( + key, + FlightKey::admitted(&later, &request, None, 100).unwrap() + ); + assert!(FlightKey::admitted(&admission, &request, None, 200).is_err()); + let mut forged = context(); + forged.cache_scope = "bob".into(); + assert!(FlightKey::admitted(&admission, &request, Some(&forged), 100).is_err()); + let mut registry = FlightRegistry::new(FlightLimits { + groups: 1, + consumers: 100, + deadline_ms: 1000, + ..Default::default() + }) + .unwrap(); + let mut tickets = Vec::new(); + for index in 0..100 { + let (ticket, owner) = registry.join(key.clone(), 0).unwrap(); + assert_eq!(owner, index == 0); + tickets.push(ticket); + } + assert_eq!(registry.consumers(), 100); + assert!(registry.join(key.clone(), 1).is_err()); + assert!(!registry.leave(tickets.pop().unwrap())); + assert_eq!(registry.consumers(), 99); + for ticket in tickets { + registry.leave(ticket); + } + assert!(registry.is_empty()); + let (old, _) = registry.join(key.clone(), 100).unwrap(); + let (new, owner) = registry.join(key.clone(), 1100).unwrap(); + assert!(owner); + assert!( + !registry.leave(old), + "expired owner cannot release new generation" + ); + assert_eq!(registry.consumers(), 1); + assert!(registry.leave(new)); + assert!(registry.is_empty()); +} + +#[test] +fn live_scope_replay_and_proof_sensitive_frames() { + let request = json!({"query":"subscription { todos { title } }"}); + let mut admitted = admission("v1"); + admitted.key = OperationKey::from_origin(&admitted.identity, &request).unwrap(); + let key = LiveKey::admitted(&admitted, &request, None, 100).unwrap(); + let mut resumed = request.clone(); + resumed["extensions"] = + json!({"distributed":{"resume":[{"projection":"todos","position":"1","token":"cursor"}]}}); + let mut replay = admitted.clone(); + replay.key = OperationKey::from_origin(&replay.identity, &resumed).unwrap(); + let replay_key = LiveKey::admitted(&replay, &resumed, None, 100).unwrap(); + assert!(key.same_operation(&replay_key)); + assert!(!key.same_initial(&replay_key)); + for changed in ["subject", "policy"] { + let mut other = admitted.clone(); + if changed == "subject" { + other.identity.cache_scope = "bob".into(); + } else { + other.identity.authorization_generation = "policy-2".into(); + } + other.key = OperationKey::from_origin(&other.identity, &request).unwrap(); + assert!(!key.same_operation(&LiveKey::admitted(&other, &request, None, 100).unwrap())); + } + assert!(LiveKey::admitted(&admitted, &request, None, 200).is_err()); + assert!(LiveKey::admitted( + &admission("v1"), + &json!({"query":"{ todos { title } }"}), + None, + 100 + ) + .is_err()); + let mut payload: serde_json::Value = serde_json::from_slice(&snapshot(&admitted).body).unwrap(); + payload["extensions"]["distributed"]["live"] = json!({"supported":true,"cursors":[{"projection":"todos","position":"2","token":"cursor"}]}); + let first = LiveFrame::from_origin(&admitted, payload.clone(), None, 4096).unwrap(); + assert!( + first.same_frame(&LiveFrame::from_origin(&admitted, payload.clone(), None, 4096).unwrap()) + ); + payload["extensions"]["distributed"]["observations"] = json!([{"commandId":"confirmed"}]); + let proof = LiveFrame::from_origin(&admitted, payload.clone(), None, 4096).unwrap(); + assert!( + !first.same_frame(&proof), + "equal data with new evidence must be delivered" + ); + assert!(first.same_cursor(&proof)); + payload["data"] = json!({"todos":[{"title":"external write"}]}); + let changed = LiveFrame::from_origin(&admitted, payload.clone(), None, 4096).unwrap(); + assert!( + !first.same_cursor(&changed), + "same projector cursor does not cover external writes" + ); + payload["extensions"]["distributed"]["live"]["supported"] = false.into(); + let unsupported = LiveFrame::from_origin(&admitted, payload, None, 4096).unwrap(); + assert!(!unsupported.same_cursor(&unsupported)); + let mut stronger = context(); + stronger.observe([index("scope", "3")]).unwrap(); + assert!(!first.satisfies(&admitted, Some(&stronger))); +} diff --git a/tests/edge_query_delivery_postgres.rs b/tests/edge_query_delivery_postgres.rs new file mode 100644 index 000000000..3eb00bd6d --- /dev/null +++ b/tests/edge_query_delivery_postgres.rs @@ -0,0 +1,196 @@ +#![cfg(all( + feature = "gateway-delivery", + feature = "graphql", + feature = "postgres" +))] +use async_graphql::Request; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions, ReadRouting}; +use distributed::microsvc::Session; +use serde_json::{json, Value}; +use std::time::Duration; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, distributed::ReadModel)] +#[readmodel(table = "gateway_replica_views", primary_key = ["id"])] +struct ReplicaView { + id: String, + title: String, +} +const QUERY: &str = "query StaleAllowed { gateway_replica_views { id title } }"; +const CURRENT: &str = "query CurrentState { gateway_replica_views { id title } }"; +fn engine(primary: &sqlx::PgPool, replica: &sqlx::PgPool, namespace: &str) -> GraphqlEngine { + GraphqlEngine::builder(primary.clone()) + .service_id("replica-fixture") + .protocol_token_key([57; 32]) + .protocol_namespace(namespace) + .roles(&["user"]) + .anonymous_role("user") + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .read_routing( + ReadRouting::new(replica.clone()) + .stale_tolerant(QUERY, Some("StaleAllowed".into())) + .unwrap(), + ) + .build() + .unwrap() +} +async fn query(engine: &GraphqlEngine, document: &str, context: Option) -> Value { + let mut request = Request::new(document).operation_name(if document == QUERY { + "StaleAllowed" + } else { + "CurrentState" + }); + if let Some(context) = context { + request.extensions.insert( + "gatewayFreshness".into(), + async_graphql::Value::from_json(context).unwrap(), + ); + } + serde_json::to_value(engine.execute(&Session::new(), request).await).unwrap() +} +async fn wait_replayed(pool: &sqlx::PgPool, title: &str) { + tokio::time::timeout(Duration::from_secs(15), async { + loop { + if sqlx::query_scalar::<_, String>( + "SELECT title FROM gateway_replica_views WHERE id='one'", + ) + .fetch_optional(pool) + .await + .ok() + .flatten() + .as_deref() + == Some(title) + { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("standby replayed fixture write"); +} + +#[tokio::test] +#[ignore = "requires owned primary and paused standby; run tests/gateway-postgres/run.py"] +async fn paused_replica_never_certifies_freshness() { + let primary = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_secs(3)) + .connect( + &std::env::var("GATEWAY_TEST_PRIMARY_URL").expect("run tests/gateway-postgres/run.py"), + ) + .await + .unwrap(); + let replica = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_secs(3)) + .connect(&std::env::var("GATEWAY_TEST_REPLICA_URL").expect("owned standby URL")) + .await + .unwrap(); + sqlx::query("CREATE TABLE gateway_replica_views (id text PRIMARY KEY, title text NOT NULL)") + .execute(&primary) + .await + .unwrap(); + sqlx::query("INSERT INTO gateway_replica_views VALUES ('one','before')") + .execute(&primary) + .await + .unwrap(); + wait_replayed(&replica, "before").await; + assert!(sqlx::query_scalar::<_, bool>("SELECT pg_is_in_recovery()") + .fetch_one(&replica) + .await + .unwrap()); + sqlx::query("SELECT pg_wal_replay_pause()") + .execute(&replica) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if sqlx::query_scalar::<_, String>("SELECT pg_get_wal_replay_pause_state()") + .fetch_one(&replica) + .await + .unwrap() + == "paused" + { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .unwrap(); + sqlx::query("UPDATE gateway_replica_views SET title='committed' WHERE id='one'") + .execute(&primary) + .await + .unwrap(); + let engine = engine(&primary, &replica, "epoch-1"); + assert_eq!( + query(&engine, QUERY, None).await["data"]["gateway_replica_views"][0]["title"], + "before" + ); + assert_eq!( + query(&engine, CURRENT, None).await["data"]["gateway_replica_views"][0]["title"], + "committed" + ); + let identity = engine + .delivery_identity( + &Session::new(), + &Request::new(QUERY).operation_name("StaleAllowed"), + ) + .unwrap(); + let context = json!({"version":1,"schemaHash":identity.schema_hash,"protocolHash":identity.protocol_hash,"authorizationGeneration":identity.authorization_generation,"cacheScope":identity.cache_scope,"pending":[{"complete":true,"models":["ReplicaView"],"relationships":[]}],"minimum":[]}); + assert_eq!( + query(&engine, QUERY, Some(context.clone())).await["data"]["gateway_replica_views"][0] + ["title"], + "committed" + ); + let mut disjoint = context.clone(); + disjoint["pending"][0]["models"] = json!(["OtherView"]); + assert_eq!( + query(&engine, QUERY, Some(disjoint)).await["data"]["gateway_replica_views"][0]["title"], + "before" + ); + // A new backend generation cannot authenticate old-scope context. + let next = self::engine(&replica, &primary, "epoch-2"); + assert_eq!( + query(&next, QUERY, Some(context.clone())).await["errors"][0]["extensions"]["code"], + "FRESHNESS_SCOPE_CHANGED" + ); + let misbound = query(&next, CURRENT, None).await; + assert!( + misbound.get("errors").is_some() && misbound["data"].is_null(), + "a standby cannot certify a current read: {misbound}" + ); + let container = std::env::var("GATEWAY_TEST_PRIMARY_CONTAINER").unwrap(); + assert!(container.starts_with("gateway-replay-") && container.ends_with("-primary")); + assert!(std::process::Command::new("docker") + .args(["stop", "-t", "1", &container]) + .status() + .unwrap() + .success()); + let unavailable = query(&engine, QUERY, Some(context.clone())).await; + assert!(unavailable.get("errors").is_some(), "{unavailable}"); + assert!( + unavailable["data"].is_null(), + "must not retry the old standby: {unavailable}" + ); + assert!(std::process::Command::new("docker") + .args(["start", &container]) + .status() + .unwrap() + .success()); + wait_replayed(&primary, "committed").await; + assert_eq!( + query(&engine, CURRENT, Some(context)).await["data"]["gateway_replica_views"][0]["title"], + "committed", + "restarted primary retains its committed data and serves a current read" + ); + sqlx::query("SELECT pg_wal_replay_resume()") + .execute(&replica) + .await + .unwrap(); + wait_replayed(&replica, "committed").await; + assert_eq!( + query(&engine, QUERY, None).await["data"]["gateway_replica_views"][0]["title"], + "committed" + ); + primary.close().await; + replica.close().await; +} diff --git a/tests/fixtures/application-contract-only/src/lib.rs b/tests/fixtures/application-contract-only/src/lib.rs index 68e6b992c..7823483f5 100644 --- a/tests/fixtures/application-contract-only/src/lib.rs +++ b/tests/fixtures/application-contract-only/src/lib.rs @@ -12,9 +12,9 @@ use distributed::microsvc::{CausalCommandContext, HandlerError}; use distributed::table::{ ColumnType, PrimaryKey, TableColumn, TableKind, TableSchema, DEFAULT_TABLE_VERSION_COLUMN, }; -use distributed::{Aggregate, Entity, EventRecord, GraphqlInput, GraphqlOutput}; #[cfg(feature = "application-runtime")] use distributed::AggregateBuilder; +use distributed::{Aggregate, CommandInput, CommandOutput, Entity, EventRecord}; use serde::{Deserialize, Serialize}; #[derive(Default)] @@ -42,13 +42,13 @@ impl Aggregate for ContractAggregate { } } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] pub struct CreateTodoInput { pub id: String, pub title: String, } -#[derive(Clone, Serialize, GraphqlOutput)] +#[derive(Clone, Serialize, CommandOutput)] pub struct CreateTodoOutput { pub id: String, } @@ -61,17 +61,19 @@ pub struct CreateTodoOutput { roles(user, admin), default(title = uuid_v7), input = CreateTodoInput, - outcome = distributed::graphql::Succeeded + outcome = distributed::command::Succeeded )] pub async fn create_todo( _context: &CausalCommandContext<'_, ContractAggregate>, input: CreateTodoInput, ) -> Result< - distributed::graphql::PreparedCommand>, + distributed::command::PreparedCommand>, HandlerError, > { - Ok(distributed::graphql::PreparedCommand::prepare(CreateTodoOutput { id: input.id }) - .expect("generated output is valid")) + Ok( + distributed::command::PreparedCommand::prepare(CreateTodoOutput { id: input.id }) + .expect("generated output is valid"), + ) } distributed::module! { @@ -113,14 +115,8 @@ fn todo_surface() -> Surface { .rows(col("status").eq("open")), )]), )]); - surface_for_application_contract( - &catalog, - "web", - &["user".into()], - &["user".into()], - &grants, - ) - .expect("contract fixture role surface should compile") + surface_for_application_contract(&catalog, "web", &["user".into()], &["user".into()], &grants) + .expect("contract fixture role surface should compile") } static WEB_SURFACE: LazyLock = LazyLock::new(|| { @@ -176,8 +172,12 @@ pub fn surface_sdl() -> String { } pub fn client_manifest_bytes() -> Vec { - serde_json::to_vec(&compiler().client_manifest().expect("client artifact should compile")) - .expect("client artifact should serialize") + serde_json::to_vec( + &compiler() + .client_manifest() + .expect("client artifact should compile"), + ) + .expect("client artifact should serialize") } pub fn selected_surface_fingerprint() -> String { @@ -196,8 +196,7 @@ pub fn runtime_service() -> distributed::microsvc::Service { let _catalog = ReadModelCatalog::new("todo-contract-only"); let repository = InMemoryRepository::new(); let routes = create_todo_register( - distributed::microsvc::Routes::new() - .with_repo(repository.aggregate::()), + distributed::microsvc::Routes::new().with_repo(repository.aggregate::()), ); distributed::microsvc::Service::new() .named("todo-contract-only") @@ -261,6 +260,9 @@ mod tests { #[test] fn no_default_contract_definition_has_no_executable_mount() { assert_eq!(TODO_MODULE.mounts().len(), 0); - assert!(TODO_MODULE.definitions().iter().all(|definition| definition.mount().is_none())); + assert!(TODO_MODULE + .definitions() + .iter() + .all(|definition| definition.mount().is_none())); } } diff --git a/tests/fixtures/core-command-contract-v1.json b/tests/fixtures/core-command-contract-v1.json new file mode 100644 index 000000000..4a715a4b4 --- /dev/null +++ b/tests/fixtures/core-command-contract-v1.json @@ -0,0 +1,83 @@ +{ + "sdl": "scalar BigInt\nscalar Bytea\nscalar JSON\nscalar Timestamptz\n\nenum order_by {\n asc\n asc_nulls_first\n asc_nulls_last\n desc\n desc_nulls_first\n desc_nulls_last\n}\n\ninput ContractInput {\n count: BigInt!\n nested: [NestedInput]\n recordId: String!\n}\n\ninput NestedInput {\n enabled: Boolean!\n}\n\ntype ContractOutput {\n record_id: String!\n value: Float!\n}\n\nenum DistributedCommandState {\n in_progress\n succeeded\n succeeded_pending_projection\n atomic\n rejected\n projection_failed\n expired\n unknown\n}\n\ntype DistributedCommandStatus {\n state: DistributedCommandState!\n}\n\ntype Query {\n commandStatus(commandId: ID!): DistributedCommandStatus!\n}\n\ntype Mutation {\n run_contract(commandId: ID!, input: ContractInput!): ContractOutput!\n}\n", + "spec": { + "applies": [], + "confirmations": [], + "consistency": "succeeded", + "defaults": [], + "effects": { + "fallback": "revalidate", + "operations": [] + }, + "emits": [], + "field_name": "run_contract", + "fingerprint": "sha256:ed43f086c62a1878472679415e0155e78a707921aa248a67416a49d5c97a39eb", + "id": "contract.test", + "input": { + "fields": [ + { + "item_nullable": false, + "list": false, + "name": "recordId", + "nullable": false, + "type_name": "String" + }, + { + "item_nullable": false, + "list": false, + "name": "count", + "nullable": false, + "type_name": "BigInt" + }, + { + "item_nullable": true, + "list": true, + "name": "nested", + "nested": { + "fields": [ + { + "item_nullable": false, + "list": false, + "name": "enabled", + "nullable": false, + "type_name": "Boolean" + } + ], + "name": "NestedInput" + }, + "nullable": true, + "type_name": "NestedInput" + } + ], + "name": "ContractInput" + }, + "output": { + "fields": [ + { + "item_nullable": false, + "list": false, + "name": "record_id", + "nullable": false, + "type_name": "String" + }, + { + "item_nullable": false, + "list": false, + "name": "value", + "nullable": false, + "type_name": "Float" + } + ], + "name": "ContractOutput" + }, + "projection_contract": { + "declaration_errors": [], + "previews": [], + "pure_reduces": [], + "selectors": [] + }, + "roles": [ + "user" + ] + } +} diff --git a/tests/fixtures/derived_event_save.graphql b/tests/fixtures/derived_event_save.graphql new file mode 100644 index 000000000..fc1100fef --- /dev/null +++ b/tests/fixtures/derived_event_save.graphql @@ -0,0 +1,3 @@ +mutation SaveDerived { + upsert_derived_rows(object: $input.row) +} diff --git a/tests/fixtures/flat_review_save.graphql b/tests/fixtures/flat_review_save.graphql new file mode 100644 index 000000000..eaa75c218 --- /dev/null +++ b/tests/fixtures/flat_review_save.graphql @@ -0,0 +1,3 @@ +mutation SaveFlatReview { + upsert_flat_reviews(object: $input.review) +} diff --git a/tests/fixtures/renamed-dependency-trybuild/tests/fixtures/renamed_dependency.rs b/tests/fixtures/renamed-dependency-trybuild/tests/fixtures/renamed_dependency.rs index 1c34c7bee..9f3b56dab 100644 --- a/tests/fixtures/renamed-dependency-trybuild/tests/fixtures/renamed_dependency.rs +++ b/tests/fixtures/renamed-dependency-trybuild/tests/fixtures/renamed_dependency.rs @@ -26,12 +26,12 @@ impl Aggregate for RenamedAggregate { } } -#[derive(Clone, serde::Deserialize, framework::GraphqlInput)] +#[derive(Clone, serde::Deserialize, framework::CommandInput)] struct RenamedInput { id: String, } -#[derive(Clone, serde::Serialize, framework::GraphqlOutput)] +#[derive(Clone, serde::Serialize, framework::CommandOutput)] struct RenamedOutput { id: String, } @@ -47,13 +47,13 @@ struct RenamedCreated { roles(user), emits(RenamedCreated), input = RenamedInput, - outcome = framework::graphql::Succeeded + outcome = framework::command::Succeeded )] async fn renamed_handler( _context: &framework::microsvc::CausalCommandContext<'_, RenamedAggregate>, _input: RenamedInput, ) -> Result< - framework::graphql::PreparedCommand>, + framework::command::PreparedCommand>, framework::microsvc::HandlerError, > { unreachable!() diff --git a/tests/fixtures/source_snapshot_delete.graphql b/tests/fixtures/source_snapshot_delete.graphql new file mode 100644 index 000000000..c91a2c30a --- /dev/null +++ b/tests/fixtures/source_snapshot_delete.graphql @@ -0,0 +1,3 @@ +mutation DeleteSnapshot { + delete_source_snapshot_rows_by_pk(id: $input.id) +} diff --git a/tests/fixtures/source_snapshot_save.graphql b/tests/fixtures/source_snapshot_save.graphql new file mode 100644 index 000000000..71cdd6c78 --- /dev/null +++ b/tests/fixtures/source_snapshot_save.graphql @@ -0,0 +1,3 @@ +mutation SaveSnapshot { + upsert_source_snapshot_rows(object: $input.row) +} diff --git a/tests/gateway-auth/.gitignore b/tests/gateway-auth/.gitignore new file mode 100644 index 000000000..bbe82e0b0 --- /dev/null +++ b/tests/gateway-auth/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.svelte-kit/ +.generated/ +test-results/ +build/ diff --git a/tests/gateway-auth/README.md b/tests/gateway-auth/README.md new file mode 100644 index 000000000..4609129bc --- /dev/null +++ b/tests/gateway-auth/README.md @@ -0,0 +1,38 @@ +# Delegated auth fixture + +Run from this directory: + +```sh +npm ci +npx playwright install chromium +npm test +``` + +The runner builds and launches a production SvelteKit/adapter-node server and +an isolated, in-memory `oidc-provider`, then drives Chromium through actual +OIDC authorization-code, PKCE, state, nonce, refresh and logout flows. It checks +cross-origin CSRF rejection, failed refresh admission, missing state/nonce, +HTTP session renewal, and explicitly configured Secure cookies. Production +output matters: SvelteKit disables its origin check in the Vite development +server. Dependency versions and the complete npm lockfile are committed. + +`prepare.mjs` copies the application's current Auth.js configuration, claim +helpers, session admission and refresh handler from `tests/e2e-ui/ui/src` into +ignored `.generated/` files on every run. The fixture does not maintain an +independent auth implementation. A local fixture-only client secret and fresh +in-memory signing/session keys are used; no playground env file or remote IdP +is read or modified. Servers bind loopback on dynamically allocated ports and +are stopped on success or failure. Build output and node_modules are ignored. + +`startFixture()` and `exerciseAuth()` are reusable by native and Worker tests. +`GATEWAY_TEST_ORIGIN` names the public origin when another fixture owns ingress; +`uiOrigin` identifies the private Auth.js upstream. Configure the gateway to +delegate `/auth`, `/login`, `/logout` and `/api/auth` to that upstream. Preserve +independent Set-Cookie fields and reconstruct forwarded host/protocol from +configured public origin. Keep the browser's Origin header for CSRF validation. + +Application bindings use `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, +`AUTH_URL`, and `AUTH_SECRET`; values belong to the host secret/configuration +provider. The fixture supplies only local values through an explicit process +environment. Its memory-only provider and unconditional local consent policy +are test infrastructure, not a production identity service. diff --git a/tests/gateway-auth/package-lock.json b/tests/gateway-auth/package-lock.json new file mode 100644 index 000000000..e21c1d322 --- /dev/null +++ b/tests/gateway-auth/package-lock.json @@ -0,0 +1,2306 @@ +{ + "name": "gateway-auth-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gateway-auth-fixture", + "dependencies": { + "@auth/core": "0.41.3", + "@auth/sveltekit": "1.11.3", + "@playwright/test": "1.61.1", + "@sveltejs/adapter-node": "5.2.12", + "@sveltejs/kit": "2.70.1", + "@sveltejs/vite-plugin-svelte": "5.1.1", + "oidc-provider": "9.12.2", + "svelte": "5.56.7", + "typescript": "5.8.3", + "vite": "6.4.3" + } + }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/sveltekit": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@auth/sveltekit/-/sveltekit-1.11.3.tgz", + "integrity": "sha512-nfI/CFHD9hpJG4W+u+xtMrw9XSrA1k6kbgbGc9PvbiEf67oPMuzLAvLDj6/3yd98kFz+51Rq04JLBckKp8T5Kg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3", + "set-cookie-parser": "^2.7.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.3", + "@sveltejs/kit": "^1.0.0 || ^2.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.2.12", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.12.tgz", + "integrity": "sha512-0bp4Yb3jKIEcZWVcJC/L1xXp9zzJS4hDwfb4VITAkfT4OVdkspSHsx7YhqJDbb2hgLl6R9Vs7VQR+fqIVOxPUQ==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.9.5" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/kit/node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/devalue": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz", + "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/koa": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.8", + "content-disposition": "~1.0.1", + "content-type": "^1.0.5", + "cookies": "~0.9.1", + "delegates": "^1.0.0", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "license": "MIT" + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/oidc-provider": { + "version": "9.12.2", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-9.12.2.tgz", + "integrity": "sha512-UaqeVpeijTocxVbDowPqPKloGjb49bxRSzHfChyI86teR+6xxoiI1V5jv8CHL2AIUmA2rg0xTZcYaC9GguympA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "jose": "^6.2.10", + "koa": "^3.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.56.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.7.tgz", + "integrity": "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "license": "MIT" + } + } +} diff --git a/tests/gateway-auth/package.json b/tests/gateway-auth/package.json new file mode 100644 index 000000000..f7fae0c8d --- /dev/null +++ b/tests/gateway-auth/package.json @@ -0,0 +1,21 @@ +{ + "name": "gateway-auth-fixture", + "private": true, + "type": "module", + "scripts": { + "prepare": "node prepare.mjs", + "test": "node run.mjs" + }, + "dependencies": { + "@auth/core": "0.41.3", + "@auth/sveltekit": "1.11.3", + "@playwright/test": "1.61.1", + "@sveltejs/adapter-node": "5.2.12", + "@sveltejs/kit": "2.70.1", + "@sveltejs/vite-plugin-svelte": "5.1.1", + "oidc-provider": "9.12.2", + "svelte": "5.56.7", + "typescript": "5.8.3", + "vite": "6.4.3" + } +} diff --git a/tests/gateway-auth/prepare.mjs b/tests/gateway-auth/prepare.mjs new file mode 100644 index 000000000..0ae773a59 --- /dev/null +++ b/tests/gateway-auth/prepare.mjs @@ -0,0 +1,12 @@ +import { mkdir, copyFile, writeFile } from 'node:fs/promises'; +// Exercise the app's actual Auth.js configuration and shared refresh handler. +// This auth-only composition does not bind an application GraphQL route loader. +for (const file of ['auth.ts', 'lib/clean-env.ts', 'lib/roles.ts', 'lib/server/oidc-scopes.ts', 'lib/server/oidc-start.ts', 'lib/server/require-auth.ts', 'lib/server/auth-refresh.ts']) { + const target = `.generated/${file}`; + await mkdir(target.substring(0, target.lastIndexOf('/')), { recursive: true }); + await copyFile(`../e2e-ui/ui/src/${file}`, target); +} +await mkdir('src/routes/api/auth/refresh', { recursive: true }); +await writeFile('.generated/refresh.ts', `import { createAuthRefreshHandler } from '$lib/server/auth-refresh'; +export const POST = createAuthRefreshHandler(); +`); diff --git a/tests/gateway-auth/provider.mjs b/tests/gateway-auth/provider.mjs new file mode 100644 index 000000000..fbde813f7 --- /dev/null +++ b/tests/gateway-auth/provider.mjs @@ -0,0 +1,63 @@ +import { Provider } from 'oidc-provider'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { randomBytes } from 'node:crypto'; + +// An isolated, in-memory standards implementation. No external IdP or secrets. +export async function startProvider(issuer, publicOrigin, { jwtAudience, accessTokenTtl = 61 } = {}) { + let refreshes = 0; + let failRefresh = false; + const provider = new Provider(issuer, { + clients: [{ client_id: 'gateway-fixture', client_secret: 'local-fixture-only', + redirect_uris: [`${publicOrigin}/auth/callback/oidc`], + response_types: ['code'], grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'client_secret_basic' }], + cookies: { keys: [randomBytes(32).toString('hex')] }, + features: { devInteractions: { enabled: false }, ...(jwtAudience ? { + resourceIndicators: { + enabled:true, defaultResource:()=>publicOrigin, useGrantedResource:()=>true, + getResourceServerInfo:()=>({scope:'openid profile email offline_access',audience:jwtAudience,accessTokenFormat:'jwt',jwt:{sign:{alg:'RS256'}}}), + }, + } : {}) }, + ...(jwtAudience ? { + extraTokenClaims:()=>({roles:['user']}), + scopes:['openid','profile','email','offline_access','urn:zitadel:iam:org:project:roles','urn:zitadel:iam:org:projects:roles',`urn:zitadel:iam:org:project:id:${jwtAudience}:aud`,`urn:zitadel:iam:org:project:id:${jwtAudience}:roles`], + } : {}), + ttl: { AccessToken: accessTokenTtl }, + async issueRefreshToken() { return true; }, + claims: { openid: ['sub'], profile: ['name', ...(jwtAudience?['roles']:[])], email: ['email'] }, + async findAccount(_ctx, id) { + return { accountId: id, async claims() { return { sub: id, name: 'Alice', email: 'alice@example.invalid',...(jwtAudience?{roles:['user']}:{}) }; } }; + }, + interactions: { url(_ctx, interaction) { return `/interaction/${interaction.uid}`; } }, + }); + provider.use(async (ctx, next) => { + if (ctx.path === '/token' && ctx.method === 'POST') { + // The grant event below counts actual successful refreshes. + if (failRefresh) { ctx.status = 400; ctx.body = { error: 'invalid_grant' }; return; } + } + await next(); + }); + provider.on('grant.success', ctx => { if (ctx.oidc.params.grant_type === 'refresh_token') refreshes++; }); + const server = createServer(async (req, res) => { + try { + if (req.url.startsWith('/interaction/')) { + const details = await provider.interactionDetails(req, res); + if (req.method === 'GET') { + res.setHeader('content-type', 'text/html'); + res.end('
'); return; + } + const grant = details.grantId ? await provider.Grant.find(details.grantId) : new provider.Grant({ accountId: 'alice', clientId: details.params.client_id }); + grant.addOIDCScope(jwtAudience?details.params.scope:'openid profile email offline_access'); + if(jwtAudience)grant.addResourceScope(publicOrigin,'openid profile email offline_access'); + const grantId = await grant.save(); + await provider.interactionFinished(req, res, { login: { accountId: 'alice' }, consent: { grantId } }, { mergeWithLastSubmission: true }); + return; + } + provider.callback()(req, res); + } catch { res.statusCode = 500; res.end('fixture provider failure'); } + }); + server.listen(Number(new URL(issuer).port), '127.0.0.1'); + await once(server, 'listening'); + return { server, refreshes: () => refreshes, failRefresh: () => { failRefresh = true; }, allowRefresh: () => { failRefresh = false; } }; +} diff --git a/tests/gateway-auth/run.mjs b/tests/gateway-auth/run.mjs new file mode 100644 index 000000000..ed4e211fe --- /dev/null +++ b/tests/gateway-auth/run.mjs @@ -0,0 +1,121 @@ +import './prepare.mjs'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { randomBytes } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +import { chromium } from '@playwright/test'; +import { startProvider } from './provider.mjs'; + +async function freePort() { + const server = createServer(); server.listen(0, '127.0.0.1'); await once(server, 'listening'); + const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port; +} +export async function startFixture({ secureCookies = false } = {}) { + const uiPort = await freePort(); + const publicOrigin = process.env.GATEWAY_TEST_ORIGIN || `http://127.0.0.1:${uiPort}`; + const issuer = `http://127.0.0.1:${await freePort()}`; + const idp = await startProvider(issuer, publicOrigin); + // Explicit environment: do not load the playground's real credential files. + const build = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'build'], { env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_ENV: 'production' }, stdio: ['ignore', 'pipe', 'pipe'] }); + let buildLog = ''; + build.stdout.on('data', chunk => { buildLog = (buildLog + chunk).slice(-4000); }); + build.stderr.on('data', chunk => { buildLog = (buildLog + chunk).slice(-4000); }); + const [buildCode] = await once(build, 'exit'); + if (buildCode !== 0) { await new Promise(resolve => idp.server.close(resolve)); throw Error('Auth.js build failed: ' + buildLog); } + const ui = spawn(process.execPath, ['build/index.js'], { + env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_ENV: 'production', HOST: '127.0.0.1', PORT: String(uiPort), + OIDC_ISSUER: issuer, OIDC_CLIENT_ID: 'gateway-fixture', OIDC_CLIENT_SECRET: 'local-fixture-only', + AUTH_SECRET: randomBytes(32).toString('hex'), AUTH_URL: publicOrigin, ORIGIN: publicOrigin, + AUTH_USE_SECURE_COOKIES: String(secureCookies) }, stdio: ['ignore', 'pipe', 'pipe'] + }); + let log = ''; ui.stdout.on('data', chunk => { log = (log + chunk).slice(-6000); }); ui.stderr.on('data', chunk => { log = (log + chunk).slice(-6000); }); + const stop = async () => { + if (ui.exitCode === null) { ui.kill('SIGTERM'); await once(ui, 'exit'); } + await new Promise(resolve => idp.server.close(resolve)); + }; + try { + for (let i = 0; i < 100; i++) { + if (ui.exitCode !== null) throw Error('Auth.js process exited: ' + log); + try { if ((await fetch(`http://127.0.0.1:${uiPort}/`)).ok) return { publicOrigin, uiOrigin: `http://127.0.0.1:${uiPort}`, issuer, idp, stop, logs: () => log }; } catch {} + await new Promise(resolve => setTimeout(resolve, 200)); + } + throw Error('Auth.js readiness timed out: ' + log); + } catch (error) { await stop(); throw error; } +} + +export async function exerciseAuth(fixture) { + const { publicOrigin, idp } = fixture; + const browser = await chromium.launch(); + try { + const context = await browser.newContext(); + const page = await context.newPage(); + assert.equal((await context.request.get(`${publicOrigin}/private`)).status(), 401); + await page.goto(publicOrigin); + await page.getByRole('link', { name: 'Log in' }).click(); + await page.getByRole('button', { name: 'Continue as Alice' }).click(); + await page.waitForURL(publicOrigin + '/'); + assert.deepEqual(await (await context.request.get(`${publicOrigin}/private`)).json(), { subject: 'alice' }); + const cookies = await context.cookies(); + const sessionCookies = cookies.filter(c => c.name.startsWith('authjs.session-token')); + assert.ok(sessionCookies.length > 0); + assert.ok(sessionCookies.every(c => c.httpOnly && c.sameSite === 'Lax' && c.path === '/')); + assert.ok(cookies.every(c => !c.name.includes('code_verifier') && !c.name.includes('state') && !c.name.includes('nonce'))); + console.log('PASS callback, PKCE/state/nonce cleanup, session cookie, protected route'); + // The real provider issues a 61-second access token. Cross the app's existing + // 60-second refresh skew; no fake auth clock or forged session is involved. + await new Promise(resolve => setTimeout(resolve, 2200)); + const refreshed = await context.request.post(`${publicOrigin}/api/auth/refresh`, { headers: { origin: publicOrigin } }); + assert.equal(refreshed.status(), 200, JSON.stringify({ url: refreshed.url(), body: await refreshed.text(), cookies: (await context.cookies()).map(({name, expires, path}) => ({name, expires, path})), privateStatus: (await context.request.get(`${publicOrigin}/private`)).status() })); + const refreshBody = await refreshed.json(); + assert.equal(refreshBody.authenticated, true); + assert.equal(refreshBody.hasRefreshToken, true); + assert.equal(refreshBody.error, undefined); + assert.ok(idp.refreshes() > 0); + console.log('PASS delegated refresh against local OIDC token endpoint'); + const csrf = await context.request.post(`${publicOrigin}/logout`, { headers: { origin: 'https://attacker.invalid', 'content-type': 'application/x-www-form-urlencoded' }, data: '' }); + assert.equal(csrf.status(), 403); + assert.equal((await context.request.get(`${publicOrigin}/private`)).status(), 200); + idp.failRefresh(); + await new Promise(resolve => setTimeout(resolve, 2200)); + const failedRefresh = await context.request.post(`${publicOrigin}/api/auth/refresh`, { headers: { origin: publicOrigin } }); + assert.equal(failedRefresh.status(), 401); + assert.equal((await failedRefresh.json()).error, 'RefreshAccessTokenError'); + assert.equal((await context.request.get(`${publicOrigin}/private`)).status(), 401); + console.log('PASS failed refresh denies protected UI/API'); + await page.getByRole('button', { name: 'Log out' }).click(); + await page.waitForURL(publicOrigin + '/'); + assert.equal((await context.request.get(`${publicOrigin}/private`)).status(), 401); + console.log('PASS cross-origin CSRF rejection and delegated logout'); + await context.close(); + idp.allowRefresh(); + // Removing the state/nonce cookies before callback must never establish a session. + for (const removed of ['state', 'nonce']) { + const bad = await browser.newContext(); const badPage = await bad.newPage(); + await badPage.goto(`${publicOrigin}/login`); + await badPage.getByRole('button', { name: 'Continue as Alice' }).waitFor(); + await bad.clearCookies({ name: new RegExp(removed) }); + await badPage.getByRole('button', { name: 'Continue as Alice' }).click(); + await badPage.waitForURL(url => url.origin === publicOrigin); + assert.equal((await bad.request.get(`${publicOrigin}/private`)).status(), 401); + await bad.close(); + console.log(`PASS missing ${removed} rejects callback`); + } + } finally { await browser.close(); } +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const fixture = await startFixture(); + try { await exerciseAuth(fixture); } + catch (error) { console.error(fixture.logs()); throw error; } + finally { await fixture.stop(); } + const secure = await startFixture({ secureCookies: true }); + try { + const response = await fetch(`${secure.uiOrigin}/login`, { redirect: 'manual' }); + assert.equal(response.status, 302); + const cookies = response.headers.getSetCookie(); + assert.ok(cookies.length >= 3); + assert.ok(cookies.every(cookie => /; Secure(?:;|$)/i.test(cookie))); + console.log('PASS explicit secure-cookie policy survives Auth.js delegation'); + } finally { await secure.stop(); } +} diff --git a/tests/gateway-auth/src/app.html b/tests/gateway-auth/src/app.html new file mode 100644 index 000000000..b42f8d00d --- /dev/null +++ b/tests/gateway-auth/src/app.html @@ -0,0 +1 @@ +%sveltekit.head%
%sveltekit.body%
diff --git a/tests/gateway-auth/src/hooks.server.ts b/tests/gateway-auth/src/hooks.server.ts new file mode 100644 index 000000000..95ddcd38a --- /dev/null +++ b/tests/gateway-auth/src/hooks.server.ts @@ -0,0 +1 @@ +export { handle } from '../.generated/auth'; diff --git a/tests/gateway-auth/src/routes/+page.svelte b/tests/gateway-auth/src/routes/+page.svelte new file mode 100644 index 000000000..2214edd45 --- /dev/null +++ b/tests/gateway-auth/src/routes/+page.svelte @@ -0,0 +1 @@ +

Gateway auth fixture

Log in
diff --git a/tests/gateway-auth/src/routes/api/auth/refresh/+server.ts b/tests/gateway-auth/src/routes/api/auth/refresh/+server.ts new file mode 100644 index 000000000..9399b3f32 --- /dev/null +++ b/tests/gateway-auth/src/routes/api/auth/refresh/+server.ts @@ -0,0 +1 @@ +export { POST } from '../../../../../.generated/refresh'; diff --git a/tests/gateway-auth/src/routes/login/+server.ts b/tests/gateway-auth/src/routes/login/+server.ts new file mode 100644 index 000000000..85469e4cd --- /dev/null +++ b/tests/gateway-auth/src/routes/login/+server.ts @@ -0,0 +1,2 @@ +import { startOidcSignIn } from '$lib/server/oidc-start'; +export async function GET(event) { return startOidcSignIn(event); } diff --git a/tests/gateway-auth/src/routes/logout/+server.ts b/tests/gateway-auth/src/routes/logout/+server.ts new file mode 100644 index 000000000..127e6fba2 --- /dev/null +++ b/tests/gateway-auth/src/routes/logout/+server.ts @@ -0,0 +1,2 @@ +import { signOut } from '../../../.generated/auth'; +export async function POST(event) { return signOut(event); } diff --git a/tests/gateway-auth/src/routes/private/+server.ts b/tests/gateway-auth/src/routes/private/+server.ts new file mode 100644 index 000000000..39811f672 --- /dev/null +++ b/tests/gateway-auth/src/routes/private/+server.ts @@ -0,0 +1,6 @@ +import { json } from '@sveltejs/kit'; +import { isCurrentSession } from '$lib/server/require-auth'; +export async function GET({ locals }) { + const session = await locals.auth(); + return isCurrentSession(session) ? json({ subject: session.user.id }) : json({ error: 'unauthorized' }, { status: 401 }); +} diff --git a/tests/gateway-auth/svelte.config.js b/tests/gateway-auth/svelte.config.js new file mode 100644 index 000000000..7f0871745 --- /dev/null +++ b/tests/gateway-auth/svelte.config.js @@ -0,0 +1,2 @@ +import adapter from '@sveltejs/adapter-node'; +export default { kit: { adapter: adapter(), files: { lib: './.generated/lib' } } }; diff --git a/tests/gateway-auth/vite.config.js b/tests/gateway-auth/vite.config.js new file mode 100644 index 000000000..b13a59513 --- /dev/null +++ b/tests/gateway-auth/vite.config.js @@ -0,0 +1,3 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +export default defineConfig({ plugins: [sveltekit()], server: { host: '127.0.0.1', strictPort: true } }); diff --git a/tests/gateway-native/.gitignore b/tests/gateway-native/.gitignore new file mode 100644 index 000000000..556a76756 --- /dev/null +++ b/tests/gateway-native/.gitignore @@ -0,0 +1,2 @@ +/target/ +!Cargo.lock diff --git a/tests/gateway-native/Cargo.lock b/tests/gateway-native/Cargo.lock new file mode 100644 index 000000000..c08398b9b --- /dev/null +++ b/tests/gateway-native/Cargo.lock @@ -0,0 +1,2352 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1 0.10.7", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitcode" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6ed1b54d8dc333e7be604d00fa9262f4635485ffea923647b6521a5fff045d" +dependencies = [ + "arrayvec", + "bitcode_derive", + "bytemuck", + "glam", + "serde", +] + +[[package]] +name = "bitcode_derive" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238b90427dfad9da4a9abd60f3ec1cdee6b80454bde49ed37f1781dd8e9dc7f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "distributed" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.23.1", + "bitcode", + "distributed_macros", + "futures-util", + "hyper", + "hyper-util", + "js-sys", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tonic-build", + "tower", + "url", + "uuid", +] + +[[package]] +name = "distributed_macros" +version = "0.1.0" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "sha2", + "syn 2.0.119", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gateway-native-fixture" +version = "0.0.0" +dependencies = [ + "axum", + "distributed", + "futures-util", + "reqwest", + "serde_json", + "tokio", + "tokio-tungstenite 0.30.0", + "tower", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glam" +version = "0.33.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21fef0953c54fd3de2f44b743fbf77e044c81a25faee03636dfccc0d35135e23" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.30.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.2", + "sha1 0.11.0", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/gateway-native/Cargo.toml b/tests/gateway-native/Cargo.toml new file mode 100644 index 000000000..599f0944b --- /dev/null +++ b/tests/gateway-native/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "gateway-native-fixture" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[features] +default = ["gateway-native"] +gateway-native = ["distributed/gateway-native"] + +[dependencies] +distributed = { path = "../..", default-features = false } + +[dev-dependencies] +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = "0.30" +reqwest = { version = "0.13", features = ["json", "stream"] } +futures-util = "0.3" +tower = { version = "0.5", features = ["util"] } +serde_json = "1" + +[[test]] +name = "gateway_ui" +path = "../gateway_ui.rs" diff --git a/tests/gateway-native/README.md b/tests/gateway-native/README.md new file mode 100644 index 000000000..650103329 --- /dev/null +++ b/tests/gateway-native/README.md @@ -0,0 +1,25 @@ +# Native gateway fixture + +This isolated consumer enables `distributed/gateway-native` without GraphQL, +SQL, domain buses or Worker SDK dependencies. + +From the repository root: + +```sh +cargo test --manifest-path tests/gateway-native/Cargo.toml --locked +cargo clippy --manifest-path tests/gateway-native/Cargo.toml --locked --all-targets -- -D warnings +python3 tests/gateway-native/check_dependencies.py +``` + +The tests use real ephemeral loopback HTTP servers and WebSocket connections. +They prove incremental streaming before an explicit completion signal, response +body drop cancelling upstream work, independent cookies, public-origin headers +and redirects, method/body/query/HEAD semantics, upgrade echo and closure, +protected assets/custom routes, owned failures, request size and concurrency +limits, timeout, and proxy loops through origin aliases. Every server belongs +to the test and is aborted on teardown. No database or external service runs. + +A parent build cache can be reused with `--target-dir target` from the repository +root. The package lockfile is committed for reproducibility. Production +Auth.js browser tests are independently reusable from `tests/gateway-auth`; +the full application/Worker owners run them through their public ingress. diff --git a/tests/gateway-native/check_dependencies.py b/tests/gateway-native/check_dependencies.py new file mode 100644 index 000000000..367c2cd62 --- /dev/null +++ b/tests/gateway-native/check_dependencies.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""The native UI/auth adapter has no GraphQL/SQL or domain bus dependency.""" +from pathlib import Path +import subprocess +manifest = Path(__file__).resolve().with_name("Cargo.toml") +output = subprocess.check_output(["cargo", "tree", "--manifest-path", str(manifest), "--locked", "--edges", "normal", "--prefix", "none"], text=True) +packages = {line.split()[0] for line in output.splitlines() if line.strip()} +forbidden = {"async-graphql", "async-graphql-axum", "sqlx", "sqlx-core", "tonic", "worker", "async-nats", "lapin", "rdkafka"} +assert not packages & forbidden, sorted(packages & forbidden) +print("native UI/auth dependency boundary passed") diff --git a/tests/gateway-native/src/lib.rs b/tests/gateway-native/src/lib.rs new file mode 100644 index 000000000..459ffc314 --- /dev/null +++ b/tests/gateway-native/src/lib.rs @@ -0,0 +1,2 @@ +//! Native UI/auth gateway consumer, independent of GraphQL and SQL. +pub use distributed::gateway::native::*; diff --git a/tests/gateway-portable/.gitignore b/tests/gateway-portable/.gitignore new file mode 100644 index 000000000..556a76756 --- /dev/null +++ b/tests/gateway-portable/.gitignore @@ -0,0 +1,2 @@ +/target/ +!Cargo.lock diff --git a/tests/gateway-portable/Cargo.lock b/tests/gateway-portable/Cargo.lock new file mode 100644 index 000000000..9577e8892 --- /dev/null +++ b/tests/gateway-portable/Cargo.lock @@ -0,0 +1,850 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-graphql-parser" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" +dependencies = [ + "bytes", + "indexmap", + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitcode" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6ed1b54d8dc333e7be604d00fa9262f4635485ffea923647b6521a5fff045d" +dependencies = [ + "arrayvec", + "bitcode_derive", + "bytemuck", + "glam", + "serde", +] + +[[package]] +name = "bitcode_derive" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238b90427dfad9da4a9abd60f3ec1cdee6b80454bde49ed37f1781dd8e9dc7f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "distributed" +version = "0.1.0" +dependencies = [ + "async-graphql-parser", + "async-trait", + "base64", + "bitcode", + "distributed_macros", + "futures-util", + "js-sys", + "serde", + "serde_json", + "sha2", + "tonic-build", + "url", + "uuid", +] + +[[package]] +name = "distributed_macros" +version = "0.1.0" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "sha2", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gateway-portable-fixture" +version = "0.0.0" +dependencies = [ + "distributed", + "serde_json", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glam" +version = "0.33.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21fef0953c54fd3de2f44b743fbf77e044c81a25faee03636dfccc0d35135e23" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/gateway-portable/Cargo.toml b/tests/gateway-portable/Cargo.toml new file mode 100644 index 000000000..af7f2ae9d --- /dev/null +++ b/tests/gateway-portable/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "gateway-portable-fixture" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[features] +default = ["gateway"] +gateway = ["distributed/gateway"] +gateway-graphql = ["gateway", "distributed/gateway-graphql"] +gateway-delivery = ["gateway-graphql", "distributed/gateway-delivery"] + +[dependencies] +distributed = { path = "../..", default-features = false } + +[dev-dependencies] +serde_json = "1" + +[[test]] +name = "gateway_routing" +path = "../gateway_routing.rs" + +[[test]] +name = "gateway_extensions" +path = "../gateway_extensions.rs" + +[[test]] +name = "gateway_auth" +path = "../gateway_auth.rs" + +[[test]] +name = "gateway_graphql_operation" +path = "../gateway_graphql_operation.rs" + +[[test]] +name = "edge_query_delivery" +path = "../edge_query_delivery.rs" + +[[test]] +name = "gateway_mounts" +path = "../gateway_mounts.rs" diff --git a/tests/gateway-portable/README.md b/tests/gateway-portable/README.md new file mode 100644 index 000000000..da77a1adc --- /dev/null +++ b/tests/gateway-portable/README.md @@ -0,0 +1,23 @@ +# Portable gateway contract fixture + +This separate consumer avoids the parent crate's native dev-dependency feature +unification. It builds the same UI/auth-only contract on native and Wasm and +runs the parent gateway contract tests with only portable dependencies. + +From the repository root: + +```sh +cargo test --manifest-path tests/gateway-portable/Cargo.toml --locked +cargo check --manifest-path tests/gateway-portable/Cargo.toml --locked --target wasm32-unknown-unknown +python3 tests/gateway-portable/check_dependencies.py +``` + +The runtime dependency tree must exclude async-graphql, axum, sqlx, tokio, +reqwest, tonic and worker. Build dependencies run on the host and are not runtime +imports. The single `gateway` feature selects contracts; no native/Worker server +adapter feature is advertised by this fixture. Route/admission tests use local +`Rc`-holding futures without an async I/O runtime. + +These tests prove portable decisions and adapter sequencing. They do not prove +real authentication, UI/network streaming, GraphQL execution or workerd/DO +behavior, which belong to the subsequent gateway implementation tasks. diff --git a/tests/gateway-portable/check_dependencies.py b/tests/gateway-portable/check_dependencies.py new file mode 100644 index 000000000..a65e23309 --- /dev/null +++ b/tests/gateway-portable/check_dependencies.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Fail if the UI/auth consumer starts depending on a native runtime or executor.""" +from pathlib import Path +import subprocess +import sys + +manifest = Path(__file__).resolve().with_name("Cargo.toml") +for target in (None, "wasm32-unknown-unknown"): + command = ["cargo", "tree", "--manifest-path", str(manifest), "--locked", "--edges", "normal", "--prefix", "none"] + command += sys.argv[1:] + if target: + command += ["--target", target] + output = subprocess.check_output(command, text=True) + packages = {line.split()[0] for line in output.splitlines() if line.strip()} + forbidden = {"async-graphql", "async-graphql-axum", "axum", "sqlx", "sqlx-core", "tokio", "reqwest", "tonic", "worker"} + leaked = sorted(packages & forbidden) + if leaked: + raise SystemExit(f"{target or 'native'}: forbidden runtime dependencies: {', '.join(leaked)}") + print(f"{target or 'native'}: portable dependency boundary passed") diff --git a/tests/gateway-portable/src/lib.rs b/tests/gateway-portable/src/lib.rs new file mode 100644 index 000000000..a3844fdbd --- /dev/null +++ b/tests/gateway-portable/src/lib.rs @@ -0,0 +1,19 @@ +//! UI/auth-only consumer: no GraphQL executor, SQL, listener or Worker SDK. +use distributed::gateway::*; + +pub fn ui_and_auth() -> Result { + let mut assets = Route::new("ui", RoutePath::prefix("/"), "assets"); + assets.admission = vec!["session".into()]; + GatewayConfig { + bindings: vec![ + Binding::new("session", BindingKind::Admission), + Binding::new("assets", BindingKind::Assets), + Binding::new("auth", BindingKind::Handler), + ], + routes: vec![ + assets, + Route::new("auth", RoutePath::prefix("/auth"), "auth"), + ], + } + .build() +} diff --git a/tests/gateway-postgres/README.md b/tests/gateway-postgres/README.md new file mode 100644 index 000000000..589679b41 --- /dev/null +++ b/tests/gateway-postgres/README.md @@ -0,0 +1,13 @@ +# Physical standby freshness fixture + +Run `python3 tests/gateway-postgres/run.py`. Requires Docker and Rust. The script +creates its own network and two digest-pinned PostgreSQL containers with empty +in-memory data directories, binds only loopback ephemeral ports, and removes only +those owned resources on exit. No application database or credentials are read. + +The test uses `pg_wal_replay_pause` and waits for the actual paused state before +writing the primary. It checks selected GraphQL responses and unavailable-primary +behavior, then resumes replay. PostgreSQL documents the distinction between +[replay pause and receive](https://www.postgresql.org/docs/16/functions-admin.html) +and the [`pg_basebackup -R` standby setup](https://www.postgresql.org/docs/16/app-pgbasebackup.html). +This is a test fixture, not a production WAL routing policy. diff --git a/tests/gateway-postgres/run.py b/tests/gateway-postgres/run.py new file mode 100644 index 000000000..148eee6a8 --- /dev/null +++ b/tests/gateway-postgres/run.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Owned ephemeral primary/physical-standby fixture; no shared DB access.""" +from pathlib import Path +import json, os, socketserver, subprocess, sys, threading, time, uuid +ROOT = Path(__file__).resolve().parents[2] +IMAGE = "postgres@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685" +prefix = "gateway-replay-" + uuid.uuid4().hex[:10] +primary, standby = prefix + "-primary", prefix + "-standby" +primary_data = prefix + "-data" + +def docker(*args): + try: + return subprocess.check_output(["docker", *args], text=True, stderr=subprocess.STDOUT).strip() + except subprocess.CalledProcessError as error: + print(error.output[-3000:], file=sys.stderr) + raise + +def ready(container): + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + result = subprocess.run(["docker", "exec", container, "pg_isready", "-h", "127.0.0.1", "-U", "postgres"], capture_output=True) + if result.returncode == 0: return + time.sleep(.2) + raise RuntimeError("fixture database failed readiness: " + docker("logs", container)[-3000:]) + +bridges = [] +def url(container): + # Docker stdio avoids dependence on desktop VM published-port forwarding. + # Every connection still terminates at the actual PostgreSQL backend. + class Tunnel(socketserver.BaseRequestHandler): + def handle(self): + process = subprocess.Popen(["docker", "exec", "-i", container, "busybox", "nc", "127.0.0.1", "5432"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0) + def upload(): + try: + while chunk := self.request.recv(65536): process.stdin.write(chunk) + except (OSError, BrokenPipeError): pass + finally: + try: process.stdin.close() + except OSError: pass + sender = threading.Thread(target=upload, daemon=True); sender.start() + try: + while chunk := process.stdout.read(65536): self.request.sendall(chunk) + except OSError: pass + finally: + process.terminate() + try: process.wait(timeout=2) + except subprocess.TimeoutExpired: process.kill(); process.wait() + try: self.request.shutdown(2) + except OSError: pass + sender.join(timeout=2) + class Server(socketserver.ThreadingTCPServer): + daemon_threads = True + server = Server(("127.0.0.1", 0), Tunnel) + threading.Thread(target=server.serve_forever, daemon=True).start() + bridges.append(server) + return "postgres://postgres@127.0.0.1:" + str(server.server_address[1]) + "/postgres?sslmode=disable" + +try: + docker("network", "create", prefix) + docker("run", "-d", "--name", primary, "--network", prefix, "--network-alias", "primary", "--mount", "type=volume,source=" + primary_data + ",target=/var/lib/postgresql/data", "-e", "POSTGRES_HOST_AUTH_METHOD=trust", IMAGE, "postgres", "-c", "wal_level=replica", "-c", "max_wal_senders=4") + ready(primary) + docker("exec", primary, "sh", "-c", "printf 'host replication postgres all trust\n' >> /var/lib/postgresql/data/pg_hba.conf") + docker("exec", primary, "psql", "-U", "postgres", "-c", "SELECT pg_reload_conf()") + docker("run", "-d", "--name", standby, "--network", prefix, "--user", "postgres", "--tmpfs", "/replica:uid=70,gid=70,mode=0700", "--entrypoint", "sh", IMAGE, "-c", "pg_basebackup -h primary -U postgres -D /replica -R -X stream && exec postgres -D /replica") + ready(standby) + print("Fixture PostgreSQL " + docker("exec", primary, "postgres", "--version"), flush=True) + env = {**os.environ, "GATEWAY_TEST_PRIMARY_URL": url(primary), "GATEWAY_TEST_REPLICA_URL": url(standby), "GATEWAY_TEST_PRIMARY_CONTAINER": primary} + subprocess.run(["cargo", "test", "-p", "distributed", "--no-default-features", "--features", "gateway-delivery,graphql,postgres", "--test", "edge_query_delivery_postgres", "--", "--ignored", "--nocapture"], cwd=ROOT, env=env, check=True) + ready(primary) # The standby proof deliberately stops and restarts the primary. + subprocess.run(["cargo", "test", "-p", "distributed", "--no-default-features", "--features", "gateway-delivery,graphql,postgres", "--lib", "graphql::delivery::versions::tests::postgres_transactional_coverage_and_snapshot_race", "--", "--ignored", "--nocapture"], cwd=ROOT, env=env, check=True) + subprocess.run(["cargo", "test", "-p", "distributed", "--no-default-features", "--features", "gateway-delivery,graphql,postgres", "--test", "postgres_repository", "projected_command_ledger_rows_upgrade_to_atomic_and_preserve_checks", "--", "--nocapture"], cwd=ROOT, env={**env, "DATABASE_URL": env["GATEWAY_TEST_PRIMARY_URL"]}, check=True) +finally: + for bridge in bridges: + bridge.shutdown(); bridge.server_close() + for container in [standby, primary]: + subprocess.run(["docker", "rm", "-f", container], capture_output=True) + subprocess.run(["docker", "volume", "rm", primary_data], capture_output=True) + subprocess.run(["docker", "network", "rm", prefix], capture_output=True) diff --git a/tests/gateway-worker/.gitignore b/tests/gateway-worker/.gitignore new file mode 100644 index 000000000..8d3e5deed --- /dev/null +++ b/tests/gateway-worker/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +target/ +build/ +.wrangler/ +artifacts/ diff --git a/tests/gateway-worker/Cargo.lock b/tests/gateway-worker/Cargo.lock new file mode 100644 index 000000000..ea87acf34 --- /dev/null +++ b/tests/gateway-worker/Cargo.lock @@ -0,0 +1,1119 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "application-gateway-worker-fixture" +version = "0.0.0" +dependencies = [ + "distributed", + "serde_json", + "worker", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-graphql-parser" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" +dependencies = [ + "bytes", + "indexmap", + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitcode" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6ed1b54d8dc333e7be604d00fa9262f4635485ffea923647b6521a5fff045d" +dependencies = [ + "arrayvec", + "bitcode_derive", + "bytemuck", + "glam", + "serde", +] + +[[package]] +name = "bitcode_derive" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238b90427dfad9da4a9abd60f3ec1cdee6b80454bde49ed37f1781dd8e9dc7f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "js-sys", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "distributed" +version = "0.1.0" +dependencies = [ + "async-graphql-parser", + "async-trait", + "base64", + "bitcode", + "distributed_macros", + "futures-channel", + "futures-util", + "js-sys", + "serde", + "serde_json", + "sha2", + "tonic-build", + "url", + "uuid", + "worker", +] + +[[package]] +name = "distributed_macros" +version = "0.1.0" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "sha2", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glam" +version = "0.33.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21fef0953c54fd3de2f44b743fbf77e044c81a25faee03636dfccc0d35135e23" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "worker" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", + "wasm-bindgen", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/gateway-worker/Cargo.toml b/tests/gateway-worker/Cargo.toml new file mode 100644 index 000000000..2216840f0 --- /dev/null +++ b/tests/gateway-worker/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +[package] +name = "application-gateway-worker-fixture" +version = "0.0.0" +edition = "2021" +publish = false +[lib] +crate-type = ["cdylib"] +[dependencies] +distributed = { path = "../..", default-features = false, features = ["gateway-worker"] } +worker = "=0.8.5" +serde_json = "1" +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/tests/gateway-worker/README.md b/tests/gateway-worker/README.md new file mode 100644 index 000000000..4d15379a0 --- /dev/null +++ b/tests/gateway-worker/README.md @@ -0,0 +1,50 @@ +# Local application gateway Worker + +This isolated workers-rs fixture mounts the framework gateway and a **separate** +`DeliveryCoordinator` Durable Object. It does not host commands, projectors, SQL, +or aggregate cells. No Cloudflare account, deployment, or remote resources are +used by its tests. See [the adapter guide](../../docs/gateway/worker.md). + +Install Node 24, a current stable Rust toolchain with `wasm32-unknown-unknown`, +and `cargo install worker-build --version 0.8.5 --locked`. From the repository: + +```sh +npm ci --prefix tests/gateway-worker +npm ci --prefix tests/gateway-auth +npm exec --prefix tests/gateway-auth -- playwright install chromium +python3 tests/gateway-worker/check_dependencies.py +cargo check --manifest-path tests/gateway-worker/Cargo.toml --locked --target wasm32-unknown-unknown +node tests/gateway-worker/proxy-runtime.mjs +node tests/gateway-worker/run.mjs +cargo test --no-default-features --features gateway-graphql-native,gateway-delivery,sqlite --test graphql_query_protocol worker_ -- --ignored --nocapture --test-threads=1 +``` + +Run these runtime commands sequentially: worker-build writes one generated +bundle. `RUSTUP_TOOLCHAIN` is passed into the isolated build environment; real +application credential files are never loaded. Lockfiles pin workers-rs 0.8.5, +Wrangler 4.129.0, Miniflare 5.20260903.0-alpha, workerd 1.20260903.1, and ws 8.21.3. + +The Rust tests start actual GraphQL engines and SQLite projection stores, then +launch Node clients against workerd. They count origin validations, actual +projection SQL, and actual live producers. Query tests cover 100 consumers, +current private hits, external SQL without an invalidation feed, restart, two +separate ingress Wasm isolates using one selected DO, and explicit ingress +AbortSignal cancellation. Live tests cover 100 real JWT-authenticated sockets, +subject isolation, projection commit fanout, proof-only updates, bounded slow +consumers, old-cursor handoff, retention gaps, expiry/reconnect, last-leave +teardown, two ingress isolates and coordinator restart. + +The proxy runner uses actual HTTP streams and text/binary WebSockets. The auth +runner reuses the production SvelteKit/Auth.js browser lifecycle fixture and +checks secure cookie attributes separately. Artifacts under `artifacts/` are +ignored. Fixture-only `/__coordinators`, origin control routes and distributor +cancellation headers are test instrumentation; the framework mounts none of +them. + +The multi-isolate runner uses Miniflare's explicit module manifest and v4-option +converter for its pinned v5 API. A direct workerd listening socket avoids making +the Node development proxy part of the gateway contract. Plain HTTP disconnects +before response headers did not trigger Request.signal in this local runtime; +the configured operation deadline remains the cleanup bound. The explicit +AbortSignal test proves propagation through service bindings into the DO, and +response-stream cancellation and WebSocket teardown are tested separately. diff --git a/tests/gateway-worker/check_dependencies.py b/tests/gateway-worker/check_dependencies.py new file mode 100644 index 000000000..f1109cc65 --- /dev/null +++ b/tests/gateway-worker/check_dependencies.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""The Worker gateway links portable control contracts, never a native server.""" +from pathlib import Path +import subprocess +manifest = Path(__file__).resolve().with_name("Cargo.toml") +output = subprocess.check_output(["cargo", "tree", "--manifest-path", str(manifest), "--locked", "--target", "wasm32-unknown-unknown", "--edges", "normal", "--prefix", "none"], text=True) +packages = {line.split()[0] for line in output.splitlines() if line.strip()} +forbidden = {"async-graphql", "async-graphql-axum", "sqlx", "sqlx-core", "axum", "reqwest", "tonic", "async-nats", "lapin", "rdkafka"} +assert not packages & forbidden, sorted(packages & forbidden) +assert "worker" in packages +# workers-rs itself uses Tokio's feature-free utility types. A native runtime, +# networking or timer feature must never become enabled in the Wasm graph. +features = subprocess.check_output(["cargo", "tree", "--manifest-path", str(manifest), "--locked", "--target", "wasm32-unknown-unknown", "--edges", "normal", "--prefix", "none", "--format", "{p}|{f}"], text=True) +for line in features.splitlines(): + if line.startswith("tokio "): + assert not line.split("|", 1)[1].strip().replace("(*)", "").strip(), line +print("Worker gateway dependency boundary passed: no native server, SQL, or domain bus") diff --git a/tests/gateway-worker/live-runtime.mjs b/tests/gateway-worker/live-runtime.mjs new file mode 100644 index 000000000..006431aca --- /dev/null +++ b/tests/gateway-worker/live-runtime.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import {once} from 'node:events'; +import WebSocket from 'ws'; +import {startRuntime} from './runtime.mjs'; +import {startShardedRuntime} from './sharded-runtime.mjs'; +const apiOrigin=process.env.GATEWAY_ORIGIN; +let runtime=await startRuntime({apiOrigin,artifact:'live-workerd.log'}); +const clients=[]; +async function until(predicate,label='condition'){for(let n=0;n<500;n++){if(await predicate())return;await new Promise(resolve=>setTimeout(resolve,20));}throw Error(label+' not reached');} +async function metrics(){return await(await fetch(apiOrigin+'/__metrics')).json();} +async function counts(){const all=await(await fetch(runtime.publicOrigin+'/__coordinators')).json();return all.reduce((a,c)=>a.map((n,i)=>n+c.live[i]),[0,0,0,0,0,0,0]);} +async function connect(id,token,{resume,ack=true,legacy=false,ingress=0}={}){ + const socket=new WebSocket((runtime.at?runtime.at(ingress,'/graphql/ws'):runtime.publicOrigin+'/graphql/ws').replace('http:','ws:'),legacy?'graphql-ws':'graphql-transport-ws'); + const frames=[];const errors=[];let initialized=false;let closed=false; + socket.on('message',data=>{const value=JSON.parse(data);if(value.type==='connection_ack')initialized=true;else if(value.type==='ping'&&ack)socket.send(JSON.stringify({type:'pong',payload:value.payload}));else if(value.type==='next'||value.type==='data'){assert.equal(value.id,id);frames.push(value.payload);}else if(value.type==='error')errors.push(value.payload);}); + socket.on('close',()=>{closed=true;});socket.on('error',error=>{errors.push(error.message);}); + clients.push(socket);await once(socket,'open');socket.send(JSON.stringify({type:'connection_init',payload:{authorization:`Bearer ${token}`}})); + await until(()=>initialized,`connection ${id} admission`); + const payload={query:'subscription WorkerWatch { causal_query_views { title } }',...(resume?{extensions:{distributed:{resume:{cursors:resume}}}}:{})}; + socket.send(JSON.stringify({id,type:legacy?'start':'subscribe',payload})); + await until(()=>frames.length||errors.length||closed,`connection ${id} first frame`); + assert.equal(errors.length,0,JSON.stringify(errors));assert.ok(frames.length,`connection ${id} closed without frame`); + return {socket,frames,errors,closed:()=>closed,cancel:()=>socket.send(JSON.stringify({id,type:legacy?'stop':'complete'}))}; +} +try{ + const alice=process.env.GATEWAY_TOKEN_ALICE,bob=process.env.GATEWAY_TOKEN_BOB; + const group=[];for(let i=0;i<100;i++)group.push(await connect(`consumer-${i}`,alice)); + await until(async()=>{const c=await counts();return c[0]===1&&c[1]===100;},'100 consumers sharing one group'); + assert.equal((await metrics()).producers,1);assert.equal((await metrics()).resultExecutions,1);assert.equal((await metrics()).validations,100); + const initial=group[0].frames[0];for(const consumer of group)assert.deepEqual(consumer.frames[0],initial); + const other=await connect('bob',bob);assert.notEqual(other.frames[0].extensions.distributed.cacheScope,initial.extensions.distributed.cacheScope);assert.equal((await metrics()).producers,2);other.cancel(); + await until(async()=>(await metrics()).producers===1,'Bob cancellation'); + await fetch(apiOrigin+'/__commit',{method:'POST'}); + await until(()=>group.every(c=>c.frames.some(f=>f.data?.causal_query_views?.[0]?.title==='worker committed')),'100 committed fanout'); + const resumed=await connect('resumed',alice,{resume:initial.extensions.distributed.live.cursors}); + await until(async()=>(await metrics()).producers===1,'safe replay handoff');assert.ok((await counts())[6]>0); + for(const consumer of group)consumer.cancel();await until(async()=>(await counts())[1]===1,'surviving resume consumer'); + assert.equal((await metrics()).producers,1);resumed.cancel();await until(async()=>(await metrics()).producers===0,'actual last-leave teardown'); + console.log('PASS actual workerd DO: 100 JWT-admitted WebSockets, one producer, subject isolation, commit fanout, safe resume and last-leave teardown'); + const legacy=await connect('legacy',alice,{legacy:true});legacy.cancel();await until(async()=>(await metrics()).producers===0,'legacy independent teardown'); + console.log('PASS legacy protocol remains independently admitted'); + const fast=await connect('fast',alice);const slow=await connect('slow',alice,{ack:false}); + for(let position=3;position<=23;position++){ + const previous=fast.frames.length;await fetch(apiOrigin+'/__next/'+position,{method:'POST'}); + await until(()=>fast.frames.length>previous,'proof-bearing frame '+position); + } + await until(()=>slow.closed()||slow.errors.length,'bounded slow consumer reset'); + assert.equal(slow.frames.length,1,'unacknowledged consumer cannot accumulate unbounded network frames'); + await until(async()=>(await counts())[1]===1,'slow consumer release'); + assert.equal((await metrics()).producers,1); + assert.deepEqual(fast.frames[1].data,fast.frames[2].data,'same values across projection commits'); + assert.notDeepEqual(fast.frames[1].extensions.distributed,fast.frames[2].extensions.distributed,'new confirmation proof is delivered'); + fast.cancel();await until(async()=>(await metrics()).producers===0,'fast teardown'); + console.log('PASS slow consumer explicit reset; proof-only updates reach healthy consumer'); + const gap=await connect('gap',alice,{resume:initial.extensions.distributed.live.cursors}); + assert.equal(gap.frames[0].extensions.distributed.live.reset,true,'expired replay retention requires origin reset'); + gap.cancel();await until(async()=>(await metrics()).producers===0,'gap teardown'); + const shortToken=(await(await fetch(apiOrigin+'/__short_token')).json()).token; + const expiring=await connect('expiring',shortToken);const survivor=await connect('survivor',alice); + const attempts=(await counts())[2]; + await until(()=>expiring.errors.length||expiring.closed(),'per-consumer expiry'); + await until(async()=>(await counts())[2]>attempts,'remaining credential reconnect'); + await until(async()=>(await metrics()).producers===1,'remaining live producer'); + const previous=survivor.frames.length;await fetch(apiOrigin+'/__next/24',{method:'POST'});await until(()=>survivor.frames.length>previous,'post-expiry survivor update'); + survivor.cancel();await until(async()=>(await metrics()).producers===0,'expiry teardown'); + console.log('PASS replay gap reset, per-consumer expiry and remaining-credential upstream reconnect'); + + await runtime.stop();runtime=await startShardedRuntime(apiOrigin); + const baseline=await metrics();const sharded=[]; + for(let i=0;i<100;i++)sharded.push(await connect('sharded-'+i,alice,{ingress:i})); + assert.equal((await metrics()).producers,1);assert.equal((await metrics()).resultExecutions,baseline.resultExecutions+1); + assert.equal((await metrics()).validations,baseline.validations+100); + await fetch(apiOrigin+'/__next/25',{method:'POST'}); + await until(()=>sharded.every(c=>c.frames.length>1),'cross-isolate fanout'); + const resume=sharded[0].frames.at(-1).extensions.distributed.live.cursors; + await runtime.stop();await until(async()=>(await metrics()).producers===0,'runtime restart releases origin producer'); + runtime=await startShardedRuntime(apiOrigin); + const recovered=await connect('recovered',alice,{resume,ingress:1}); + await fetch(apiOrigin+'/__next/26',{method:'POST'});await until(()=>recovered.frames.length>1,'fresh producer after restart'); + recovered.cancel();await until(async()=>(await metrics()).producers===0,'restarted producer teardown'); + console.log('PASS two ingress isolates share100live consumers; actual coordinator restart resumes from origin and tears down'); + +}finally{for(const socket of clients)socket.terminate();await runtime.stop();} diff --git a/tests/gateway-worker/load-runtime.mjs b/tests/gateway-worker/load-runtime.mjs new file mode 100644 index 000000000..02c2e3d80 --- /dev/null +++ b/tests/gateway-worker/load-runtime.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import {once} from 'node:events'; +import {mkdir,writeFile} from 'node:fs/promises'; +import {execFileSync} from 'node:child_process'; +import WebSocket,{WebSocketServer} from 'ws'; +import {startRuntime,root} from './runtime.mjs'; + +const origin=process.env.GATEWAY_ORIGIN; +const tokens={alice:process.env.GATEWAY_TOKEN_ALICE,bob:process.env.GATEWAY_TOKEN_BOB}; +const native=JSON.parse(process.env.GATEWAY_NATIVE_MODES); +const meterOrigin=`http://127.0.0.1:${process.env.GATEWAY_METER_PORT}`; +// Count actual application bytes at the origin boundary, including control +// responses. HTTP/TLS framing and kernel/socket memory are not estimated here. +const wire={httpRequestBytes:0,httpResponseBytes:0,wsRequestBytes:0,wsResponseBytes:0,wsConnections:0}; +const meter=http.createServer((request,response)=>{ + const upstream=http.request(origin+request.url,{method:request.method,headers:{...request.headers,host:new URL(origin).host}},reply=>{ + response.writeHead(reply.statusCode,reply.headers); + reply.on('data',chunk=>wire.httpResponseBytes+=chunk.length);reply.pipe(response); + }); + upstream.on('error',()=>{response.writeHead(502);response.end();}); + request.on('data',chunk=>wire.httpRequestBytes+=chunk.length);request.pipe(upstream); + response.on('close',()=>{if(!response.writableFinished)upstream.destroy();}); +}); +const wss=new WebSocketServer({noServer:true}); +meter.on('upgrade',(request,socket,head)=>{ + const protocol=request.headers['sec-websocket-protocol']; + const upstream=new WebSocket((origin+request.url).replace('http:','ws:'),protocol,{headers:{origin:request.headers.origin??meterOrigin}}); + upstream.on('error',()=>socket.destroy()); + upstream.once('open',()=>wss.handleUpgrade(request,socket,head,downstream=>{ + wire.wsConnections++; + downstream.on('message',(data,binary)=>{wire.wsRequestBytes+=data.length;if(upstream.readyState===WebSocket.OPEN)upstream.send(data,{binary});}); + upstream.on('message',(data,binary)=>{wire.wsResponseBytes+=data.length;if(downstream.readyState===WebSocket.OPEN)downstream.send(data,{binary});}); + downstream.on('close',()=>upstream.close());upstream.on('close',()=>downstream.close()); + downstream.on('error',()=>upstream.close()); + })); +}); +meter.listen(Number(process.env.GATEWAY_METER_PORT),'127.0.0.1');await once(meter,'listening'); +const report={version:1,revision:execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),candidateChanges:execFileSync('git',['status','--porcelain'],{encoding:'utf8'}).trim().length>0,node:process.version,platform:process.platform,participants:100,repetitions:1,payloadCharacters:4096,measurement:'actual HTTP bodies and WebSocket application frames; excludes headers/TLS/framing',limits:{snapshotEntries:128,snapshotBytes:2097152,entryBytes:262144,flightGroups:8,consumers:128,flightBytes:262144,liveGroups:4,liveFrameBytes:65536},rows:[]}; +let position=2; +const snapshots=()=>({...wire}); +const difference=(after,before)=>Object.fromEntries(Object.keys(after).map(key=>[key,after[key]-before[key]])); +async function metrics(){return (await fetch(origin+'/__metrics')).json();} +async function until(predicate,label){for(let n=0;n<1000;n++){if(await predicate())return;await new Promise(resolve=>setTimeout(resolve,10));}throw Error(label+' timed out');} +const latency=values=>{const sorted=[...values].sort((a,b)=>a-b);return {min:sorted[0],p50:sorted[Math.floor(sorted.length/2)],p95:sorted[Math.floor(sorted.length*.95)],max:sorted.at(-1)};}; +async function advance(){position++;await fetch(origin+'/__next/'+position,{method:'POST'});return 'load-'+position+'-';} +async function modeRun(host,mode,runtime){ + console.log('START '+host+'/'+mode); + const url=runtime.publicOrigin; + const counts=async()=> (await fetch(url+'/__coordinators')).json(); + let downstreamBytes=0; + const query=async(subject='alice',document='query Load { causal_query_views { title } }')=>{ + const started=performance.now(); + const body=JSON.stringify({query:document,extensions:{gatewayDelivery:{action:'execute',connectionInit:{authorization:'Bearer '+tokens[subject]}}}}); + const response=await fetch(url+'/graphql',{method:'POST',headers:{'content-type':'application/json'},body}); + const text=await response.text();downstreamBytes+=Buffer.byteLength(text); + assert.equal(response.status,200,'query status');const value=JSON.parse(text);assert.equal(value.errors,undefined,'query errors'); + return {value,ms:performance.now()-started}; + }; + await advance(); + let warmup; + if(mode==='snapshots'){ + const before=await metrics(),bytes=snapshots();await query();warmup={origin:difference(await metrics(),before),wire:difference(snapshots(),bytes),clientBytes:downstreamBytes}; + } + const before=await metrics(),bytes=snapshots(),clientBefore=downstreamBytes; + if(mode==='flights')await fetch(origin+'/__block',{method:'POST'}); + const pending=Promise.all(Array.from({length:100},()=>query()));pending.catch(()=>{}); + if(mode==='flights'){ + await until(async()=> (await counts()).reduce((sum,row)=>sum+row.query[2],0)===100,'100 flight consumers'); + assert.equal((await metrics()).resultExecutions,before.resultExecutions,'barrier holds actual SQL'); + await fetch(origin+'/__release',{method:'POST'}); + } + const result=await pending; + for(const item of result)assert.deepEqual(item.value,result[0].value); + const queryWork=difference(await metrics(),before); + assert.equal(queryWork.resultExecutions,mode==='snapshots'?0:mode==='flights'?1:100); + assert.equal(queryWork.validations,mode==='snapshots'?100:mode==='flights'?101:0,'unselected query optimizations perform no validation work'); + const queryReport={origin:queryWork,wire:difference(snapshots(),bytes),clientResponseBytes:downstreamBytes-clientBefore,latencyMs:latency(result.map(item=>item.ms)),warmup}; + const alice=result[0].value.extensions.distributed.cacheScope; + const control=await query('bob');assert.notEqual(control.value.extensions.distributed.cacheScope,alice,'subjects stay separate'); + const title=await advance();assert.ok((await query()).value.data.causal_query_views[0].title.startsWith(title),'external projection invalidates cache'); + const decisions=await counts(); + if(mode==='snapshots'){ + assert.ok(decisions.reduce((sum,row)=>sum+(row.metrics[0]?.hits??0),0)>=100); + assert.ok(decisions.reduce((sum,row)=>sum+(row.metrics[0]?.staleRejections??0),0)>0); + } + // Unsupported protocol introspection follows the ordinary origin path and + // remains distinct from a reusable query hit. + const bypassBefore=decisions.reduce((sum,row)=>sum+row.metrics[1],0); + if(mode==='snapshots'||mode==='flights'){ + await query('alice','query Introspection { __typename }'); + assert.ok((await counts()).reduce((sum,row)=>sum+row.metrics[1],0)>bypassBefore); + } + await fetch(url+'/__coordinators',{method:'POST'}); + const liveBefore=await metrics(),liveBytes=snapshots();let receivedBytes=0; + const clients=[]; + async function connect(id,subject='alice',resume){ + const start=performance.now();const socket=new WebSocket(url.replace('http:','ws:')+'/graphql/ws','graphql-transport-ws'); + const frames=[];let initialized=false,closed=false;const errors=[];socket.on('close',()=>closed=true); + socket.on('message',data=>{ + receivedBytes+=data.length;const frame=JSON.parse(data); + if(frame.type==='connection_ack')initialized=true; + if(frame.type==='ping'&&id!=='slow')socket.send(JSON.stringify({type:'pong',payload:frame.payload})); + if(frame.type==='next'){assert.equal(frame.id,id);frames.push(frame.payload);} + if(frame.type==='error')errors.push(frame.payload); + }); + socket.on('error',()=>errors.push('socket failure'));clients.push(socket); + await once(socket,'open');socket.send(JSON.stringify({type:'connection_init',payload:{authorization:'Bearer '+tokens[subject]}})); + await until(()=>initialized||errors.length,'socket admission');assert.equal(errors.length,0); + socket.send(JSON.stringify({id,type:'subscribe',payload:{query:'subscription LoadLive { causal_query_views { title } }',...(resume?{extensions:{distributed:{resume:{cursors:resume}}}}:{})}})); + await until(()=>frames.length||errors.length,'live first frame');assert.equal(errors.length,0);assert.equal(frames[0].errors,undefined); + return {frames,errors,closed:()=>closed,ms:performance.now()-start,cancel:()=>socket.send(JSON.stringify({id,type:'complete'}))}; + } + try{ + const consumers=[]; + // Sequential admission makes steady-state producer count deterministic; + // all100 remain concurrently subscribed during the measured commit fanout. + for(let index=0;index<100;index++)consumers.push(await connect(String(index))); + const producerCount=(await metrics()).producers; + assert.equal(producerCount,mode==='live'?1:100); + const initial=consumers[0].frames[0];for(const consumer of consumers)assert.deepEqual(consumer.frames[0],initial); + const commitStart=performance.now(),title=await advance(); + await until(()=>consumers.every(consumer=>consumer.frames.some(frame=>frame.data?.causal_query_views?.[0]?.title.startsWith(title))),'100 commit deliveries'); + const fanoutMs=performance.now()-commitStart; + const liveReport={logicalSubscriptions:100,upstreamProducers:producerCount,origin:difference(await metrics(),liveBefore),wire:difference(snapshots(),liveBytes),clientFrameBytes:receivedBytes,initialLatencyMs:latency(consumers.map(consumer=>consumer.ms)),commitFanoutMs:fanoutMs}; + const bob=await connect('bob','bob');assert.notEqual(bob.frames[0].extensions.distributed.cacheScope,initial.extensions.distributed.cacheScope); + assert.equal((await metrics()).producers,producerCount+1);bob.cancel(); + await until(async()=>(await metrics()).producers===producerCount,'subject control teardown'); + for(const consumer of consumers)consumer.cancel(); + await until(async()=>(await metrics()).producers===0,'last subscriber teardown'); + const recoveryBefore=await metrics(),recoveryBytes=snapshots(),recoveryClientBytes=receivedBytes; + let recovery; + if(mode==='live'){ + for(let index=0;index<7;index++)await advance(); + const gap=await connect('gap','alice',initial.extensions.distributed.live.cursors); + assert.equal(gap.frames[0].extensions.distributed.live.reset,true,'old cursor gets an explicit origin reset'); + gap.cancel();await until(async()=>(await metrics()).producers===0,'gap teardown'); + if(host==='workerd'){ + const fast=await connect('fast'),slow=await connect('slow'); + for(let index=0;index<20;index++){ + const before=fast.frames.length;await advance();await until(()=>fast.frames.length>before,'healthy consumer receives update'); + } + await until(()=>slow.closed()||slow.errors.length,'slow consumer reset'); + assert.equal(slow.frames.length,1,'unacknowledged client has bounded network delivery'); + fast.cancel();await until(async()=>(await metrics()).producers===0,'slow scenario teardown'); + } + recovery={origin:difference(await metrics(),recoveryBefore),wire:difference(snapshots(),recoveryBytes),clientFrameBytes:receivedBytes-recoveryClientBytes,cursorResets:1,slowClientResets:host==='workerd'?1:0}; + } + report.rows.push({host,mode,recovery,query:queryReport,live:liveReport,decisions:await counts(),subjectControls:'query and live scope differ; separate producer',invalidation:'new projection returned; old cache envelope rejected'}); + console.log(`PASS ${host}/${mode}: 100 queries -> ${queryWork.resultExecutions} result SQL; 100 live -> ${producerCount} producers; measured origin and client bytes`); + }finally{for(const socket of clients)socket.terminate();} +} +try{ + for(const host of ['native','workerd'])for(const mode of ['none','flights','snapshots','live']){ + const runtime=host==='native'?{publicOrigin:native[mode],stop:async()=>{}}:await startRuntime({apiOrigin:meterOrigin,mode,artifact:'load-'+mode+'-workerd.log'}); + try{await modeRun(host,mode,runtime);}finally{await runtime.stop();} + } + await mkdir(root+'/artifacts',{recursive:true});await writeFile(root+'/artifacts/load-report.json',JSON.stringify(report,null,2)+'\n'); +}finally{for(const client of wss.clients)client.terminate();wss.close();meter.closeAllConnections();meter.close();} diff --git a/tests/gateway-worker/package-lock.json b/tests/gateway-worker/package-lock.json new file mode 100644 index 000000000..e3af7b6a1 --- /dev/null +++ b/tests/gateway-worker/package-lock.json @@ -0,0 +1,1609 @@ +{ + "name": "application-gateway-worker-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "application-gateway-worker-fixture", + "devDependencies": { + "miniflare": "5.20260903.0-alpha", + "wrangler": "4.129.0", + "ws": "8.21.3" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260903.1.tgz", + "integrity": "sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260903.1.tgz", + "integrity": "sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260903.1.tgz", + "integrity": "sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260903.1.tgz", + "integrity": "sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260903.1.tgz", + "integrity": "sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20260903.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260903.0-alpha.tgz", + "integrity": "sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260903.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260903.1.tgz", + "integrity": "sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260903.1", + "@cloudflare/workerd-darwin-arm64": "1.20260903.1", + "@cloudflare/workerd-linux-64": "1.20260903.1", + "@cloudflare/workerd-linux-arm64": "1.20260903.1", + "@cloudflare/workerd-windows-64": "1.20260903.1" + } + }, + "node_modules/wrangler": { + "version": "4.129.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.129.0.tgz", + "integrity": "sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260903.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260903.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260903.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/tests/gateway-worker/package.json b/tests/gateway-worker/package.json new file mode 100644 index 000000000..b2dc58bf1 --- /dev/null +++ b/tests/gateway-worker/package.json @@ -0,0 +1 @@ +{"name":"application-gateway-worker-fixture","private":true,"type":"module","scripts":{"build":"worker-build --dev","dev":"wrangler dev --local","test":"node run.mjs"},"devDependencies":{"wrangler":"4.129.0","ws":"8.21.3","miniflare":"5.20260903.0-alpha"}} diff --git a/tests/gateway-worker/proxy-runtime.mjs b/tests/gateway-worker/proxy-runtime.mjs new file mode 100644 index 000000000..3c6e8855d --- /dev/null +++ b/tests/gateway-worker/proxy-runtime.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {once} from 'node:events'; +import WebSocket,{WebSocketServer} from 'ws'; +import {startRuntime} from './runtime.mjs'; +let streamClosed=false,upgrades=0,mutations=0; +const server=createServer(async(request,response)=>{ + if(request.url==='/redirect'){response.writeHead(302,{location:origin+'/target?one=1'});response.end();return;} + if(request.url==='/cookies'){response.writeHead(200,{'set-cookie':['first=1; HttpOnly; SameSite=Lax','second=2; Secure; SameSite=Strict']});response.end('cookies');return;} + if(request.url==='/stream'){ + response.writeHead(200,{'content-type':'application/octet-stream'});const interval=setInterval(()=>response.write(Buffer.alloc(4096,7)),10); + response.on('close',()=>{clearInterval(interval);streamClosed=true;});return; + } + if(request.url==='/failure'){response.writeHead(503);response.end('origin failure');return;} + let bytes=0,body='';try{for await(const chunk of request){bytes+=chunk.length;if(request.url==='/graphql')body+=chunk;}}catch{return;} + if(request.url==='/graphql'&&JSON.parse(body).query.startsWith('mutation'))mutations++; + response.writeHead(200,{'content-type':'application/json'});response.end(JSON.stringify({headers:request.headers,bytes,method:request.method,data:{accepted:true}})); +}); +const wsServer=new WebSocketServer({noServer:true}); +server.on('upgrade',(request,socket,head)=>wsServer.handleUpgrade(request,socket,head,ws=>{upgrades++;ws.on('message',(bytes,binary)=>{ + if(request.url==='/graphql/ws'){ + const message=JSON.parse(bytes); + if(message.type==='connection_init')ws.send(JSON.stringify({type:'connection_ack'})); + else if(message.type==='subscribe'||message.type==='start'){ + if(message.payload.query.startsWith('mutation'))mutations++; + ws.send(JSON.stringify({id:message.id,type:message.type==='start'?'data':'next',payload:{data:{accepted:true},extensions:{unchanged:'origin'}}})); + ws.send(JSON.stringify({id:message.id,type:'complete'})); + } + }else ws.send(bytes,{binary}); + });ws.on('close',()=>upgrades--);})); +server.listen(0,'127.0.0.1');await once(server,'listening');const origin=`http://127.0.0.1:${server.address().port}`; +let runtime; +async function until(predicate){for(let i=0;i<300;i++){if(await predicate())return;await new Promise(r=>setTimeout(r,20));}throw Error('proxy condition not reached');} +try{ + runtime=await startRuntime({apiOrigin:origin,requestBytes:65536,mode:'none',artifact:'proxy-workerd.log'});const base=runtime.publicOrigin; + const headers=(await(await fetch(base+'/echo',{headers:{origin:base,cookie:'opaque=session','x-user-id':'mallory','x-forwarded-host':'attacker.invalid','x-distributed-subject':'mallory'}})).json()).headers; + assert.equal(headers.origin,base);assert.equal(headers.cookie,'opaque=session');assert.equal(headers['x-forwarded-host'],new URL(base).host);assert.equal(headers['x-forwarded-proto'],'http');assert.equal(headers['x-user-id'],undefined);assert.equal(headers['x-distributed-subject'],undefined); + const redirect=await fetch(base+'/redirect',{redirect:'manual'});assert.equal(redirect.status,302);assert.equal(redirect.headers.get('location'),base+'/target?one=1'); + assert.equal((await fetch(base+'/cookies')).headers.getSetCookie().length,2); + assert.equal((await fetch(base+'/failure')).status,503);assert.equal((await fetch(base+'/__owned/missing')).status,404);assert.equal((await fetch(base+'/graphql')).status,405); + assert.equal((await fetch(base+'/graphql',{method:'POST',body:JSON.stringify({query:'mutation Change { change }'})})).status,200);assert.equal(mutations,1); + console.log('PASS workerd header trust, redirects, duplicate cookies, terminal errors and one command execution'); + const upload=new ReadableStream({start(controller){for(let i=0;i<4;i++)controller.enqueue(new Uint8Array(4096));controller.close();}}); + const result=await(await fetch(base+'/upload',{method:'POST',body:upload,duplex:'half'})).json();assert.equal(result.bytes,16384); + assert.equal((await fetch(base+'/graphql',{method:'POST',body:'x'.repeat(65537)})).status,413); + const tooBig=new ReadableStream({start(controller){controller.enqueue(new Uint8Array(65537));controller.close();}}); + assert.equal((await fetch(base+'/upload',{method:'POST',body:tooBig,duplex:'half'})).status,413); + const stream=await fetch(base+'/stream');const reader=stream.body.getReader();assert.ok((await reader.read()).value.length);await reader.cancel();await until(()=>streamClosed); + console.log('PASS actual streamed upload, body limits and response cancellation reaches origin'); + const ws=new WebSocket(base.replace('http:','ws:')+'/socket');await once(ws,'open'); + let next=once(ws,'message');ws.send('hello');let [data,binary]=await next;assert.equal(data.toString(),'hello');assert.equal(binary,false); + next=once(ws,'message');ws.send(Buffer.from([0,255,17]));[data,binary]=await next;assert.deepEqual([...data],[0,255,17]);assert.equal(binary,true); + const closed=once(ws,'close');ws.close(1000,'done');const [code,reason]=await closed;assert.equal(code,1000);assert.equal(reason.toString(),'done');await until(()=>upgrades===0); + console.log('PASS actual UI text/binary WebSocket relay and close teardown'); + for(const legacy of [false,true]){ + const socket=new WebSocket(base.replace('http:','ws:')+'/graphql/ws',legacy?'graphql-ws':'graphql-transport-ws');await once(socket,'open'); + const messages=[];socket.on('message',data=>messages.push(JSON.parse(data))); + socket.send(JSON.stringify({type:'connection_init',payload:{opaque:'origin-owned'}}));await until(()=>messages.some(m=>m.type==='connection_ack')); + for(const [id,query] of [['query','query Read { value }'],['command','mutation Change { change }']]){ + socket.send(JSON.stringify({id,type:legacy?'start':'subscribe',payload:{query}}));await until(()=>messages.some(m=>m.id===id&&m.type==='complete')); + const frame=messages.find(m=>m.id===id&&m.type===(legacy?'data':'next'));assert.deepEqual(frame.payload,{data:{accepted:true},extensions:{unchanged:'origin'}}); + } + socket.close();await once(socket,'close'); + } + assert.equal(mutations,3);await until(()=>upgrades===0); + console.log('PASS modern/legacy query and command envelopes with delivery entirely disabled'); + await runtime.stop();runtime=await startRuntime({apiOrigin:origin,requestBytes:65536,mode:'all',artifact:'proxy-coordinated-workerd.log'}); + const socket=new WebSocket(runtime.publicOrigin.replace('http:','ws:')+'/graphql/ws','graphql-transport-ws');await once(socket,'open'); + const messages=[];socket.on('message',data=>messages.push(JSON.parse(data))); + socket.send(JSON.stringify({type:'connection_init',payload:{opaque:'origin-owned'}}));await until(()=>messages.some(m=>m.type==='connection_ack')); + for(const [id,query] of [['read','query Read { value }'],['write','mutation Change { change }']]){ + socket.send(JSON.stringify({id,type:'subscribe',payload:{query}}));await until(()=>messages.some(m=>m.id===id&&m.type==='complete')); + assert.deepEqual(messages.find(m=>m.id===id&&m.type==='next').payload,{data:{accepted:true},extensions:{unchanged:'origin'}}); + } + socket.close();await once(socket,'close');await until(()=>upgrades===0);assert.equal(mutations,4); + console.log('PASS delivery-enabled command identity and query fallback for origins without control protocol'); + + +}finally{await runtime?.stop();for(const ws of wsServer.clients)ws.terminate();await new Promise(r=>wsServer.close(r));server.closeAllConnections();await new Promise(r=>server.close(r));} diff --git a/tests/gateway-worker/query-runtime.mjs b/tests/gateway-worker/query-runtime.mjs new file mode 100644 index 000000000..eacdd7512 --- /dev/null +++ b/tests/gateway-worker/query-runtime.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import {startRuntime} from './runtime.mjs'; +import {startShardedRuntime} from './sharded-runtime.mjs'; +const apiOrigin=process.env.GATEWAY_ORIGIN; +assert.ok(apiOrigin?.startsWith('http://127.0.0.1:'),'isolated origin required'); +let runtime=await startRuntime({apiOrigin}); +const body=JSON.stringify({query:'query WorkerShared { causal_query_views { title } }'}); +const query=async()=>{const response=await fetch(runtime.publicOrigin+'/graphql',{method:'POST',headers:{'content-type':'application/json'},body});assert.equal(response.status,200,await response.clone().text());const value=await response.json();assert.equal(value.errors,undefined,JSON.stringify(value));return value;}; +const metrics=async()=>await(await fetch(apiOrigin+'/__metrics')).json(); +async function until(predicate){for(let n=0;n<300;n++){if(await predicate())return;await new Promise(resolve=>setTimeout(resolve,20));}throw Error('coordinator condition not reached');} +try{ + const pending=Promise.all(Array.from({length:100},()=>query())); + pending.catch(()=>{}); + await until(async()=>{const counts=await(await fetch(runtime.publicOrigin+'/__coordinators')).json();return counts.reduce((n,c)=>n+c.query[2],0)===100;}); + assert.deepEqual(await metrics(),{validations:100,resultExecutions:0}); + await fetch(apiOrigin+'/__release',{method:'POST'}); + const responses=await pending;for(const response of responses)assert.deepEqual(response,responses[0]); + assert.equal((await metrics()).resultExecutions,1); + const before=await metrics();await query();const hit=await metrics();assert.equal(hit.resultExecutions,before.resultExecutions);assert.equal(hit.validations,before.validations+1); + console.log('PASS actual workerd DO: 100 admitted queries, one actual origin SQL execution, current private hit'); + await fetch(apiOrigin+'/__write',{method:'POST'}); + assert.equal((await query()).data.causal_query_views[0].title,'external write'); + assert.equal((await metrics()).resultExecutions,2); + console.log('PASS missed feed/external SQL write invalidates through current origin validation'); + await runtime.stop();runtime=await startRuntime({apiOrigin,artifact:'query-restarted-workerd.log'}); + assert.equal((await query()).data.causal_query_views[0].title,'external write'); + assert.equal((await metrics()).resultExecutions,3); + console.log('PASS actual workerd restart loses cache and revalidates/refills'); + await runtime.stop();runtime=await startShardedRuntime(apiOrigin); + assert.equal(await(await fetch(runtime.at(0,'/__gateway_health'))).text(),'ingress-a'); + assert.equal(await(await fetch(runtime.at(1,'/__gateway_health'))).text(),'ingress-b'); + await fetch(apiOrigin+'/__block',{method:'POST'});const beforeShard=await metrics(); + const shared=Promise.all(Array.from({length:100},async(_,index)=>{const response=await fetch(runtime.at(index,'/graphql'),{method:'POST',headers:{'content-type':'application/json'},body});assert.equal(response.status,200);return response.json();}));shared.catch(()=>{}); + await until(async()=>{const counts=await(await fetch(runtime.at(0,'/__coordinators'))).json();return counts.reduce((n,c)=>n+c.query[2],0)===100;}); + assert.equal((await metrics()).validations,beforeShard.validations+100); + await fetch(apiOrigin+'/__release',{method:'POST'});const shardResults=await shared;for(const result of shardResults)assert.deepEqual(result,shardResults[0]); + assert.equal((await metrics()).resultExecutions,beforeShard.resultExecutions+1); + console.log('PASS two distinct ingress Wasm isolates coordinate100queries in one selected Durable Object'); + + await fetch(runtime.at(0,'/__coordinators'),{method:'POST'}); + await fetch(apiOrigin+'/__block',{method:'POST'}); + const controllers=[new AbortController(),new AbortController()]; + const cancellable=controllers.map((controller,index)=>new Promise(resolve=>{const request=http.request(runtime.at(index,'/graphql'),{method:'POST',agent:false,headers:{'content-type':'application/json',...(index===0?{'x-fixture-cancel':'yes'}:{})},signal:controller.signal},response=>{let data='';response.on('data',chunk=>data+=chunk);response.on('end',()=>resolve(JSON.parse(data)));});request.on('error',error=>resolve(error.name));request.end(body);})); + const consumers=async()=>{const all=await(await fetch(runtime.at(0,'/__coordinators'))).json();return all.reduce((n,c)=>n+c.query[2],0);}; + await until(async()=>await consumers()===2);controllers[0].abort(); + await until(async()=>await consumers()===1); + await fetch(apiOrigin+'/__release',{method:'POST'});assert.equal(await cancellable[0],'AbortError');assert.ok((await cancellable[1]).data); + console.log('PASS ingress AbortSignal releases one DO consumer while another completes'); + await fetch(runtime.at(0,'/__coordinators'),{method:'POST'});await fetch(apiOrigin+'/__block',{method:'POST'}); + const last=fetch(runtime.at(0,'/graphql'),{method:'POST',headers:{'content-type':'application/json','x-fixture-cancel':'yes'},body}).catch(()=>null); + await until(async()=>await consumers()===1);await until(async()=>await consumers()===0);await last; + console.log('PASS last ingress cancellation releases the actual DO flight'); + +}finally{await runtime.stop();} diff --git a/tests/gateway-worker/run.mjs b/tests/gateway-worker/run.mjs new file mode 100644 index 000000000..dcc588959 --- /dev/null +++ b/tests/gateway-worker/run.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import {root,freePort,startRuntime} from './runtime.mjs'; +const port=await freePort(); +const publicOrigin=`http://127.0.0.1:${port}`; +process.env.GATEWAY_TEST_ORIGIN=publicOrigin; +process.chdir(path.join(root,'../gateway-auth')); +const {startFixture,exerciseAuth}=await import('../gateway-auth/run.mjs'); +for(const secureCookies of [false,true]){ + process.chdir(path.join(root,'../gateway-auth')); + const fixture=await startFixture({secureCookies}); + process.chdir(root); + let runtime; + try{ + runtime=await startRuntime({port,uiOrigin:fixture.uiOrigin,apiOrigin:fixture.uiOrigin,artifact:secureCookies?'secure-auth-workerd.log':'auth-workerd.log'}); + assert.equal((await fetch(publicOrigin+'/__owned/not-found')).status,404); + assert.equal((await fetch(publicOrigin+'/',{headers:{authorization:'Bearer invalid'}})).status,401); + if(secureCookies){ + const response=await fetch(publicOrigin+'/login',{redirect:'manual'}); + assert.equal(response.status,302);const cookies=response.headers.getSetCookie(); + assert.ok(cookies.length>=3);assert.ok(cookies.every(cookie=>/; Secure(?:;|$)/i.test(cookie))); + console.log('PASS explicit secure-cookie policy survives actual workerd delegation'); + }else{ + await exerciseAuth(fixture); + console.log('PASS real production Auth.js/OIDC through actual workerd ingress'); + } + }finally{await runtime?.stop();await fixture.stop();} +} diff --git a/tests/gateway-worker/runtime.mjs b/tests/gateway-worker/runtime.mjs new file mode 100644 index 000000000..fb6e0c8c9 --- /dev/null +++ b/tests/gateway-worker/runtime.mjs @@ -0,0 +1,22 @@ +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { mkdir,writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +export const root=path.dirname(fileURLToPath(import.meta.url)); +export async function freePort(){const server=createServer();server.listen(0,'127.0.0.1');await once(server,'listening');const port=server.address().port;await new Promise(resolve=>server.close(resolve));return port;} +export async function startRuntime({apiOrigin,uiOrigin=apiOrigin,mode='all',port=0,artifact='query-workerd.log',requestBytes=16*1024*1024}={}){ + port ||= await freePort(); const publicOrigin=`http://127.0.0.1:${port}`;let log=''; + const runner=spawn(process.execPath,['node_modules/wrangler/bin/wrangler.js','dev','--local','--ip','127.0.0.1','--port',String(port),'--var',`PUBLIC_ORIGIN:${publicOrigin}`,'--var',`UI_ORIGIN:${uiOrigin}`,'--var',`API_ORIGIN:${apiOrigin}`,'--var',`DELIVERY_MODE:${mode}`,'--var',`REQUEST_BYTES:${requestBytes}`],{cwd:root,env:{PATH:process.env.PATH,HOME:process.env.HOME,RUSTUP_TOOLCHAIN:process.env.RUSTUP_TOOLCHAIN||'stable',WRANGLER_SEND_METRICS:'false',CI:'1'},stdio:['ignore','pipe','pipe']}); + runner.stdout.on('data',chunk=>{log+=chunk;});runner.stderr.on('data',chunk=>{log+=chunk;}); + const stop=async()=>{if(runner.exitCode===null){runner.kill('SIGTERM');await once(runner,'exit');}await mkdir(path.join(root,'artifacts'),{recursive:true});await writeFile(path.join(root,'artifacts',artifact),log);}; + try{ + for(let n=0;n<600;n++){ + if(runner.exitCode!==null)throw Error(`workerd exited: ${log.slice(-7000)}`); + try{if((await fetch(`${publicOrigin}/__gateway_health`)).ok)return {publicOrigin,port,stop,logs:()=>log};}catch{} + await new Promise(resolve=>setTimeout(resolve,200)); + } + throw Error(`workerd readiness timeout: ${log.slice(-7000)}`); + }catch(error){await stop();throw error;} +} diff --git a/tests/gateway-worker/sharded-runtime.mjs b/tests/gateway-worker/sharded-runtime.mjs new file mode 100644 index 000000000..e62a5b87d --- /dev/null +++ b/tests/gateway-worker/sharded-runtime.mjs @@ -0,0 +1,14 @@ +import {Miniflare,convertV4MiniflareOptions} from 'miniflare'; +import {root,freePort} from './runtime.mjs'; +import path from 'node:path'; +export async function startShardedRuntime(apiOrigin){ + const port=await freePort();const publicOrigin=`http://127.0.0.1:${port}`; + const common={modules:[{type:'ESModule',path:path.join(root,'build/index.js')},{type:'CompiledWasm',path:path.join(root,'build/index_bg.wasm')}],modulesRoot:path.join(root,'build'),compatibilityDate:'2026-09-03',compatibilityFlags:['enable_request_signal']}; + const worker=(name)=>({...common,name,bindings:{PUBLIC_ORIGIN:publicOrigin,UI_ORIGIN:apiOrigin,API_ORIGIN:apiOrigin,DELIVERY_MODE:'all',INGRESS_ID:name},durableObjects:{DELIVERY:{className:'DeliveryCoordinator',...(name==='coordinator'?{}:{scriptName:'coordinator'}),useSQLite:true}}}); + const mf=new Miniflare(convertV4MiniflareOptions({cf:false,host:'127.0.0.1',port:await freePort(),workers:[ + {name:'test-distributor',unsafeDirectSockets:[{host:'127.0.0.1',port,proxy:false}],modules:true,compatibilityDate:'2026-09-03',compatibilityFlags:['enable_request_signal'],serviceBindings:{A:'ingress-a',B:'ingress-b'},script:`export default {fetch(request,env){const url=new URL(request.url);const target=url.searchParams.get('__ingress')==='b'?env.B:env.A;url.searchParams.delete('__ingress');const outbound=new Request(url,request);if(request.headers.get('x-fixture-cancel')==='yes'){const controller=new AbortController();setTimeout(()=>controller.abort(),500);return target.fetch(new Request(outbound,{signal:controller.signal}));}return target.fetch(outbound);}}`}, + worker('ingress-a'),worker('ingress-b'),worker('coordinator'), + ]})); + try{await mf.ready;}catch(error){await mf.dispose();throw error;} + return {publicOrigin,stop:()=>mf.dispose(),at:(index,path)=>`${publicOrigin}${path}${path.includes('?')?'&':'?'}__ingress=${index%2?'b':'a'}`}; +} diff --git a/tests/gateway-worker/src/lib.rs b/tests/gateway-worker/src/lib.rs new file mode 100644 index 000000000..ad5af9a2a --- /dev/null +++ b/tests/gateway-worker/src/lib.rs @@ -0,0 +1,183 @@ +use ::worker::wasm_bindgen; +use ::worker::{ + durable_object, event, Context, DurableObject, Env, Request, Response, Result, State, +}; +use distributed::gateway::{delivery::*, worker::*, *}; +use std::rc::Rc; +fn delivery_options(env: &Env) -> WorkerDeliveryOptions { + let mode = env + .var("DELIVERY_MODE") + .map(|v| v.to_string()) + .unwrap_or_else(|_| "all".into()); + if mode == "none" { + return WorkerDeliveryOptions::default(); + } + WorkerDeliveryOptions { + snapshots: (mode == "all" || mode == "snapshots").then_some(SnapshotLimits { + entries: 128, + bytes: 2 * 1024 * 1024, + entry_bytes: 256 * 1024, + }), + coalescing: (mode == "all" || mode == "flights").then_some(FlightLimits { + groups: 8, + consumers: 128, + response_bytes: 256 * 1024, + ..Default::default() + }), + live: (mode == "all" || mode == "live").then(|| LiveLimits { + groups: 4, + consumers: 128, + frame_bytes: 64 * 1024, + ..Default::default() + }), + } +} +fn gateway(env: &Env) -> Result { + let origin = env.var("UI_ORIGIN")?.to_string(); + let public = env.var("PUBLIC_ORIGIN")?.to_string(); + let api = env + .var("API_ORIGIN") + .map(|v| v.to_string()) + .unwrap_or_else(|_| origin.clone()); + let options = delivery_options(env); + let config = GatewayConfig { + bindings: vec![ + Binding::new("ui", BindingKind::UiProxy { origin }), + Binding::new("health", BindingKind::Handler), + Binding::new("owned", BindingKind::Handler), + Binding::new( + "api", + BindingKind::Graphql { + executor: GraphqlExecutor::Remote { origin: api }, + capabilities: GraphqlCapabilities { + queries: true, + commands: true, + live: true, + }, + delivery: DeliveryCapabilities { + snapshots: options.snapshots.is_some(), + coalescing: options.coalescing.is_some(), + live_sharing: options.live.is_some(), + }, + schema_extensions: vec![], + }, + ), + ], + routes: vec![ + Route::new("health", RoutePath::exact("/__gateway_health"), "health"), + Route::new("owned", RoutePath::prefix("/__owned"), "owned"), + Route::new("api", RoutePath::prefix("/graphql"), "api"), + Route::new("ui", RoutePath::prefix("/"), "ui"), + ], + } + .build() + .map_err(|error| ::worker::Error::RustError(error.to_string()))?; + let mut worker_options = WorkerOptions::new(public); + worker_options + .strip_headers + .push("x-distributed-subject".into()); + if let Ok(limit) = env.var("REQUEST_BYTES") { + worker_options.limits.request_bytes = + limit.to_string().parse().expect("fixture request limit"); + } + WorkerGateway::new( + config, + worker_options, + [ + ("ui".into(), WorkerBinding::UiProxy { websocket: true }), + ( + "health".into(), + WorkerBinding::Handler(WorkerHandler::new(|_, _, env| async move { + Response::ok( + env.var("INGRESS_ID") + .map(|v| v.to_string()) + .unwrap_or_else(|_| "ready".into()), + ) + })), + ), + ( + "owned".into(), + WorkerBinding::Handler(WorkerHandler::new(|_, _, _| async { + Response::error("owned error", 404) + })), + ), + ( + "api".into(), + WorkerBinding::Graphql { + http_path: "/graphql".into(), + live_path: Some("/graphql/ws".into()), + delivery: (options.snapshots.is_some() + || options.coalescing.is_some() + || options.live.is_some()) + .then_some(WorkerDeliveryBinding { + namespace: "DELIVERY".into(), + epoch: "fixture-v1".into(), + shards: 4, + options, + }), + }, + ), + ], + WorkerAuth::anonymous(), + ) + .map_err(|error| ::worker::Error::RustError(error.to_string())) +} +#[durable_object] +pub struct DeliveryCoordinator { + env: Env, + coordinator: Rc, +} +impl DurableObject for DeliveryCoordinator { + fn new(_state: State, env: Env) -> Self { + Self { + coordinator: WorkerCoordinator::new(delivery_options(&env)) + .expect("validated fixture limits"), + env, + } + } + async fn fetch(&self, request: Request) -> Result { + match request.path().as_str() { + "/__metrics" => { + return Response::from_json( + &serde_json::json!({"query":self.coordinator.counts(),"live":self.coordinator.live_counts(),"metrics":self.coordinator.metrics()}), + ) + } + "/__reset" => { + self.coordinator.invalidate_all(); + return Response::ok("reset"); + } + _ => {} + } + gateway(&self.env)? + .fetch_coordinated(request, self.env.clone(), self.coordinator.clone()) + .await + } +} +#[event(fetch)] +async fn fetch(request: Request, env: Env, _ctx: Context) -> Result { + if request.path() == "/__coordinators" { + let options = delivery_options(&env); + if options.snapshots.is_none() && options.coalescing.is_none() && options.live.is_none() { + return Response::from_json(&Vec::::new()); + } + let reset = request.method() == ::worker::Method::Post; + let mut counts = Vec::new(); + for shard in 0..4 { + let namespace = env.durable_object("DELIVERY")?; + let stub = namespace + .id_from_name(&format!("gateway-delivery-v1:fixture-v1:api:{shard}"))? + .get_stub()?; + let mut response = stub + .fetch_with_str(&format!( + "http://internal.invalid/{}", + if reset { "__reset" } else { "__metrics" } + )) + .await?; + if !reset { + counts.push(response.json::().await?); + } + } + return Response::from_json(&counts); + } + gateway(&env)?.fetch(request, env).await +} diff --git a/tests/gateway-worker/wrangler.jsonc b/tests/gateway-worker/wrangler.jsonc new file mode 100644 index 000000000..d42ea3df5 --- /dev/null +++ b/tests/gateway-worker/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "application-gateway-local-fixture", + "main": "build/worker/shim.mjs", + "compatibility_flags": ["enable_request_signal"], + "compatibility_date": "2026-09-03", + "workers_dev": false, + "durable_objects": {"bindings":[{"name":"DELIVERY","class_name":"DeliveryCoordinator"}]}, + "migrations":[{"tag":"gateway-v1","new_sqlite_classes":["DeliveryCoordinator"]}], + "build": {"command":"worker-build --dev"}, + "vars": { + "PUBLIC_ORIGIN": "http://127.0.0.1:8787", + "UI_ORIGIN": "http://127.0.0.1:3000" + } +} diff --git a/tests/gateway_auth.rs b/tests/gateway_auth.rs new file mode 100644 index 000000000..2f93be139 --- /dev/null +++ b/tests/gateway_auth.rs @@ -0,0 +1,115 @@ +#![cfg(feature = "gateway")] +mod gateway_support; +use distributed::gateway::*; +use gateway_support::run; + +struct Provider { + revision: &'static str, +} +impl AuthProvider for Provider { + async fn authenticate(&self, credentials: &Credentials) -> Result { + let (identity, backend) = match credentials.authorization.as_deref() { + None => (None, BackendCredential::None), + Some("valid") | Some("expired") => ( + Some( + Identity::verified( + "issuer", + "alice", + vec!["user".into()], + if credentials.authorization.as_deref() == Some("expired") { + 10 + } else { + 100 + }, + ) + .unwrap(), + ), + BackendCredential::Bearer("validated-token".into()), + ), + _ => return Err(AuthError::Unauthorized), + }; + Ok(RequestContext::from_provider(identity, self.revision, backend).unwrap()) + } +} + +#[test] +fn shared_context_and_delegated_handlers() { + let provider = Provider { revision: "v1" }; + let anonymous = run(provider.authenticate(&Credentials::default())).unwrap(); + assert_eq!(Admission::Public.check(&anonymous, 20), Ok(())); + assert_eq!( + Admission::Authenticated.check(&anonymous, 20), + Err(AuthError::Unauthorized) + ); + let credentials = Credentials { + authorization: Some("valid".into()), + cookie: None, + }; + let context = run(provider.authenticate(&credentials)).unwrap(); + for _route in ["UI", "API", "custom", "protected-assets"] { + assert_eq!(Admission::Authenticated.check(&context, 20), Ok(())); + assert_eq!(context.identity().unwrap().subject(), "alice"); + } + // Route admission is not backend authorization; a more privileged action + // still fails its own policy after successful gateway admission. + assert_eq!( + Admission::Role("admin".into()).check(&context, 20), + Err(AuthError::Forbidden) + ); + let replacement = run(Provider { revision: "v2" }.authenticate(&credentials)).unwrap(); + assert_ne!(context.provider_revision(), replacement.provider_revision()); + assert!(run(provider.authenticate(&Credentials { + authorization: Some("invalid".into()), + cookie: None + })) + .is_err()); + let expired = run(provider.authenticate(&Credentials { + authorization: Some("expired".into()), + cookie: None, + })) + .unwrap(); + assert_eq!( + Admission::Public.check(&expired, 20), + Err(AuthError::Unauthorized) + ); + assert_eq!( + Admission::Authenticated.check(&context, 100), + Err(AuthError::Unauthorized) + ); +} + +#[test] +fn credentials_and_assertions_cannot_leak_or_be_guessed() { + let credentials = Credentials { + authorization: Some("secret-token".into()), + cookie: Some("session-secret".into()), + }; + let debug = format!( + "{credentials:?} {:?}", + BackendCredential::Bearer("secret-token".into()) + ); + assert!(!debug.contains("secret-token")); + assert!(!debug.contains("session-secret")); + assert!( + RequestContext::from_provider(None, "v1", BackendCredential::Bearer("token".into())) + .is_err() + ); + for name in [ + "X-User-Id", + "X-Roles", + "X-Hasura-Allowed-Roles", + "Forwarded", + "X-Forwarded-Host", + "CF-Access-Jwt-Assertion", + ] { + assert!(is_untrusted_identity_header(name)); + } + assert!(!is_untrusted_identity_header("content-type")); + // A bearer-only provider cannot promote a session cookie into a token. + let context = run(Provider { revision: "v1" }.authenticate(&Credentials { + authorization: None, + cookie: Some("valid".into()), + })) + .unwrap(); + assert!(context.identity().is_none()); +} diff --git a/tests/gateway_extensions.rs b/tests/gateway_extensions.rs new file mode 100644 index 000000000..ee1949606 --- /dev/null +++ b/tests/gateway_extensions.rs @@ -0,0 +1,164 @@ +#![cfg(feature = "gateway")] +mod gateway_support; +use distributed::gateway::*; +use gateway_support::*; +use std::rc::Rc; + +fn graphql(executor: GraphqlExecutor, schema_extensions: Vec) -> BindingKind { + BindingKind::Graphql { + executor, + schema_extensions, + capabilities: GraphqlCapabilities { + commands: true, + queries: true, + live: true, + }, + delivery: DeliveryCapabilities::default(), + } +} + +#[test] +fn custom_route_provider_and_field() { + let mut c = config(); + c.bindings.push(Binding::new( + "schema", + graphql( + GraphqlExecutor::Embedded, + vec!["custom_health_field".into()], + ), + )); + let mut custom = Route::new("custom", RoutePath::exact("/custom"), "schema"); + custom.admission = vec!["identity".into()]; + c.routes.push(custom); + let gateway = c.build().unwrap(); + let mut adapter = Adapter::new(200); + adapter.provider = Rc::new("replacement-provider".into()); + let response = run(gateway.dispatch( + &adapter, + Request { + method: "GET", + target: "/custom", + authorized: true, + }, + )); + assert_eq!(response.body, "schema:replacement-provider"); + assert_eq!( + *adapter.calls.borrow(), + ["admit:identity", "execute:schema"] + ); + let BindingKind::Graphql { + schema_extensions, + delivery, + .. + } = &gateway.binding("schema").unwrap().kind + else { + panic!("wrong executor kind") + }; + assert_eq!(schema_extensions, &["custom_health_field"]); + assert_eq!(*delivery, DeliveryCapabilities::default()); +} + +#[test] +fn remote_binding_requires_extensions_at_its_executor() { + let remote = GraphqlExecutor::Remote { + origin: "https://api.example.test".into(), + }; + for extensions in [vec![], vec!["local_field".into()]] { + let mut c = config(); + c.bindings.push(Binding::new( + "schema", + graphql(remote.clone(), extensions.clone()), + )); + assert_eq!(c.build().is_ok(), extensions.is_empty()); + } + let mut c = config(); + c.bindings.push(Binding::new( + "schema", + graphql(GraphqlExecutor::Embedded, vec!["field".into(); 2]), + )); + assert!(c.build().is_err()); +} + +#[test] +fn delivery_mounts_are_independent_and_require_their_surface() { + for queries in [false, true] { + for live in [false, true] { + for snapshots in [false, true] { + for coalescing in [false, true] { + for live_sharing in [false, true] { + let c = GatewayConfig { + bindings: vec![Binding::new( + "schema", + BindingKind::Graphql { + executor: GraphqlExecutor::Embedded, + capabilities: GraphqlCapabilities { + commands: true, + queries, + live, + }, + delivery: DeliveryCapabilities { + snapshots, + coalescing, + live_sharing, + }, + schema_extensions: vec![], + }, + )], + routes: vec![], + }; + assert_eq!( + c.build().is_ok(), + (!(snapshots || coalescing) || queries) && (!live_sharing || live) + ); + } + } + } + } + } + let c = GatewayConfig { + bindings: vec![Binding::new( + "empty", + BindingKind::Graphql { + executor: GraphqlExecutor::Embedded, + capabilities: GraphqlCapabilities::default(), + delivery: DeliveryCapabilities::default(), + schema_extensions: vec![], + }, + )], + routes: vec![], + }; + assert!(c.build().is_err()); +} + +#[test] +fn native_adapter_can_return_a_send_dispatch_future() { + struct Native; + impl GatewayAdapter for Native { + type Request = String; + type Context = (); + type Response = u16; + fn method<'a>(&self, _: &'a String) -> &'a str { + "GET" + } + fn target<'a>(&self, request: &'a String) -> &'a str { + request + } + async fn admit(&self, _: SelectedRoute<'_>, _: &String) -> Result<(), u16> { + Ok(()) + } + async fn execute(&self, _: SelectedRoute<'_>, _: (), _: String) -> u16 { + 204 + } + fn reject(&self, _: Rejection<'_>) -> u16 { + 404 + } + } + fn require_send(value: T) -> T { + value + } + let gateway = config().build().unwrap(); + assert_eq!( + run(require_send(gateway.dispatch(&Native, "/".into()))), + 204 + ); +} diff --git a/tests/gateway_graphql.rs b/tests/gateway_graphql.rs new file mode 100644 index 000000000..0dcc28653 --- /dev/null +++ b/tests/gateway_graphql.rs @@ -0,0 +1,442 @@ +#![cfg(all(feature = "gateway-graphql-native", feature = "sqlite"))] +use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema, Subscription}; +use async_graphql_axum::{GraphQLProtocol, GraphQLRequest, GraphQLResponse, GraphQLWebSocket}; +use axum::{ + extract::ws::WebSocketUpgrade, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use distributed::gateway::{native::*, *}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::task::JoinHandle; +use tokio_tungstenite::{ + tungstenite::{client::IntoClientRequest, Message}, + MaybeTlsStream, WebSocketStream, +}; + +struct Server { + origin: String, + task: JoinHandle<()>, +} +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} +async fn serve(router: Router) -> Server { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + Server { + origin, + task: tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }), + } +} +fn mounted( + binding: GraphqlBinding, + executor: GraphqlExecutor, + caps: GraphqlCapabilities, + extensions: Vec, +) -> Router { + mounted_with_options( + binding, + executor, + caps, + extensions, + NativeOptions::new("https://public.example.invalid"), + ) +} +fn mounted_with_options( + binding: GraphqlBinding, + executor: GraphqlExecutor, + caps: GraphqlCapabilities, + extensions: Vec, + options: NativeOptions, +) -> Router { + let gateway = GatewayConfig { + bindings: vec![Binding::new( + "graphql", + BindingKind::Graphql { + executor, + capabilities: caps, + delivery: DeliveryCapabilities::default(), + schema_extensions: extensions, + }, + )], + routes: vec![Route::new("api", RoutePath::prefix("/graphql"), "graphql")], + } + .build() + .unwrap(); + NativeGateway::new( + gateway, + options, + [("graphql".into(), NativeBinding::Graphql(binding))], + NativeAuth::anonymous(), + ) + .unwrap() + .router() +} +struct CustomQuery; +#[Object] +impl CustomQuery { + async fn extension_value(&self, input: String) -> String { + input + } +} +fn custom_router(filter: distributed::graphql::GraphqlOperationFilter) -> Router { + let schema = Schema::build(CustomQuery, EmptyMutation, EmptySubscription).finish(); + Router::new().route( + "/graphql", + post(move |request: GraphQLRequest| { + let schema = schema.clone(); + let filter = filter.clone(); + async move { + let request = request.into_inner(); + let mut result = match filter(&request) { + Ok(()) => schema.execute(request).await, + Err(error) => async_graphql::Response::from_errors(vec![error]), + }; + result.extensions.insert( + "originProof".into(), + async_graphql::value!({"generation": "g7", "opaque": "unchanged"}), + ); + GraphQLResponse::from(result) + } + }), + ) +} + +#[tokio::test] +async fn embedded_remote_parity() { + let caps = GraphqlCapabilities { + queries: true, + ..Default::default() + }; + let embedded = EmbeddedGraphql::custom(custom_router, caps, ["extra-field".into()]).unwrap(); + let direct = serve(mounted( + GraphqlBinding::Embedded(embedded.clone()), + GraphqlExecutor::Embedded, + caps, + vec!["extra-field".into()], + )) + .await; + // Custom fields are registered at the complete origin executor. The remote + // binding has no local schema extensions or field federation. + let origin = serve(custom_router(Arc::new(|_| Ok(())))).await; + let remote = serve(mounted( + GraphqlBinding::Remote(RemoteGraphql { + live_path: None, + ..Default::default() + }), + GraphqlExecutor::Remote { + origin: origin.origin.clone(), + }, + caps, + vec![], + )) + .await; + let client = reqwest::Client::new(); + for request in [ + json!({ "query": "query First { extensionValue(input: \"wrong\") } query Selected($input: String!) { chosen: extensionValue(input: $input) }", "operationName": "Selected", "variables": { "input": "selected" }, "extensions": { "requestTag": "opaque" } }), + json!({ "query": "{ unknownField }" }), + json!({ "query": "query A { extensionValue(input: \"a\") } query B { extensionValue(input: \"b\") }" }), + json!({ "query": "mutation Forbidden { write }" }), + ] { + let left = client + .post(format!("{}/graphql", direct.origin)) + .json(&request) + .send() + .await + .unwrap(); + let right = client + .post(format!("{}/graphql", remote.origin)) + .json(&request) + .send() + .await + .unwrap(); + assert_eq!(left.status(), right.status()); + let left: Value = left.json().await.unwrap(); + let right: Value = right.json().await.unwrap(); + assert_eq!(left, right, "{request}"); + if request["operationName"] == "Selected" { + assert_eq!(left["data"], json!({"chosen": "selected"})); + assert_eq!(left["extensions"]["originProof"]["generation"], "g7"); + } + } + let mut ws_request = format!("{}/graphql/ws", remote.origin.replace("http:", "ws:")) + .into_client_request() + .unwrap(); + ws_request.headers_mut().insert( + "sec-websocket-protocol", + "graphql-transport-ws".parse().unwrap(), + ); + assert!(tokio_tungstenite::connect_async(ws_request).await.is_err()); +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, distributed::ReadModel)] +#[readmodel(table = "gateway_items", primary_key = ["id"])] +struct Item { + id: String, +} +#[tokio::test] +async fn query_only_schema_is_built_without_command_or_subscription_roots() { + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + let engine = Arc::new( + GraphqlEngine::builder(pool) + .roles(&["user"]) + .anonymous_role("user") + .subscriptions(false) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .build() + .unwrap(), + ); + let caps = GraphqlCapabilities { + queries: true, + ..Default::default() + }; + let schema = engine.sdl_for_role("user").unwrap(); + assert!(!schema.contains("type Mutation")); + assert!(!schema.contains("type Subscription")); + let embedded = EmbeddedGraphql::new(engine, None, caps).unwrap(); + let server = serve(mounted( + GraphqlBinding::Embedded(embedded), + GraphqlExecutor::Embedded, + caps, + vec![], + )) + .await; + let value: Value = reqwest::Client::new() + .post(format!("{}/graphql", server.origin)) + .json(&json!({"query":"{ __schema { mutationType { name } subscriptionType { name } } }"})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + value["data"]["__schema"], + json!({"mutationType": null, "subscriptionType": null}) + ); +} + +struct Writes(Arc); +#[Object] +impl Writes { + async fn write(&self) -> i32 { + self.0.fetch_add(1, Ordering::SeqCst); + 1 + } +} +struct Ticks; +#[Subscription] +impl Ticks { + async fn ticks(&self) -> impl futures_util::Stream { + futures_util::stream::iter([1]) + } +} +type Socket = WebSocketStream>; +async fn ws_json(socket: &mut Socket) -> Value { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + match socket.next().await.unwrap().unwrap() { + Message::Text(text) => return serde_json::from_str(&text).unwrap(), + Message::Ping(bytes) => { + socket.send(Message::Pong(bytes)).await.unwrap(); + } + other => panic!("unexpected {other:?}"), + } + } + }) + .await + .unwrap() +} +async fn connect(origin: &str) -> Socket { + let mut request = format!("{}/graphql/ws", origin.replace("http:", "ws:")) + .into_client_request() + .unwrap(); + request.headers_mut().insert( + "sec-websocket-protocol", + "graphql-transport-ws".parse().unwrap(), + ); + let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap(); + socket + .send(Message::Text( + json!({"type":"connection_init","payload":{}}) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!(ws_json(&mut socket).await, json!({"type":"connection_ack"})); + socket +} + +#[tokio::test] +async fn remote_ws_checks_selected_operations_and_preserves_ids() { + let writes = Arc::new(AtomicUsize::new(0)); + let schema = Schema::build(CustomQuery, Writes(writes.clone()), Ticks).finish(); + let origin = serve(Router::new().route( + "/graphql/ws", + get( + move |protocol: GraphQLProtocol, upgrade: WebSocketUpgrade| { + let schema = schema.clone(); + async move { + upgrade + .protocols(async_graphql::http::ALL_WEBSOCKET_PROTOCOLS) + .on_upgrade(move |socket| { + GraphQLWebSocket::new(socket, schema, protocol).serve() + }) + .into_response() + } + }, + ), + )) + .await; + let caps = GraphqlCapabilities { + queries: true, + live: true, + commands: false, + }; + let remote = serve(mounted( + GraphqlBinding::Remote(RemoteGraphql::default()), + GraphqlExecutor::Remote { + origin: origin.origin.clone(), + }, + caps, + vec![], + )) + .await; + let mut socket = connect(&remote.origin).await; + socket.send(Message::Text(json!({"id":"query-id","type":"subscribe","payload":{"query":"query Wrong { extensionValue(input: \"wrong\") } query Right($value:String!) { extensionValue(input:$value) }", "operationName":"Right", "variables":{"value":"right"}}}).to_string().into())).await.unwrap(); + let next = ws_json(&mut socket).await; + assert_eq!(next["id"], "query-id"); + assert_eq!(next["payload"]["data"], json!({"extensionValue":"right"})); + assert_eq!(ws_json(&mut socket).await["type"], "complete"); + socket + .send(Message::Text( + json!({"id":"mutation-id","type":"subscribe","payload":{"query":"mutation { write }"}}) + .to_string() + .into(), + )) + .await + .unwrap(); + let denied = ws_json(&mut socket).await; + assert_eq!(denied["id"], "mutation-id"); + assert_eq!( + denied["payload"]["errors"][0]["extensions"]["code"], + "OPERATION_NOT_MOUNTED" + ); + assert_eq!(ws_json(&mut socket).await["type"], "complete"); + socket + .send(Message::Text( + json!({"id":"live-id","type":"subscribe","payload":{"query":"subscription { ticks }"}}) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!( + ws_json(&mut socket).await["payload"]["data"], + json!({"ticks":1}) + ); + assert_eq!(ws_json(&mut socket).await["type"], "complete"); + socket.close(None).await.unwrap(); + let mut binary = connect(&remote.origin).await; + binary + .send(Message::Binary( + json!({"id":"binary","type":"subscribe","payload":{"query":"mutation { write }"}}) + .to_string() + .into_bytes() + .into(), + )) + .await + .unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), binary.next()) + .await + .unwrap(); + assert_eq!(writes.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn reloading_response_is_preserved_without_retry_or_receipt() { + let calls = Arc::new(AtomicUsize::new(0)); + let counted = calls.clone(); + let origin = serve(Router::new().fallback(move || { let calls = counted.clone(); async move { + calls.fetch_add(1, Ordering::SeqCst); + (StatusCode::SERVICE_UNAVAILABLE, axum::Json(json!({"errors":[{"message":"application generation is reloading", "extensions":{"code":"APPLICATION_RELOADING"}}]}))) + }})).await; + let caps = GraphqlCapabilities { + commands: true, + queries: true, + live: false, + }; + let remote = serve(mounted( + GraphqlBinding::Remote(RemoteGraphql::default()), + GraphqlExecutor::Remote { + origin: origin.origin.clone(), + }, + caps, + vec![], + )) + .await; + let response = reqwest::Client::new().post(format!("{}/graphql", remote.origin)).json(&json!({"query":"mutation Write($commandId: ID!) { write(commandId:$commandId) }", "variables":{"commandId":"exact-id"}})).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: Value = response.json().await.unwrap(); + assert_eq!( + body, + json!({"errors":[{"message":"application generation is reloading", "extensions":{"code":"APPLICATION_RELOADING"}}]}) + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn timed_out_mutation_executes_once_and_returns_no_receipt() { + let calls = Arc::new(AtomicUsize::new(0)); + let counted = calls.clone(); + let origin = serve(Router::new().fallback(move || { + let calls = counted.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + std::future::pending::<()>().await; + StatusCode::OK + } + })) + .await; + let caps = GraphqlCapabilities { + commands: true, + queries: true, + live: false, + }; + let mut options = NativeOptions::new("https://public.example.invalid"); + options.limits.response_header_timeout = Duration::from_millis(100); + let remote = serve(mounted_with_options( + GraphqlBinding::Remote(RemoteGraphql::default()), + GraphqlExecutor::Remote { + origin: origin.origin.clone(), + }, + caps, + vec![], + options, + )) + .await; + let response = reqwest::Client::new() + .post(format!("{}/graphql", remote.origin)) + .json(&json!({"query":"mutation { write }"})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert!(response.bytes().await.unwrap().is_empty()); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} diff --git a/tests/gateway_graphql_operation.rs b/tests/gateway_graphql_operation.rs new file mode 100644 index 000000000..be6c1a96c --- /dev/null +++ b/tests/gateway_graphql_operation.rs @@ -0,0 +1,65 @@ +#![cfg(feature = "gateway-graphql")] +use distributed::gateway::{graphql::*, GraphqlCapabilities}; +use serde_json::json; + +#[test] +fn selected_operation_and_status_follow_the_parser() { + let query = GraphqlCapabilities { + queries: true, + ..Default::default() + }; + let command = GraphqlCapabilities { + commands: true, + ..Default::default() + }; + let document = + "query Allowed { message(text: \"mutation { write }\") } mutation Blocked { write }"; + assert_eq!( + admit_operation(document, Some("Allowed"), query), + Ok(OperationKind::Query) + ); + assert_eq!( + admit_operation(document, Some("Blocked"), query), + Err(OperationError::NotMounted) + ); + assert_eq!( + admit_operation(document, None, query), + Err(OperationError::AmbiguousOperation) + ); + assert_eq!( + admit_operation(document, Some("Missing"), query), + Err(OperationError::UnknownOperation) + ); + let status = "query Recover { ...Recovery } fragment Recovery on Query { renamed: commandStatus(commandId: \"same-id\") { state } }"; + assert_eq!( + admit_operation(status, None, command), + Ok(OperationKind::CommandStatus) + ); + assert_eq!( + admit_operation(status, None, query), + Err(OperationError::NotMounted) + ); + assert_eq!( + admit_operation( + "{ commandStatus(commandId: \"id\") { state } items { id } }", + None, + command + ), + Err(OperationError::NotMounted) + ); + assert_eq!( + admit_request(&json!({"query":"{ ok }", "variables":[1]}), query), + Err(OperationError::InvalidRequest) + ); + assert_eq!( + admit_request( + &json!({"query":"{ ok }", "variables":null, "extensions":{"opaque":"value"}}), + query + ), + Ok(OperationKind::Query) + ); + assert_eq!( + operation_kind(&" ".repeat(MAX_DOCUMENT_BYTES + 1), None), + Err(OperationError::InvalidRequest) + ); +} diff --git a/tests/gateway_mounts.rs b/tests/gateway_mounts.rs new file mode 100644 index 000000000..c114747f5 --- /dev/null +++ b/tests/gateway_mounts.rs @@ -0,0 +1,97 @@ +#![cfg(feature = "gateway")] +use distributed::application::{ + compile_deployment_plan, Application, MountSelector, ProcessIntent, Runtime, +}; +use distributed::gateway::*; +use std::cell::Cell; + +fn ui(origin: &str) -> Gateway { + GatewayConfig { + bindings: vec![Binding::new( + "ui", + BindingKind::UiProxy { + origin: origin.into(), + }, + )], + routes: vec![Route::new("ui", RoutePath::prefix("/"), "ui")], + } + .build() + .unwrap() +} +#[test] +fn selected_capabilities_only() { + let gateway = ui("http://ui.internal:3000"); + let app = Application::new("site") + .build() + .unwrap() + .with_gateway("public", &gateway) + .unwrap(); + let selected = MountSelector::gateway("public").unwrap(); + let unused = MountSelector::gateway("unused").unwrap(); + let runtime = Runtime::default() + .mount_gateway(&app, selected.clone(), gateway) + .unwrap(); + let allocated = Cell::new(0); + let construct = |_: &Gateway| { + allocated.set(allocated.get() + 1); + Ok::<_, ()>("adapter") + }; + assert_eq!(runtime.bind_gateway(&unused, construct), Ok(None)); + assert_eq!(allocated.get(), 0); + assert_eq!( + runtime.bind_gateway(&selected, construct), + Ok(Some("adapter")) + ); + assert_eq!(allocated.get(), 1); + assert!(runtime.starts_gateway()); + assert!(!runtime.starts_graphql()); + assert!(!runtime.starts_outbox()); + assert!(!runtime.starts_projector_consumer()); + let plan = compile_deployment_plan( + "edge", + app.manifest(), + [ProcessIntent::new("ingress").unwrap().mounts([selected])], + ) + .unwrap(); + assert!(plan.processes[0] + .mounts + .iter() + .all(|mount| matches!(mount, MountSelector::Extension { .. }))); + assert!(!plan.processes[0].capabilities.iter().any(|requirement| { + let name = requirement.capability.as_str(); + name.contains("store") || name.contains("project") || name.contains("dispatch") + })); +} +#[test] +fn physical_binding_changes_do_not_rewrite_application_identity() { + let a = Application::new("site") + .build() + .unwrap() + .with_gateway("public", &ui("http://a.internal:3000")) + .unwrap(); + let b = Application::new("site") + .build() + .unwrap() + .with_gateway("public", &ui("http://b.internal:4000")) + .unwrap(); + assert_eq!(a.canonical_bytes().unwrap(), b.canonical_bytes().unwrap()); + assert!(!String::from_utf8(a.canonical_bytes().unwrap()) + .unwrap() + .contains("internal")); + assert!(Runtime::default() + .mount_gateway( + &a, + MountSelector::command("public").unwrap(), + ui("http://a.internal:3000") + ) + .is_err()); + let other = GatewayConfig { + bindings: vec![Binding::new("ui", BindingKind::Handler)], + routes: vec![Route::new("ui", RoutePath::prefix("/"), "ui")], + } + .build() + .unwrap(); + assert!(Runtime::default() + .mount_gateway(&a, MountSelector::gateway("public").unwrap(), other) + .is_err()); +} diff --git a/tests/gateway_routing.rs b/tests/gateway_routing.rs new file mode 100644 index 000000000..0b73d3f5d --- /dev/null +++ b/tests/gateway_routing.rs @@ -0,0 +1,317 @@ +#![cfg(feature = "gateway")] +mod gateway_support; +use distributed::gateway::*; +use gateway_support::*; + +#[test] +fn route_ownership_matrix() { + let cases = [ + ("GET", "/", "ui", true), + ("POST", "/graphql?operationName=Write", "api", true), + ("DELETE", "/graphql", "api", false), + ("HEAD", "/graphql", "api", false), + ("GET", "/graphql/missing", "api", true), + ("DELETE", "/graphql/ws", "ws", true), + ("GET", "/graphql/ws/child", "api", true), + ("GET", "/graphqlish", "ui", true), + ("POST", "/api/auth/callback", "auth", true), + ("GET", "/api/%61uth/callback", "auth", true), + ("GET", "/private/a.css", "protected", true), + ("GET", "/privacy", "ui", true), + ]; + let original = config(); + for reverse in [false, true] { + let mut config = original.clone(); + if reverse { + config.routes.reverse(); + config.bindings.reverse(); + } + let gateway = config.build().unwrap(); + for (method, path, owner, allowed) in cases { + let selected = gateway.select(method, path).unwrap().unwrap(); + assert_eq!( + (selected.route().id.as_str(), selected.method_allowed()), + (owner, allowed), + "{method} {path}" + ); + } + } +} + +#[test] +fn exact_owner_precedes_even_a_longer_prefix_and_prefixes_use_segment_boundaries() { + let mut c = config(); + c.routes.push(Route::new( + "nested", + RoutePath::prefix("/private/images/"), + "assets", + )); + c.routes.push(Route::new( + "exact", + RoutePath::exact("/private/images/logo"), + "auth", + )); + let g = c.build().unwrap(); + for (path, owner) in [ + ("/private/images", "nested"), + ("/private/images/", "nested"), + ("/private/images/logo", "exact"), + ("/private/images2", "protected"), + ] { + assert_eq!(g.select("GET", path).unwrap().unwrap().route().id, owner); + } +} + +#[test] +fn duplicate_and_normalized_alias_owners_fail_before_serving_even_with_disjoint_methods() { + for path in [ + RoutePath::prefix("/graphql"), + RoutePath::prefix("/graphql/"), + RoutePath::prefix("/%67raphql"), + ] { + let mut c = config(); + let mut duplicate = Route::new("duplicate", path, "ui"); + duplicate.methods = Methods::Only(vec!["DELETE".into()]); + c.routes.push(duplicate); + assert!(c.build().is_err()); + } + let mut c = config(); + c.routes + .push(Route::new("api", RoutePath::exact("/elsewhere"), "ui")); + assert!(c.build().is_err()); +} + +#[test] +fn ambiguous_paths_and_malformed_methods_fail_closed() { + let g = config().build().unwrap(); + for path in [ + "", + "graphql", + "https://host/graphql", + "//host/graphql", + "/a//b", + "/../graphql", + "/a/./b", + "/a/%2e%2e/graphql", + "/%2fgraphql", + "/%5Cgraphql", + "/%252e", + "/a\\b", + "/%00", + "/%7f", + "/%ff", + "/%c0%af", + "/%", + "/%2", + "/%ZZ", + "/a#b", + "/a%3fb", + "/a%23b", + ] { + assert!(g.select("GET", path).is_err(), "accepted {path:?}"); + } + for method in ["", "G ET", "GET\r\n", "GÉT"] { + assert!(g.select(method, "/").is_err()); + } + assert!(g + .select("GET", &format!("/{}", "a".repeat(MAX_PATH_BYTES))) + .is_err()); + assert_eq!( + g.select("GET", "/caf%C3%A9").unwrap().unwrap().route().id, + "ui" + ); +} + +#[test] +fn protected_assets_admit_in_order_before_execution() { + let g = config().build().unwrap(); + for authorized in [false, true] { + let adapter = Adapter::new(200); + let result = run(g.dispatch( + &adapter, + Request { + method: "GET", + target: "/private/app.js", + authorized, + }, + )); + assert_eq!(result.status, if authorized { 200 } else { 401 }); + let expected = if authorized { + vec!["admit:identity", "admit:policy", "execute:assets"] + } else { + vec!["admit:identity"] + }; + assert_eq!(*adapter.calls.borrow(), expected); + } +} + +#[test] +fn owned_errors_never_retry_or_reach_ui_fallback() { + let g = config().build().unwrap(); + for target in ["/graphql/missing", "/api/auth/missing"] { + for status in [404, 405, 500, 503, 504] { + let adapter = Adapter::new(status); + let response = run(g.dispatch( + &adapter, + Request { + method: "GET", + target, + authorized: true, + }, + )); + assert_eq!(response.status, status); + assert_eq!(response.evidence, "opaque-causal-envelope"); + assert_eq!(adapter.calls.borrow().len(), 1); + assert!(!adapter + .calls + .borrow() + .iter() + .any(|call| call == "execute:ui")); + } + } + let adapter = Adapter::new(200); + assert_eq!( + run(g.dispatch( + &adapter, + Request { + method: "DELETE", + target: "/graphql", + authorized: true + } + )) + .status, + 405 + ); + assert!(adapter.calls.borrow().is_empty()); +} + +#[test] +fn absent_mounts_expose_nothing_and_invalid_requests_do_not_execute() { + let g = GatewayConfig::default().build().unwrap(); + let adapter = Adapter::new(200); + for (target, status) in [("/", 404), ("/graphql", 404), ("/%2fprivate", 400)] { + assert_eq!( + run(g.dispatch( + &adapter, + Request { + method: "GET", + target, + authorized: false + } + )) + .status, + status + ); + } + assert!(adapter.calls.borrow().is_empty()); +} + +#[test] +fn declarations_are_bounded_and_references_are_checked() { + let mut c = config(); + c.routes[0].target = "missing".into(); + assert!(c.build().is_err()); + let mut c = config(); + c.routes[0].target = "identity".into(); + assert!(c.build().is_err()); + for admission in [ + vec!["missing".into()], + vec!["ui".into()], + vec!["identity".into(); MAX_ADMISSIONS + 1], + vec!["identity".into(); 2], + ] { + let mut c = config(); + c.routes[0].admission = admission; + assert!(c.build().is_err()); + } + let mut c = config(); + c.routes[0].methods = Methods::Only(vec![]); + assert!(c.build().is_err()); + let mut c = config(); + c.routes[0].methods = Methods::Only(vec!["GET".into(); 2]); + assert!(c.build().is_err()); + let mut c = config(); + c.bindings.push(c.bindings[0].clone()); + assert!(c.build().is_err()); + let mut c = config(); + c.routes = vec![c.routes[0].clone(); MAX_ROUTES + 1]; + assert!(c.build().is_err()); + let mut c = config(); + c.bindings = vec![c.bindings[0].clone(); MAX_BINDINGS + 1]; + assert!(c.build().is_err()); +} + +#[test] +fn configured_origins_cannot_hide_paths_credentials_or_non_http_targets() { + for origin in [ + "https://site.test/path", + "https://site.test/..", + "https://site.test//", + "https:site.test", + "//site.test", + "file:///etc/passwd", + "ftp://site.test", + "https://user:pass@site.test", + "https://@site.test", + "https://site.test?url=elsewhere", + "https://site.test#x", + "https://site.test\\evil", + "https://site.test\n", + ] { + let mut c = config(); + c.bindings[5].kind = BindingKind::UiProxy { + origin: origin.into(), + }; + assert!(c.build().is_err(), "accepted {origin:?}"); + } + for origin in [ + "http://localhost:5180", + "https://site.test/", + "http://[::1]:5180", + ] { + let mut c = config(); + c.bindings[5].kind = BindingKind::UiProxy { + origin: origin.into(), + }; + assert!(c.build().is_ok()); + } +} + +#[test] +fn serialized_configuration_still_requires_validation() { + let c = config(); + let serialized = serde_json::to_vec(&c).unwrap(); + let roundtrip: GatewayConfig = serde_json::from_slice(&serialized).unwrap(); + assert_eq!(roundtrip, c); + assert!(roundtrip.build().is_ok()); + assert!(serde_json::from_str::( + r#"{"routes":[],"bindings":[],"implicit_ui":true}"# + ) + .is_err()); +} + +#[test] +fn identifiers_and_exact_inventory_limits_are_enforced() { + let mut c = GatewayConfig { + bindings: vec![Binding::new("assets", BindingKind::Assets)], + routes: (0..MAX_ROUTES) + .map(|i| { + Route::new( + format!("route-{i}"), + RoutePath::exact(format!("/route-{i}")), + "assets", + ) + }) + .collect(), + }; + assert!(c.clone().build().is_ok()); + c.routes[0].id = "a".repeat(MAX_ID_BYTES + 1); + assert!(c.build().is_err()); + let c = GatewayConfig { + bindings: (0..MAX_BINDINGS) + .map(|i| Binding::new(format!("binding-{i}"), BindingKind::Handler)) + .collect(), + routes: vec![], + }; + assert!(c.build().is_ok()); +} diff --git a/tests/gateway_support/mod.rs b/tests/gateway_support/mod.rs new file mode 100644 index 000000000..c70450058 --- /dev/null +++ b/tests/gateway_support/mod.rs @@ -0,0 +1,138 @@ +#![allow(dead_code)] +use distributed::gateway::*; +use std::{ + cell::RefCell, + future::Future, + pin::pin, + rc::Rc, + task::{Context, Poll, Waker}, +}; + +pub fn run(future: F) -> F::Output { + let mut future = pin!(future); + let mut cx = Context::from_waker(Waker::noop()); + match future.as_mut().poll(&mut cx) { + Poll::Ready(value) => value, + Poll::Pending => panic!("contract fixture unexpectedly requires an I/O runtime"), + } +} + +pub struct Request { + pub method: &'static str, + pub target: &'static str, + pub authorized: bool, +} + +#[derive(Debug, PartialEq)] +pub struct Response { + pub status: u16, + pub body: String, + pub evidence: &'static str, +} + +pub struct Adapter { + pub calls: RefCell>, + pub provider: Rc, + pub status: u16, +} + +impl Adapter { + pub fn new(status: u16) -> Self { + Self { + calls: RefCell::new(Vec::new()), + provider: Rc::new("default".into()), + status, + } + } +} + +impl GatewayAdapter for Adapter { + type Request = Request; + type Context = Rc; + type Response = Response; + fn method<'a>(&self, request: &'a Request) -> &'a str { + request.method + } + fn target<'a>(&self, request: &'a Request) -> &'a str { + request.target + } + async fn admit( + &self, + selected: SelectedRoute<'_>, + request: &Request, + ) -> Result, Response> { + for policy in &selected.route().admission { + self.calls.borrow_mut().push(format!("admit:{policy}")); + if !request.authorized { + return Err(Response { + status: 401, + body: "denied".into(), + evidence: "", + }); + } + } + // Holding Rc across await proves that neither the contract nor dispatcher + // forces Worker adapters into Send futures or a native async runtime. + let context = self.provider.clone(); + std::future::ready(()).await; + Ok(context) + } + async fn execute( + &self, + selected: SelectedRoute<'_>, + context: Rc, + _request: Request, + ) -> Response { + self.calls + .borrow_mut() + .push(format!("execute:{}", selected.binding().id)); + std::future::ready(()).await; + Response { + status: self.status, + body: format!("{}:{}", selected.binding().id, context), + evidence: "opaque-causal-envelope", + } + } + fn reject(&self, rejection: Rejection<'_>) -> Response { + let status = match rejection { + Rejection::BadRequest => 400, + Rejection::NotFound => 404, + Rejection::MethodNotAllowed(_) => 405, + }; + Response { + status, + body: "rejected".into(), + evidence: "", + } + } +} + +pub fn config() -> GatewayConfig { + let mut api = Route::new("api", RoutePath::prefix("/graphql"), "api"); + api.methods = Methods::Only(vec!["POST".into(), "GET".into()]); + let auth = Route::new("auth", RoutePath::prefix("/api/auth"), "auth"); + let mut protected = Route::new("protected", RoutePath::prefix("/private"), "assets"); + protected.admission = vec!["identity".into(), "policy".into()]; + GatewayConfig { + bindings: vec![ + Binding::new("api", BindingKind::Handler), + Binding::new("auth", BindingKind::Handler), + Binding::new("assets", BindingKind::Assets), + Binding::new("identity", BindingKind::Admission), + Binding::new("policy", BindingKind::Admission), + Binding::new( + "ui", + BindingKind::UiProxy { + origin: "http://localhost:5180".into(), + }, + ), + ], + routes: vec![ + api, + auth, + protected, + Route::new("ws", RoutePath::exact("/graphql/ws"), "api"), + Route::new("ui", RoutePath::prefix("/"), "ui"), + ], + } +} diff --git a/tests/gateway_ui.rs b/tests/gateway_ui.rs new file mode 100644 index 000000000..ee85fea0e --- /dev/null +++ b/tests/gateway_ui.rs @@ -0,0 +1,592 @@ +#![cfg(feature = "gateway-native")] +use axum::{ + body::{Body, Bytes}, + extract::{ + ws::{Message, WebSocketUpgrade}, + State, + }, + http::{header, HeaderMap, HeaderValue, Request, StatusCode}, + response::{IntoResponse, Response}, + routing::{any, get}, + Router, +}; +use distributed::gateway::{native::*, *}; +use futures_util::{SinkExt, Stream, StreamExt}; +use std::{ + collections::BTreeMap, + convert::Infallible, + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{ + net::TcpListener, + sync::{mpsc, Notify}, + task::JoinHandle, +}; +use tower::ServiceExt; + +struct Server { + origin: String, + task: JoinHandle<()>, +} +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} +async fn serve(router: Router) -> Server { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + Server { origin, task } +} +fn auth() -> NativeAuth { + NativeAuth::new(|credentials| async move { + let identity = match credentials.authorization.as_deref() { + None => None, + Some("Bearer valid") => { + Some(Identity::verified("local", "alice", vec!["user".into()], u64::MAX).unwrap()) + } + _ => return Err(AuthError::Unauthorized), + }; + RequestContext::from_provider(identity, "test-v1", BackendCredential::None) + .map_err(|_| AuthError::Unavailable) + }) +} +fn gateway(origin: &str, upstream: &str, limits: ProxyLimits) -> NativeGateway { + let mut private = Route::new("private", RoutePath::prefix("/private"), "ui"); + private.admission = vec!["auth".into()]; + let config = GatewayConfig { + bindings: vec![ + Binding::new( + "ui", + BindingKind::UiProxy { + origin: upstream.into(), + }, + ), + Binding::new("auth", BindingKind::Admission), + ], + routes: vec![private, Route::new("ui", RoutePath::prefix("/"), "ui")], + } + .build() + .unwrap(); + let mut options = NativeOptions::new(origin); + options.limits = limits; + options.strip_headers = vec!["x-company-user".into(), "x-gateway-secret".into()]; + NativeGateway::new( + config, + options, + vec![ + ("ui".into(), NativeBinding::UiProxy { websocket: true }), + ( + "auth".into(), + NativeBinding::Admission(Admission::Authenticated), + ), + ], + auth(), + ) + .unwrap() +} + +struct TrackedStream { + receiver: mpsc::Receiver>, + dropped: Arc, +} +impl Stream for TrackedStream { + type Item = Result; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.receiver.poll_recv(cx) + } +} +impl Drop for TrackedStream { + fn drop(&mut self) { + self.dropped.fetch_add(1, Ordering::SeqCst); + } +} +#[derive(Clone)] +struct Upstream { + finish: Arc, + dropped: Arc, + ws_closed: Arc, +} +async fn stream(State(state): State) -> Response { + let (send, receiver) = mpsc::channel(1); + let dropped = state.dropped.clone(); + tokio::spawn(async move { + if send.send(Ok(Bytes::from_static(b"first"))).await.is_err() { + return; + } + tokio::select! { _ = state.finish.notified() => { let _ = send.send(Ok(Bytes::from_static(b"last"))).await; }, _ = send.closed() => {} } + }); + Response::new(Body::from_stream(TrackedStream { receiver, dropped })) +} +async fn echo(request: Request) -> Response { + let (parts, body) = request.into_parts(); + let body = axum::body::to_bytes(body, 1024).await.unwrap(); + let headers: BTreeMap<_, _> = parts + .headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap().to_string())) + .collect(); + let mut result = axum::Json(serde_json::json!({ "method": parts.method.to_string(), "target": parts.uri.to_string(), "body": String::from_utf8(body.to_vec()).unwrap(), "headers": headers })).into_response(); + result.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_static("one=1; Path=/; HttpOnly; SameSite=Lax"), + ); + result.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_static("two=2; Path=/; HttpOnly; SameSite=Lax"), + ); + result.headers_mut().insert( + header::CONNECTION, + HeaderValue::from_static("x-hop-response"), + ); + result + .headers_mut() + .insert("x-hop-response", HeaderValue::from_static("remove")); + result +} +async fn websocket(State(state): State, ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(move |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if matches!(message, Message::Close(_)) { + break; + } + if ws.send(message).await.is_err() { + break; + } + } + state.ws_closed.notify_one(); + }) +} + +#[tokio::test] +async fn stream_cookies_redirects_and_upgrades() { + let state = Upstream { + finish: Arc::new(Notify::new()), + dropped: Arc::new(AtomicUsize::new(0)), + ws_closed: Arc::new(Notify::new()), + }; + let upstream = serve( + Router::new() + .route("/stream", get(stream)) + .route("/ws", get(websocket)) + .route("/echo", any(echo)) + .route("/head", get(|| async { "head-body" })) + .with_state(state.clone()), + ) + .await; + let ingress_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let public = format!("http://{}", ingress_listener.local_addr().unwrap()); + let router = gateway(&public, &upstream.origin, ProxyLimits::default()).router(); + let ingress = Server { + origin: public.clone(), + task: tokio::spawn(async move { axum::serve(ingress_listener, router).await.unwrap() }), + }; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let response = client + .post(format!("{public}/echo?x=a%2Fb")) + .header("x-user-id", "admin") + .header("x-hasura-role", "admin") + .header("x-forwarded-host", "attacker.invalid") + .header("forwarded", "host=attacker.invalid") + .header("x-company-user", "admin") + .header("x-gateway-secret", "spoofed") + .header("connection", "x-hop-request") + .header("x-hop-request", "remove") + .header("origin", &public) + .body("input") + .send() + .await + .unwrap(); + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2 + ); + assert!(!response.headers().contains_key("x-hop-response")); + let value: serde_json::Value = response.json().await.unwrap(); + assert_eq!(value["method"], "POST"); + assert_eq!(value["target"], "/echo?x=a%2Fb"); + assert_eq!(value["body"], "input"); + assert_eq!( + value["headers"]["host"], + public.trim_start_matches("http://") + ); + assert_eq!( + value["headers"]["x-forwarded-host"], + public.trim_start_matches("http://") + ); + assert_eq!(value["headers"]["origin"], public); + for name in [ + "x-user-id", + "x-hasura-role", + "forwarded", + "x-company-user", + "x-gateway-secret", + "x-hop-request", + ] { + assert!(value["headers"].get(name).is_none(), "{name}"); + } + let head = client.head(format!("{public}/head")).send().await.unwrap(); + assert_eq!(head.headers()[header::CONTENT_LENGTH], "9"); + assert!(head.bytes().await.unwrap().is_empty()); + let mut response = client.get(format!("{public}/stream")).send().await.unwrap(); + let first = tokio::time::timeout(Duration::from_secs(2), response.chunk()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(first, "first"); // Completion is impossible until this signal. + state.finish.notify_one(); + assert_eq!(response.bytes().await.unwrap(), "last"); + let mut response = client.get(format!("{public}/stream")).send().await.unwrap(); + assert_eq!(response.chunk().await.unwrap().unwrap(), "first"); + drop(response); + tokio::time::timeout(Duration::from_secs(2), async { + while state.dropped.load(Ordering::SeqCst) < 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let (mut ws, _) = + tokio_tungstenite::connect_async(format!("{}/ws", ingress.origin.replace("http:", "ws:"))) + .await + .unwrap(); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + "hello".into(), + )) + .await + .unwrap(); + assert_eq!( + ws.next().await.unwrap().unwrap().into_text().unwrap(), + "hello" + ); + ws.close(None).await.unwrap(); + drop(ws); + tokio::time::timeout(Duration::from_secs(2), state.ws_closed.notified()) + .await + .unwrap(); +} + +#[tokio::test] +async fn redirect_ownership_assets_and_limits() { + let upstream = serve(Router::new().fallback(|headers: HeaderMap| async move { + let mut response = StatusCode::FOUND.into_response(); + // App emits a private absolute URL; it must be rewritten without following. + response + .headers_mut() + .insert(header::LOCATION, headers["x-redirect-to"].clone()); + response + })) + .await; + let ingress = serve( + gateway( + "https://site.test", + &upstream.origin, + ProxyLimits { + request_body_bytes: 4, + ..Default::default() + }, + ) + .router(), + ) + .await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let response = client + .get(&ingress.origin) + .header( + "x-redirect-to", + format!("{}/next?q=1#anchor", upstream.origin), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.headers()[header::LOCATION], + "https://site.test/next?q=1#anchor" + ); + let response = client + .get(&ingress.origin) + .header("x-redirect-to", "https://idp.example.invalid/authorize") + .send() + .await + .unwrap(); + assert_eq!( + response.headers()[header::LOCATION], + "https://idp.example.invalid/authorize" + ); + let response = client + .get(&ingress.origin) + .header( + "x-redirect-to", + format!("//{}/next", upstream.origin.trim_start_matches("http://")), + ) + .send() + .await + .unwrap(); + assert_eq!( + response.headers()[header::LOCATION], + "https://site.test/next" + ); + assert_eq!( + client + .post(&ingress.origin) + .body("large") + .send() + .await + .unwrap() + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!( + client + .get(format!("{}/private/page", ingress.origin)) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + client + .get(&ingress.origin) + .header("authorization", "Bearer forged") + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + + let assets = StaticAssets::new( + [( + "/private/file".into(), + Asset { + bytes: Bytes::from_static(b"secret"), + content_type: HeaderValue::from_static("text/plain"), + }, + )], + 6, + ) + .unwrap(); + let mut route = Route::new("asset", RoutePath::prefix("/private"), "asset"); + route.admission.push("auth".into()); + let mut api = Route::new("api", RoutePath::prefix("/api"), "api"); + api.methods = Methods::Only(vec!["POST".into()]); + let config = GatewayConfig { + bindings: vec![ + Binding::new("asset", BindingKind::Assets), + Binding::new("auth", BindingKind::Admission), + Binding::new("api", BindingKind::Handler), + ], + routes: vec![route, api, { + let mut custom = Route::new("custom", RoutePath::exact("/custom"), "api"); + custom.admission.push("auth".into()); + custom + }], + } + .build() + .unwrap(); + let router = NativeGateway::new( + config, + NativeOptions::new("https://site.test"), + vec![ + ("asset".into(), NativeBinding::Assets(assets)), + ( + "auth".into(), + NativeBinding::Admission(Admission::Authenticated), + ), + ( + "api".into(), + NativeBinding::Handler( + Router::new().route("/custom", get(|axum::Extension(context): axum::Extension| async move { context.identity().unwrap().subject().to_string() })).fallback(|| async { StatusCode::SERVICE_UNAVAILABLE }), + ), + ), + ], + auth(), + ) + .unwrap() + .router(); + for (method, path, token, expected) in [ + ("GET", "/private/file", None, 401), + ("GET", "/custom", None, 401), + ("GET", "/custom", Some("Bearer valid"), 200), + ("GET", "/private/file", Some("Bearer valid"), 200), + ("HEAD", "/private/file", Some("Bearer valid"), 200), + ("GET", "/private/%2e%2e/file", Some("Bearer valid"), 400), + ("GET", "/api/missing", None, 405), + ("POST", "/api/missing", None, 503), + ] { + let mut request = Request::builder().method(method).uri(path); + if let Some(token) = token { + request = request.header("authorization", token); + } + let response = router + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + if path == "/custom" && expected == 200 { + assert_eq!( + axum::body::to_bytes(response.into_body(), 100) + .await + .unwrap(), + "alice" + ); + continue; + } + if method == "HEAD" { + assert_eq!(response.headers()[header::CONTENT_LENGTH], "6"); + assert!(axum::body::to_bytes(response.into_body(), 100) + .await + .unwrap() + .is_empty()); + } + } +} + +#[test] +fn native_bindings_reject_loops_and_duplicate_resources() { + let config = || { + GatewayConfig { + bindings: vec![Binding::new( + "ui", + BindingKind::UiProxy { + origin: "https://site.test".into(), + }, + )], + routes: vec![Route::new("ui", RoutePath::prefix("/"), "ui")], + } + .build() + .unwrap() + }; + assert!(NativeGateway::new( + config(), + NativeOptions::new("https://site.test"), + [("ui".into(), NativeBinding::UiProxy { websocket: false })], + NativeAuth::anonymous() + ) + .is_err()); + assert!(NativeGateway::new( + config(), + NativeOptions::new("https://other.test"), + [ + ("ui".into(), NativeBinding::UiProxy { websocket: false }), + ("ui".into(), NativeBinding::UiProxy { websocket: false }) + ], + NativeAuth::anonymous() + ) + .is_err()); + assert!(StaticAssets::new( + [( + "/../secret".into(), + Asset { + bytes: Bytes::new(), + content_type: HeaderValue::from_static("text/plain") + } + )], + 100 + ) + .is_err()); +} + +#[tokio::test] +async fn capacity_cancellation_timeout_and_alias_loop_are_bounded() { + let state = Upstream { + finish: Arc::new(Notify::new()), + dropped: Arc::new(AtomicUsize::new(0)), + ws_closed: Arc::new(Notify::new()), + }; + let upstream = serve( + Router::new() + .route("/stream", get(stream)) + .route( + "/slow", + get(|| async { + std::future::pending::<()>().await; + "unreachable" + }), + ) + .with_state(state.clone()), + ) + .await; + let ingress = serve( + gateway( + "https://site.test", + &upstream.origin, + ProxyLimits { + concurrent_requests: 1, + response_header_timeout: Duration::from_millis(100), + ..Default::default() + }, + ) + .router(), + ) + .await; + let client = reqwest::Client::new(); + let mut first = client + .get(format!("{}/stream", ingress.origin)) + .send() + .await + .unwrap(); + assert_eq!(first.chunk().await.unwrap().unwrap(), "first"); + assert_eq!( + client + .get(format!("{}/stream", ingress.origin)) + .send() + .await + .unwrap() + .status(), + StatusCode::SERVICE_UNAVAILABLE + ); + drop(first); + tokio::time::timeout(Duration::from_secs(2), async { + while state.dropped.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + client + .get(format!("{}/slow", ingress.origin)) + .send() + .await + .unwrap() + .status(), + StatusCode::GATEWAY_TIMEOUT + ); + + // Two names can resolve to one listener. A bounded hop chain catches this + // even when origin string comparison at construction cannot detect it. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let direct = format!("http://{}", listener.local_addr().unwrap()); + let router = gateway("https://alias.test", &direct, ProxyLimits::default()).router(); + let looped = Server { + origin: direct, + task: tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }), + }; + let response = tokio::time::timeout(Duration::from_secs(2), client.get(&looped.origin).send()) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::LOOP_DETECTED); +} diff --git a/tests/graphql_causal_transport/main.rs b/tests/graphql_causal_transport/main.rs index 44daccf31..048c3db29 100644 --- a/tests/graphql_causal_transport/main.rs +++ b/tests/graphql_causal_transport/main.rs @@ -4,12 +4,11 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use base64::Engine as _; -use distributed::graphql::{ - typed_command, GraphqlEngine, IdentityConfig, OidcConfig, PreparedCommand, Succeeded, -}; +use distributed::command::{typed_command, PreparedCommand, Succeeded}; +use distributed::graphql::{GraphqlEngine, IdentityConfig, OidcConfig}; use distributed::microsvc::{CausalCommandContext, HandlerError, Routes, Service}; use distributed::{ - Aggregate, AggregateRepository, Entity, EventRecord, GraphqlInput, GraphqlOutput, + Aggregate, AggregateRepository, CommandInput, CommandOutput, Entity, EventRecord, InMemoryRepository, }; use futures_util::{SinkExt, StreamExt}; @@ -76,12 +75,12 @@ impl Aggregate for TransportAggregate { } } -#[derive(Deserialize, GraphqlInput)] +#[derive(Deserialize, CommandInput)] struct TransportCommandInput { id: String, } -#[derive(Serialize, GraphqlOutput)] +#[derive(Serialize, CommandOutput)] struct TransportCommandOutput { id: String, } @@ -186,20 +185,108 @@ struct TestServer { http_url: String, ws_url: String, task: JoinHandle<()>, + upstream: Option>, } impl Drop for TestServer { fn drop(&mut self) { self.task.abort(); + if let Some(upstream) = &self.upstream { + upstream.abort(); + } } } +enum GatewayMode { + Direct, + #[cfg(feature = "gateway-graphql-native")] + Embedded, + #[cfg(feature = "gateway-graphql-native")] + Remote, +} + async fn spawn_server(service: Arc) -> TestServer { + spawn_server_mode(service, GatewayMode::Direct).await +} + +async fn spawn_server_mode(service: Arc, mode: GatewayMode) -> TestServer { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind test server"); let address = listener.local_addr().expect("test server address"); - let app = distributed::microsvc::router(service); + let mut upstream = None; + let app = match mode { + GatewayMode::Direct => distributed::microsvc::router(service), + #[cfg(feature = "gateway-graphql-native")] + mode => { + use distributed::gateway::{native::*, *}; + let engine = service.graphql_engine().unwrap(); + let provider = Arc::new(distributed::graphql::identity::OidcGatewayProvider::new( + engine.identity_config().oidc.clone().unwrap(), + "causal-fixture-v1", + )); + let auth = NativeAuth::new(move |credentials| { + let provider = provider.clone(); + async move { provider.authenticate(&credentials).await } + }); + let caps = GraphqlCapabilities { + commands: true, + queries: true, + live: true, + }; + let (executor, binding) = match mode { + GatewayMode::Embedded => ( + GraphqlExecutor::Embedded, + GraphqlBinding::Embedded( + EmbeddedGraphql::new( + engine, + Some(Arc::new( + distributed::command_dispatch::LocalCommandHost::new(service), + )), + caps, + ) + .unwrap(), + ), + ), + GatewayMode::Remote => { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + upstream = Some(tokio::spawn(async move { + axum::serve(listener, distributed::microsvc::router(service)) + .await + .unwrap() + })); + ( + GraphqlExecutor::Remote { origin }, + GraphqlBinding::Remote(RemoteGraphql::default()), + ) + } + GatewayMode::Direct => unreachable!(), + }; + let gateway = GatewayConfig { + bindings: vec![Binding::new( + "graphql", + BindingKind::Graphql { + executor, + capabilities: caps, + delivery: DeliveryCapabilities::default(), + schema_extensions: vec![], + }, + )], + routes: vec![Route::new("api", RoutePath::prefix("/graphql"), "graphql")], + } + .build() + .unwrap(); + NativeGateway::new( + gateway, + NativeOptions::new(format!("http://{address}")), + [("graphql".into(), NativeBinding::Graphql(binding))], + auth, + ) + .unwrap() + .router() + } + }; let task = tokio::spawn(async move { axum::serve(listener, app) .await @@ -209,6 +296,7 @@ async fn spawn_server(service: Arc) -> TestServer { http_url: format!("http://{address}/graphql"), ws_url: format!("ws://{address}/graphql/ws"), task, + upstream, } } @@ -373,6 +461,22 @@ async fn assert_http_ws_status_pair( #[tokio::test] async fn causal_receipt_status_replay_and_nonenumeration_match_http_and_ws() { + exercise_causal_transport(GatewayMode::Direct).await; +} + +#[cfg(feature = "gateway-graphql-native")] +#[tokio::test] +async fn gateway_embedded_causal_receipt_status_parity() { + exercise_causal_transport(GatewayMode::Embedded).await; +} + +#[cfg(feature = "gateway-graphql-native")] +#[tokio::test] +async fn gateway_remote_causal_receipt_status_parity() { + exercise_causal_transport(GatewayMode::Remote).await; +} + +async fn exercise_causal_transport(mode: GatewayMode) { let keys = TestKeys::new(); let mut oidc = OidcConfig::new(ISSUER, AUDIENCE) .with_static_jwks(keys.jwks.clone()) @@ -396,7 +500,7 @@ async fn causal_receipt_status_replay_and_nonenumeration_match_http_and_ws() { .try_with_graphql(engine) .expect("causal transport GraphQL attachment"), ); - let server = spawn_server(service).await; + let server = spawn_server_mode(service, mode).await; let writer_a = keys.token("subject-a", "writer"); let writer_b = keys.token("subject-b", "writer"); diff --git a/tests/graphql_commands/main.rs b/tests/graphql_commands/main.rs index aa478b9db..fac88121a 100644 --- a/tests/graphql_commands/main.rs +++ b/tests/graphql_commands/main.rs @@ -2,14 +2,14 @@ #![cfg(feature = "graphql")] -use distributed::graphql::{GraphqlTypeDef, GraphqlTypeField}; +use distributed::command::{CommandTypeDef, CommandTypeField}; #[test] fn graphql_type_def_mapping_golden() { - let input = GraphqlTypeDef::new( + let input = CommandTypeDef::new( "CreateItemInput", vec![ - GraphqlTypeField { + CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -17,7 +17,7 @@ fn graphql_type_def_mapping_golden() { item_nullable: false, nested: None, }, - GraphqlTypeField { + CommandTypeField { name: "tags".into(), type_name: "String".into(), nullable: true, @@ -33,7 +33,7 @@ fn graphql_type_def_mapping_golden() { assert!(input.fields[1].list); } -#[derive(distributed::GraphqlInput)] +#[derive(distributed::CommandInput)] #[allow(dead_code)] struct DerivedInput { id: String, @@ -41,14 +41,14 @@ struct DerivedInput { tags: Option>, } -#[derive(distributed::GraphqlOutput)] +#[derive(distributed::CommandOutput)] #[allow(dead_code)] struct DerivedOutput { ok: bool, id: String, } -#[derive(distributed::GraphqlInput, serde::Deserialize)] +#[derive(distributed::CommandInput, serde::Deserialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] struct ScalarMatrixInput { @@ -61,7 +61,7 @@ struct ScalarMatrixInput { optional_nullable_items: Option>>, } -#[derive(distributed::GraphqlOutput, serde::Serialize)] +#[derive(distributed::CommandOutput, serde::Serialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] struct ScalarMatrixOutput { @@ -74,7 +74,7 @@ struct ScalarMatrixOutput { optional_nullable_items: Option>>, } -#[derive(distributed::GraphqlInput)] +#[derive(distributed::CommandInput)] #[serde(rename_all(deserialize = "camelCase", serialize = "SCREAMING_SNAKE_CASE"))] #[allow(dead_code)] struct DirectionalInputNames { @@ -83,7 +83,7 @@ struct DirectionalInputNames { custom_id: String, } -#[derive(distributed::GraphqlOutput)] +#[derive(distributed::CommandOutput)] #[serde(rename_all(deserialize = "camelCase", serialize = "SCREAMING_SNAKE_CASE"))] #[allow(dead_code)] struct DirectionalOutputNames { @@ -94,9 +94,9 @@ struct DirectionalOutputNames { #[test] fn derive_mapping_golden() { - use distributed::graphql::{GraphqlInputType, GraphqlOutputType}; + use distributed::command::{CommandInputType, CommandOutputType}; - let input = DerivedInput::graphql_type(); + let input = DerivedInput::command_type(); assert_eq!(input.name, "DerivedInput"); assert_eq!(input.fields.len(), 3); assert_eq!(input.fields[0].type_name, "String"); @@ -105,7 +105,7 @@ fn derive_mapping_golden() { assert!(input.fields[2].list); assert!(input.fields[2].nullable); - let output = DerivedOutput::graphql_type(); + let output = DerivedOutput::command_type(); assert_eq!(output.name, "DerivedOutput"); assert_eq!(output.fields[0].type_name, "Boolean"); assert_eq!(output.fields[1].type_name, "String"); @@ -113,7 +113,7 @@ fn derive_mapping_golden() { #[test] fn derive_preserves_outer_and_item_nullability_and_serde_names() { - use distributed::graphql::{GraphqlInputType, GraphqlOutputType}; + use distributed::command::{CommandInputType, CommandOutputType}; let expected = [ ("wireRequired", false, false, false), @@ -124,8 +124,8 @@ fn derive_preserves_outer_and_item_nullability_and_serde_names() { ("optionalNullableItems", true, true, true), ]; for definition in [ - ScalarMatrixInput::graphql_type(), - ScalarMatrixOutput::graphql_type(), + ScalarMatrixInput::command_type(), + ScalarMatrixOutput::command_type(), ] { for (name, nullable, list, item_nullable) in expected { let field = definition @@ -140,14 +140,14 @@ fn derive_preserves_outer_and_item_nullability_and_serde_names() { } } - let input_names: Vec<_> = DirectionalInputNames::graphql_type() + let input_names: Vec<_> = DirectionalInputNames::command_type() .fields .into_iter() .map(|field| field.name) .collect(); assert_eq!(input_names, ["regularField", "inputID"]); - let output_names: Vec<_> = DirectionalOutputNames::graphql_type() + let output_names: Vec<_> = DirectionalOutputNames::command_type() .fields .into_iter() .map(|field| field.name) diff --git a/tests/graphql_engine/main.rs b/tests/graphql_engine/main.rs index 1ab1808cf..25874e7da 100644 --- a/tests/graphql_engine/main.rs +++ b/tests/graphql_engine/main.rs @@ -44,6 +44,7 @@ fn bidirectional_parent_schema() -> TableSchema { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "Child".into(), @@ -72,6 +73,7 @@ fn bidirectional_child_schema() -> TableSchema { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "parent".into(), kind: RelationshipKind::BelongsTo, target_model: "Parent".into(), diff --git a/tests/graphql_harden/authz.rs b/tests/graphql_harden/authz.rs index 9f9348bdd..0e442d7d7 100644 --- a/tests/graphql_harden/authz.rs +++ b/tests/graphql_harden/authz.rs @@ -3,9 +3,7 @@ use async_graphql::Request; use distributed::graphql::{claim, col, read, GraphqlEngine, ModelPermissions}; -use distributed::{ - ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind, -}; +use distributed::{ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind}; use super::common::{ engine_all_columns, error_messages, exec_json, seed_orders, session, ChildView, OrderView, @@ -189,6 +187,7 @@ async fn a5_nested_relationship_column_allowlist_denies() { let mut parent = ParentView::schema().clone(); parent.relationships = vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), @@ -244,6 +243,7 @@ async fn a5_nested_relationship_column_allowlist_denies() { fn parent_child_engine(pool: sqlx::SqlitePool) -> GraphqlEngine { let mut parent = ParentView::schema().clone(); parent.relationships = vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), diff --git a/tests/graphql_harden/dos.rs b/tests/graphql_harden/dos.rs index 09ad199d5..a49babf4e 100644 --- a/tests/graphql_harden/dos.rs +++ b/tests/graphql_harden/dos.rs @@ -249,9 +249,7 @@ async fn d7_concurrent_with_timeout_bound_terminates() { async fn d8_nested_has_many_exceeds_complexity_budget() { use distributed::graphql::{read, GraphqlEngine}; use distributed::ReadModel; - use distributed::{ - ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind, - }; + use distributed::{ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] @@ -300,6 +298,7 @@ async fn d8_nested_has_many_exceeds_complexity_budget() { let mut parent = ParentView::schema().clone(); parent.relationships = vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), @@ -309,6 +308,7 @@ async fn d8_nested_has_many_exceeds_complexity_budget() { }]; let mut child = ChildView::schema().clone(); child.relationships = vec![RelationshipDef { + references: None, field_name: "grandchildren".into(), kind: RelationshipKind::HasMany, target_model: "GrandView".into(), @@ -366,9 +366,7 @@ async fn d8_nested_has_many_exceeds_complexity_budget() { async fn d8_shallow_nested_has_many_within_budget() { use distributed::graphql::{read, GraphqlEngine}; use distributed::ReadModel; - use distributed::{ - ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind, - }; + use distributed::{ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] @@ -405,6 +403,7 @@ async fn d8_shallow_nested_has_many_within_budget() { let mut parent = ParentView::schema().clone(); parent.relationships = vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), @@ -443,9 +442,7 @@ async fn d8_shallow_nested_has_many_within_budget() { async fn d8_low_max_complexity_rejects_single_nest() { use distributed::graphql::{read, GraphqlEngine}; use distributed::ReadModel; - use distributed::{ - ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind, - }; + use distributed::{ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] @@ -482,6 +479,7 @@ async fn d8_low_max_complexity_rejects_single_nest() { let mut parent = ParentView::schema().clone(); parent.relationships = vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), diff --git a/tests/graphql_harden/residual.rs b/tests/graphql_harden/residual.rs index fb8974569..dcccce36c 100644 --- a/tests/graphql_harden/residual.rs +++ b/tests/graphql_harden/residual.rs @@ -2,9 +2,7 @@ use async_graphql::Request; use distributed::graphql::{claim, col, read, GraphqlEngine, ModelPermissions}; -use distributed::{ - ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind, -}; +use distributed::{ReadModelCatalog, RelationalReadModel, RelationshipDef, RelationshipKind}; use super::common::{ assert_no_sql_leak, engine_all_columns, error_messages, extension_code, seed_orders, session, @@ -34,6 +32,7 @@ async fn a8_nested_has_many_without_child_grant_is_unknown_field() { let mut parent = ParentView::schema().clone(); parent.relationships = vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), @@ -104,6 +103,7 @@ async fn a12_rel_where_without_target_grant_is_unknown_field() { let mut parent = ParentView::schema().clone(); parent.relationships = vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), diff --git a/tests/graphql_harden/transport.rs b/tests/graphql_harden/transport.rs index 1fd758d6d..341215042 100644 --- a/tests/graphql_harden/transport.rs +++ b/tests/graphql_harden/transport.rs @@ -3,10 +3,8 @@ use std::sync::Arc; use async_graphql::Request; -use distributed::graphql::{ - graphiql_enabled_from_env_vars, read, typed_command, GraphqlEngine, ModelPermissions, - PreparedCommand, Succeeded, -}; +use distributed::command::{typed_command, PreparedCommand, Succeeded}; +use distributed::graphql::{graphiql_enabled_from_env_vars, read, GraphqlEngine, ModelPermissions}; use distributed::microsvc::{router, CausalCommandContext, HandlerError, Routes, Service, Session}; use distributed::{ Aggregate, AggregateRepository, Entity, EventRecord, InMemoryRepository, ReadModel, @@ -42,13 +40,13 @@ impl Aggregate for T4Aggregate { } } -#[derive(Deserialize, distributed::GraphqlInput)] +#[derive(Deserialize, distributed::CommandInput)] struct T4CommandInput { id: String, name: String, } -#[derive(Serialize, distributed::GraphqlOutput)] +#[derive(Serialize, distributed::CommandOutput)] struct T4CommandOutput { id: String, name: String, diff --git a/tests/graphql_identity/main.rs b/tests/graphql_identity/main.rs index c13c6c87b..1e2906f30 100644 --- a/tests/graphql_identity/main.rs +++ b/tests/graphql_identity/main.rs @@ -913,3 +913,67 @@ fn gateway_secret_wrong_is_401() { AuthError::Unauthorized ); } + +#[cfg(feature = "gateway")] +#[tokio::test] +async fn gateway_oidc_context_keeps_backend_authority() { + use distributed::gateway::{Admission, AuthProvider, BackendCredential, Credentials}; + use distributed::graphql::identity::OidcGatewayProvider; + let keys = mint_keys(); + let claims = json!({ "iss": "http://localhost:8080", "aud": "graphql-api", "sub": "user-a-001", "exp": now() + 3600, "iat": now(), "groups": ["customer", "unmapped"] }); + let token = sign_claims(&keys, claims.clone()); + let provider = OidcGatewayProvider::new(oidc_cfg(&keys), "oidc-v1"); + let credentials = Credentials { + authorization: Some(format!("Bearer {token}")), + cookie: Some("forged-session".into()), + }; + let context = provider.authenticate(&credentials).await.unwrap(); + assert_eq!(Admission::Authenticated.check(&context, now()), Ok(())); + assert_eq!(context.identity().unwrap().subject(), "user-a-001"); + assert_eq!(context.identity().unwrap().roles(), &["customer"]); + let BackendCredential::Bearer(forwarded) = context.backend_credential() else { + panic!("explicit bearer") + }; + assert_eq!(forwarded, &token); + + // A valid gateway assertion does not suppress backend credential checks. + let mut backend = oidc_cfg(&keys); + backend.audience = "another-api".into(); + let app = graphql_router(engine_with_identity(IdentityConfig::oidc_bearer(backend)).await); + let response = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/graphql") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {forwarded}")) + .header("x-user-id", "admin") + .body(axum::body::Body::from(r#"{"query":"{ id_items { id } }"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::UNAUTHORIZED); + + for replacement in [ + "invalid".to_string(), + sign_claims(&keys, { + let mut expired = claims; + expired["exp"] = json!(now() - 3600); + expired + }), + ] { + assert!(provider + .authenticate(&Credentials { + authorization: Some(format!("Bearer {replacement}")), + cookie: None + }) + .await + .is_err()); + } + let anon = provider + .authenticate(&Credentials::default()) + .await + .unwrap(); + assert!(anon.identity().is_none()); +} diff --git a/tests/graphql_query_protocol/load.rs b/tests/graphql_query_protocol/load.rs new file mode 100644 index 000000000..721c5e186 --- /dev/null +++ b/tests/graphql_query_protocol/load.rs @@ -0,0 +1,212 @@ +use super::*; + +#[tokio::test] +#[ignore = "runs actual 100-query/100-live native and workerd load matrix; requires pinned Worker fixture"] +async fn separate_origin_and_client_savings() { + use axum::Router; + use distributed::graphql::{delivery::GatewayVersionStore, IdentityConfig, OidcConfig}; + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use rsa::{pkcs1::EncodeRsaPrivateKey, traits::PublicKeyParts, RsaPrivateKey, RsaPublicKey}; + let private = RsaPrivateKey::new(&mut rand::thread_rng(), 2048).unwrap(); + let public = RsaPublicKey::from(&private); + let encoding = EncodingKey::from_rsa_pem( + private + .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF) + .unwrap() + .as_bytes(), + ) + .unwrap(); + let jwks=json!({"keys":[{"kty":"RSA","kid":"live-test","alg":"RS256","use":"sig", + "n":base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()),"e":base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.e().to_bytes_be())}]}).to_string(); + let token = |subject: &str| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("live-test".into()); + encode(&header,&json!({"iss":"https://live-fixture.invalid","aud":"live-fixture","sub":subject,"iat":now-1,"nbf":now-1,"exp":now+3600,"roles":["user"]}),&encoding).unwrap() + }; + + let fixture = protocol_fixture_with_retention(5).await; + let versions = GatewayVersionStore::install( + &distributed::graphql::GraphqlPool::from(fixture.repository.pool().clone()), + "load-fixture", + ["causal_query_views".into()], + ) + .await + .unwrap(); + let oidc = OidcConfig::new("https://live-fixture.invalid", "live-fixture") + .with_static_jwks(jwks) + .engine_roles(&["user"]); + let engine = Arc::new( + GraphqlEngine::builder(&fixture.repository) + .service_id(SERVICE_ID) + .protocol_token_key(TEST_PROTOCOL_TOKEN_KEY) + .roles(&["user"]) + .identity(IdentityConfig::oidc_bearer(oidc)) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .client_projectors([projector()]) + .change_stream(fixture.repository.read_model_changes()) + .gateway_versions(versions.clone()) + .build() + .unwrap(), + ); + + use axum::{ + body::Body, + extract::Request as HttpRequest, + middleware::{self, Next}, + routing::{get, post}, + }; + use distributed::gateway::{delivery::*, native::*, *}; + use std::collections::BTreeMap; + let gate = Arc::new(tokio::sync::Semaphore::new(10000)); + let gate_layer = gate.clone(); + let gql = distributed::graphql::graphql_router_composed(engine.clone(), None, None).layer( + middleware::from_fn(move |request: HttpRequest, next: Next| { + let gate = gate_layer.clone(); + async move { + if request.method() != axum::http::Method::POST { + return next.run(request).await; + } + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, 1024 * 1024).await.unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + if value["extensions"]["gatewayDelivery"]["action"] != "validate" { + gate.acquire().await.unwrap().forget(); + } + next.run(HttpRequest::from_parts(parts, Body::from(bytes))) + .await + } + }), + ); + let observed = engine.clone(); + let counters = versions.clone(); + let release = gate.clone(); + let repository = fixture.repository.clone(); + let bus = fixture.bus.clone(); + let control = Router::new() + .route("/__metrics", get(move || {let engine=observed.clone();let versions=counters.clone();async move {let m=versions.metrics();axum::Json(json!({"producers":engine.live_subscriber_count(),"validations":m.validations,"resultExecutions":m.result_executions}))}})) + .route("/__block", post(move || {let gate=gate.clone();async move {gate.forget_permits(gate.available_permits());"blocked"}})) + .route("/__release", post(move || {let gate=release.clone();async move {gate.add_permits(10000);"released"}})) + .route("/__next/{position}",post(move |axum::extract::Path(position):axum::extract::Path| {let repository=repository.clone();let bus=bus.clone();async move {project_item(&repository,&bus,position,&format!("load-{position}-{}","x".repeat(4096))).await;"committed"}})); + struct Server { + origin: String, + task: tokio::task::JoinHandle<()>, + } + impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } + } + async fn serve(router: Router) -> Server { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + Server { + origin, + task: tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }), + } + } + let origin = serve(gql.merge(control)).await; + // Reserve an ephemeral metering proxy port for the owned Node runner. + let meter = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let meter_port = meter.local_addr().unwrap().port(); + drop(meter); + let mut servers = Vec::new(); + let mut native = BTreeMap::new(); + for mode in ["none", "flights", "snapshots", "live"] { + let caps = GraphqlCapabilities { + queries: true, + commands: true, + live: true, + }; + let delivery = if mode == "none" { + None + } else { + Some(Arc::new( + NativeDelivery::new(NativeDeliveryOptions { + snapshots: (mode == "snapshots").then_some(SnapshotLimits { + entries: 128, + bytes: 2 * 1024 * 1024, + entry_bytes: 256 * 1024, + }), + coalescing: (mode == "flights").then_some(FlightLimits { + groups: 8, + consumers: 128, + response_bytes: 256 * 1024, + ..Default::default() + }), + live: (mode == "live").then_some(LiveLimits { + groups: 4, + consumers: 128, + frame_bytes: 64 * 1024, + ..Default::default() + }), + }) + .unwrap(), + )) + }; + let selected = DeliveryCapabilities { + snapshots: mode == "snapshots", + coalescing: mode == "flights", + live_sharing: mode == "live", + }; + let config = GatewayConfig { + bindings: vec![Binding::new( + "api", + BindingKind::Graphql { + executor: GraphqlExecutor::Remote { + origin: format!("http://127.0.0.1:{meter_port}"), + }, + capabilities: caps, + delivery: selected, + schema_extensions: vec![], + }, + )], + routes: vec![Route::new("api", RoutePath::prefix("/graphql"), "api")], + } + .build() + .unwrap(); + let binding = GraphqlBinding::Remote(RemoteGraphql::default()); + let resource = delivery.clone().map_or_else( + || NativeBinding::Graphql(binding.clone()), + |d| NativeBinding::GraphqlWithDelivery(binding.clone(), d), + ); + let gateway = NativeGateway::new( + config, + NativeOptions::new("http://load-gateway.invalid"), + [("api".into(), resource)], + NativeAuth::anonymous(), + ) + .unwrap(); + let counts = delivery.clone(); + let reset = delivery; + let router=gateway.router().route("/__coordinators",get(move || {let delivery=counts.clone();async move {axum::Json(delivery.map_or_else(||json!([]),|d|json!([{ "query":[0,d.flight_counts().0,d.flight_counts().1],"live":d.live_counts(),"metrics":d.metrics()}])))}}).post(move || {let delivery=reset.clone();async move {if let Some(d)=delivery{d.invalidate_all();}"reset"}})); + let server = serve(router).await; + native.insert(mode, server.origin.clone()); + servers.push(server); + } + let result = tokio::process::Command::new("node") + .arg("tests/gateway-worker/load-runtime.mjs") + .env("GATEWAY_ORIGIN", &origin.origin) + .env("GATEWAY_METER_PORT", meter_port.to_string()) + .env( + "GATEWAY_NATIVE_MODES", + serde_json::to_string(&native).unwrap(), + ) + .env("GATEWAY_TOKEN_ALICE", token("alice")) + .env("GATEWAY_TOKEN_BOB", token("bob")) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .output() + .await + .unwrap(); + println!("{}", String::from_utf8_lossy(&result.stdout)); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); +} diff --git a/tests/graphql_query_protocol/main.rs b/tests/graphql_query_protocol/main.rs index 6d856fa1b..325e4a294 100644 --- a/tests/graphql_query_protocol/main.rs +++ b/tests/graphql_query_protocol/main.rs @@ -13,7 +13,7 @@ use distributed::microsvc::{ CausalProjectorContext, HandlerError, Routes, Service, Session, ROLE_KEY, }; use distributed::projection_protocol::ProjectionChangeRetention; -use distributed::{ReadModelCatalog, ReadModel, RelationalReadModel, SqliteRepository}; +use distributed::{ReadModel, ReadModelCatalog, RelationalReadModel, SqliteRepository}; use futures_util::{stream::BoxStream, SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -596,8 +596,7 @@ async fn embedded_models_emit_index_evidence_without_record_evidence() { let repository = SqliteRepository::connect_and_migrate("sqlite::memory:") .await .expect("migrated embedded SQLite repository"); - let manifest = - ReadModelCatalog::new(EMBEDDED_SERVICE_ID).read_model::(); + let manifest = ReadModelCatalog::new(EMBEDDED_SERVICE_ID).read_model::(); repository .bootstrap_table_schema_for_dev( &manifest @@ -1220,19 +1219,51 @@ async fn query_over_http_and_graphql_ws( (http, next["payload"].clone()) } +#[derive(Clone, Copy)] +enum QueryGatewayMode { + Direct, + #[cfg(feature = "gateway-graphql-native")] + Embedded, + #[cfg(feature = "gateway-graphql-native")] + Remote, +} + #[tokio::test] async fn http_and_graphql_ws_serialize_the_same_query_revision_envelope() { + exercise_query_transport(QueryGatewayMode::Direct).await; +} + +#[cfg(feature = "gateway-graphql-native")] +#[tokio::test] +async fn gateway_embedded_query_revision_parity() { + exercise_query_transport(QueryGatewayMode::Embedded).await; +} + +#[cfg(feature = "gateway-graphql-native")] +#[tokio::test] +async fn gateway_remote_query_revision_parity() { + exercise_query_transport(QueryGatewayMode::Remote).await; +} + +async fn exercise_query_transport(mode: QueryGatewayMode) { let fixture = protocol_fixture_with_retention(10).await; - let engine = GraphqlEngine::builder(&fixture.repository) + let builder = GraphqlEngine::builder(&fixture.repository) .service_id(SERVICE_ID) .protocol_token_key(TEST_PROTOCOL_TOKEN_KEY) .roles(&["user"]) .anonymous_role("user") .model::(ModelPermissions::new().grant("user", read().all_columns())) .client_projectors([projector()]) - .change_stream(fixture.repository.read_model_changes()) - .build() - .expect("query transport parity engine"); + .change_stream(fixture.repository.read_model_changes()); + #[cfg(feature = "gateway-graphql-native")] + let builder = match mode { + QueryGatewayMode::Direct => builder, + _ => builder.identity(distributed::graphql::IdentityConfig::oidc_bearer( + distributed::graphql::OidcConfig::new("http://local-fixture.invalid", "query-api") + .require_auth(false), + )), + }; + let engine = builder.build().expect("query transport parity engine"); let service = Arc::new( Service::new() .named(SERVICE_ID) @@ -1243,8 +1274,67 @@ async fn http_and_graphql_ws_serialize_the_same_query_revision_envelope() { .await .expect("query transport listener"); let address = listener.local_addr().expect("query transport address"); + #[allow(unused_mut)] + let mut upstream = None::>; + let router = match mode { + QueryGatewayMode::Direct => distributed::microsvc::router(service), + #[cfg(feature = "gateway-graphql-native")] + mode => { + use distributed::gateway::{native::*, *}; + let caps = GraphqlCapabilities { + commands: false, + queries: true, + live: true, + }; + let (executor, binding) = match mode { + QueryGatewayMode::Embedded => ( + GraphqlExecutor::Embedded, + GraphqlBinding::Embedded( + EmbeddedGraphql::new(service.graphql_engine().unwrap(), None, caps) + .unwrap(), + ), + ), + QueryGatewayMode::Remote => { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + upstream = Some(tokio::spawn(async move { + axum::serve(listener, distributed::microsvc::router(service)) + .await + .unwrap() + })); + ( + GraphqlExecutor::Remote { origin }, + GraphqlBinding::Remote(RemoteGraphql::default()), + ) + } + QueryGatewayMode::Direct => unreachable!(), + }; + let config = GatewayConfig { + bindings: vec![Binding::new( + "graphql", + BindingKind::Graphql { + executor, + capabilities: caps, + delivery: DeliveryCapabilities::default(), + schema_extensions: vec![], + }, + )], + routes: vec![Route::new("api", RoutePath::prefix("/graphql"), "graphql")], + } + .build() + .unwrap(); + NativeGateway::new( + config, + NativeOptions::new(format!("http://{address}")), + [("graphql".into(), NativeBinding::Graphql(binding))], + NativeAuth::anonymous(), + ) + .unwrap() + .router() + } + }; let server = tokio::spawn(async move { - axum::serve(listener, distributed::microsvc::router(service)) + axum::serve(listener, router) .await .expect("query transport server"); }); @@ -1262,7 +1352,117 @@ async fn http_and_graphql_ws_serialize_the_same_query_revision_envelope() { "{case}: HTTP and GraphQL-WS must preserve one canonical query/revision envelope" ); } + // Exercise committed projection changes through the actual socket path, + // including redundant invalidation and immutable per-frame causal metadata. + let mut request = format!("ws://{address}/graphql/ws") + .into_client_request() + .unwrap(); + request.headers_mut().insert( + SEC_WEBSOCKET_PROTOCOL, + "graphql-transport-ws".parse().unwrap(), + ); + let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap(); + socket + .send(WsMessage::Text( + json!({"type":"connection_init"}).to_string().into(), + )) + .await + .unwrap(); + let ack = socket.next().await.unwrap().unwrap(); + assert_eq!( + serde_json::from_str::(ack.to_text().unwrap()).unwrap()["type"], + "connection_ack" + ); + socket + .send(WsMessage::Text( + json!({"id":"live-proof", "type":"subscribe", "payload":{"query":LIVE_SUBSCRIPTION}}) + .to_string() + .into(), + )) + .await + .unwrap(); + let mut frames = Vec::new(); + for position in 1..=3 { + if position > 1 { + project_item( + &fixture.repository, + &fixture.bus, + position, + &format!("causal row {position}"), + ) + .await; + } + let frame = tokio::time::timeout(Duration::from_secs(5), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let frame: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(frame["id"], "live-proof"); + assert_eq!(frame["type"], "next"); + let title = if position == 1 { + "causal row".into() + } else { + format!("causal row {position}") + }; + assert_live_frame( + &frame["payload"], + "causal_query_views", + &title, + &position.to_string(), + false, + ); + if position > 1 { + assert_eq!( + distributed_envelope(&frame["payload"])["snapshot"]["observations"][0] + ["causationId"], + format!("query-protocol-command-{position}") + ); + } + frames.push(frame["payload"].clone()); + fixture + .repository + .publish_read_model_change(distributed::ReadModelChange::new(["causal_query_views"])); + let redundant = tokio::time::timeout(Duration::from_millis(350), socket.next()).await; + if position == 1 { + assert!( + redundant.is_err(), + "identical data and metadata must be suppressed" + ); + } else { + // The origin emits a proof-only frame as its causation suffix moves + // to the new head. Preserve it even though domain data is identical. + let redundant = redundant.unwrap().unwrap().unwrap(); + let proof: Value = serde_json::from_str(redundant.to_text().unwrap()).unwrap(); + assert_eq!(proof["id"], "live-proof"); + assert_eq!(proof["payload"]["data"], frame["payload"]["data"]); + assert_eq!( + distributed_envelope(&proof["payload"])["snapshot"]["observations"], + json!([]) + ); + fixture + .repository + .publish_read_model_change(distributed::ReadModelChange::new([ + "causal_query_views", + ])); + assert!( + tokio::time::timeout(Duration::from_millis(350), socket.next()) + .await + .is_err() + ); + } + } + for (i, frame) in frames.iter().enumerate() { + assert_eq!( + distributed_envelope(frame)["snapshot"]["indexes"][0]["position"], + (i + 1).to_string() + ); + } + socket.close(None).await.unwrap(); server.abort(); + if let Some(upstream) = upstream { + upstream.abort(); + } } #[tokio::test] @@ -1296,3 +1496,1083 @@ async fn unowned_legacy_query_is_explicitly_incomplete_and_still_strips_keys() { assert_eq!(snapshot["observations"], json!([])); assert_opaque_token(&snapshot["scopeToken"], "query-snapshot"); } + +#[cfg(feature = "gateway-delivery")] +#[tokio::test] +async fn freshness_context_binds_origin_and_rejects_unproven_minima() { + use distributed::gateway::delivery::{FreshnessContext, Minimum}; + let fixture = protocol_fixture_with_retention(10).await; + let document = "query ReadFreshness { causal_query_views { title } }"; + let request = || Request::new(document); + let identity = fixture + .engine + .delivery_identity(&user_session(), &request()) + .unwrap(); + let mut context = FreshnessContext::parse(&json!({ + "version":1, "schemaHash":identity.schema_hash, "protocolHash":identity.protocol_hash, + "authorizationGeneration":identity.authorization_generation, "cacheScope":identity.cache_scope, + "pending":[{"complete":true,"models":["CausalQueryView"],"relationships":[]}], "minimum":[] + })).unwrap(); + let with_context = |context: &FreshnessContext| { + let mut request = request(); + request.extensions.insert( + "gatewayFreshness".into(), + async_graphql::Value::from_json(serde_json::to_value(context).unwrap()).unwrap(), + ); + request + }; + let before = wire_response( + fixture + .engine + .execute(&user_session(), with_context(&context)) + .await, + ); + assert_eq!( + before["data"]["causal_query_views"][0]["title"], + "causal row" + ); + assert_eq!( + distributed_envelope(&before)["snapshot"]["observations"], + json!([]) + ); + project_item(&fixture.repository, &fixture.bus, 2, "committed").await; + let after = wire_response( + fixture + .engine + .execute(&user_session(), with_context(&context)) + .await, + ); + let index = &distributed_envelope(&after)["snapshot"]["indexes"][0]; + context + .observe([Minimum::Index { + projection: index["projection"].as_str().unwrap().into(), + scope_token: index["scopeToken"].as_str().unwrap().into(), + position: "2".into(), + }]) + .unwrap(); + context.pending.clear(); + assert_eq!( + wire_response( + fixture + .engine + .execute(&user_session(), with_context(&context)) + .await + )["data"]["causal_query_views"][0]["title"], + "committed" + ); + let mut future = context.clone(); + if let Minimum::Index { position, .. } = &mut future.minimum[0] { + *position = "999".into(); + } + let rejected = serde_json::to_value( + fixture + .engine + .execute(&user_session(), with_context(&future)) + .await, + ) + .unwrap(); + assert_eq!( + rejected["errors"][0]["extensions"]["code"], + "FRESHNESS_PENDING" + ); + assert!(rejected["data"].is_null()); + let mut forged = context; + forged.cache_scope = "another-subject".into(); + assert_eq!( + serde_json::to_value( + fixture + .engine + .execute(&user_session(), with_context(&forged)) + .await + ) + .unwrap()["errors"][0]["extensions"]["code"], + "FRESHNESS_SCOPE_CHANGED" + ); +} + +#[cfg(feature = "gateway-delivery")] +#[tokio::test] +async fn gateway_validator_tracks_projection_commit_in_the_result_snapshot() { + use distributed::graphql::delivery::GatewayVersionStore; + let fixture = protocol_fixture_with_retention(10).await; + let pool = distributed::graphql::GraphqlPool::from(fixture.repository.pool().clone()); + let store = + GatewayVersionStore::install(&pool, "query-protocol-cache", ["causal_query_views".into()]) + .await + .unwrap(); + let engine = GraphqlEngine::builder(&fixture.repository) + .service_id(SERVICE_ID) + .protocol_token_key(TEST_PROTOCOL_TOKEN_KEY) + .roles(&["user"]) + .anonymous_role("user") + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .client_projectors([projector()]) + .gateway_versions(store.clone()) + .build() + .unwrap(); + let request = |action: &str| { + let mut request = Request::new("query CachedRows { causal_query_views { title } }"); + request.extensions.insert( + "gatewayDelivery".into(), + async_graphql::Value::from_json(json!({"action":action})).unwrap(), + ); + request + }; + let validate = wire_response(engine.execute(&user_session(), request("validate")).await); + assert_eq!( + validate["extensions"]["gatewayDelivery"]["eligible"], true, + "{validate}" + ); + let validator = validate["extensions"]["gatewayDelivery"]["admission"]["validator"].clone(); + let first = wire_response(engine.execute(&user_session(), request("snapshot")).await); + assert_eq!( + first["extensions"]["gatewayDelivery"]["validator"], validator, + "{first}" + ); + assert_eq!( + first["data"]["causal_query_views"][0]["title"], + "causal row" + ); + project_item(&fixture.repository, &fixture.bus, 2, "changed").await; + let validate = wire_response(engine.execute(&user_session(), request("validate")).await); + assert_ne!( + validate["extensions"]["gatewayDelivery"]["admission"]["validator"], + validator + ); + let second = wire_response(engine.execute(&user_session(), request("snapshot")).await); + assert_eq!( + second["extensions"]["gatewayDelivery"]["validator"], + validate["extensions"]["gatewayDelivery"]["admission"]["validator"] + ); + assert_eq!(second["data"]["causal_query_views"][0]["title"], "changed"); + assert_eq!( + distributed_envelope(&second)["snapshot"]["indexes"][0]["position"], + "2" + ); + store.rotate_epoch(&pool).await.unwrap(); + let next_epoch = wire_response(engine.execute(&user_session(), request("validate")).await); + assert_ne!( + next_epoch["extensions"]["gatewayDelivery"]["admission"]["validator"], + second["extensions"]["gatewayDelivery"]["validator"] + ); + // A failed write transaction cannot advance the cache dependency version. + let mut tx = fixture.repository.pool().begin().await.unwrap(); + sqlx::query("UPDATE causal_query_views SET title='rolled back'") + .execute(&mut *tx) + .await + .unwrap(); + tx.rollback().await.unwrap(); + let rolled_back = wire_response(engine.execute(&user_session(), request("validate")).await); + assert_eq!( + rolled_back["extensions"]["gatewayDelivery"]["admission"]["validator"], + next_epoch["extensions"]["gatewayDelivery"]["admission"]["validator"] + ); +} + +#[cfg(all(feature = "gateway-delivery", feature = "gateway-graphql-native"))] +#[tokio::test] +async fn native_snapshot_cache_revalidates_each_consumer_without_result_sql() { + use axum::{routing::post, Router}; + use distributed::gateway::{delivery::SnapshotLimits, native::*, *}; + use distributed::graphql::delivery::GatewayVersionStore; + use std::sync::atomic::{AtomicBool, Ordering}; + use tower::ServiceExt; + let fixture = protocol_fixture_with_retention(10).await; + let pool = distributed::graphql::GraphqlPool::from(fixture.repository.pool().clone()); + let store = GatewayVersionStore::install(&pool, "native-cache", ["causal_query_views".into()]) + .await + .unwrap(); + let engine = Arc::new( + GraphqlEngine::builder(&fixture.repository) + .service_id(SERVICE_ID) + .protocol_token_key(TEST_PROTOCOL_TOKEN_KEY) + .roles(&["user"]) + .anonymous_role("user") + .subscriptions(false) + .model::( + ModelPermissions::new().grant("user", read().all_columns().aggregations()), + ) + .client_projectors([projector()]) + .gateway_versions(store.clone()) + .build() + .unwrap(), + ); + let revoked = Arc::new(AtomicBool::new(false)); + let origin = { + let engine = engine.clone(); + let revoked = revoked.clone(); + Router::new().route( + "/graphql", + post(move |axum::Json(value): axum::Json| { + let engine = engine.clone(); + let revoked = revoked.clone(); + async move { + if revoked.load(Ordering::SeqCst) { + return ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(json!({"error":"denied"})), + ); + } + let request: Request = serde_json::from_value(value).unwrap(); + ( + axum::http::StatusCode::OK, + axum::Json( + serde_json::to_value(engine.execute(&user_session(), request).await) + .unwrap(), + ), + ) + } + }), + ) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin_url = format!("http://{}", listener.local_addr().unwrap()); + let origin_task = tokio::spawn(async move { axum::serve(listener, origin).await.unwrap() }); + struct Stop(tokio::task::JoinHandle<()>); + impl Drop for Stop { + fn drop(&mut self) { + self.0.abort(); + } + } + let _origin = Stop(origin_task); + for remote in [false, true] { + let caps = GraphqlCapabilities { + queries: true, + ..Default::default() + }; + let executor = if remote { + GraphqlExecutor::Remote { + origin: origin_url.clone(), + } + } else { + GraphqlExecutor::Embedded + }; + let binding = if remote { + GraphqlBinding::Remote(RemoteGraphql { + live_path: None, + ..Default::default() + }) + } else { + GraphqlBinding::Embedded(EmbeddedGraphql::new(engine.clone(), None, caps).unwrap()) + }; + let delivery = Arc::new(NativeDelivery::snapshots(SnapshotLimits::default()).unwrap()); + let config = GatewayConfig { + bindings: vec![Binding::new( + "api", + BindingKind::Graphql { + executor, + capabilities: caps, + delivery: DeliveryCapabilities { + snapshots: true, + ..Default::default() + }, + schema_extensions: vec![], + }, + )], + routes: vec![Route::new("api", RoutePath::prefix("/graphql"), "api")], + } + .build() + .unwrap(); + let gateway = NativeGateway::new( + config, + NativeOptions::new("http://public.invalid"), + [( + "api".into(), + NativeBinding::GraphqlWithDelivery(binding, delivery.clone()), + )], + NativeAuth::anonymous(), + ) + .unwrap() + .router(); + let run = |document: &str| { + let gateway = gateway.clone(); + let body = serde_json::to_vec(&json!({"query": document})).unwrap(); + async move { + let response = gateway + .oneshot( + axum::http::Request::post("/graphql") + .header("content-type", "application/json") + .body(axum::body::Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + ( + status, + serde_json::from_slice::(&bytes).unwrap(), + ) + } + }; + let document = "query Cache { causal_query_views { title } }"; + let before = store.metrics(); + let (status, first) = run(document).await; + assert_eq!(status, axum::http::StatusCode::OK); + assert!(first.get("errors").is_none(), "{first}"); + assert!( + first["extensions"]["gatewayDelivery"]["validator"].is_string(), + "{first}" + ); + let filled = store.metrics(); + assert_eq!(filled.result_executions, before.result_executions + 1); + assert_eq!(filled.validations, before.validations + 2); + for _ in 0..5 { + assert_eq!(run(document).await.1, first); + } + let hit = store.metrics(); + assert_eq!( + hit.result_executions, filled.result_executions, + "cache hit ran result SQL" + ); + assert_eq!( + hit.validations, + filled.validations + 5, + "each consumer needs fresh origin admission" + ); + // No invalidation feed is connected: transactional dependency validation + // must still discover a supported external SQL producer's update. + sqlx::query("UPDATE causal_query_views SET title=title || '-external'") + .execute(fixture.repository.pool()) + .await + .unwrap(); + let changed = run(document).await.1; + assert_ne!(changed["data"], first["data"]); + assert_eq!(store.metrics().result_executions, hit.result_executions + 1); + assert_eq!(run(document).await.1, changed); + delivery.invalidate_all(); + assert_eq!(run(document).await.1, changed); + assert_eq!(store.metrics().result_executions, hit.result_executions + 2); + if remote { + revoked.store(true, Ordering::SeqCst); + assert_eq!( + run(document).await.0, + axum::http::StatusCode::UNAUTHORIZED, + "no private stale-on-auth-error" + ); + assert_eq!(store.metrics().result_executions, hit.result_executions + 2); + revoked.store(false, Ordering::SeqCst); + } + // A projection can advance evidence while leaving selected data equal. + let title = changed["data"]["causal_query_views"][0]["title"] + .as_str() + .unwrap(); + project_item( + &fixture.repository, + &fixture.bus, + if remote { 3 } else { 2 }, + title, + ) + .await; + let proof_only = run(document).await.1; + assert_eq!(proof_only["data"], changed["data"]); + assert_ne!( + proof_only["extensions"]["gatewayDelivery"]["validator"], + changed["extensions"]["gatewayDelivery"]["validator"] + ); + let filtered = + "query Filtered { causal_query_views(where: {title: {_eq: \"matching\"}}) { title } }"; + let count = "query Count { causal_query_views_aggregate(where: {title: {_eq: \"matching\"}}) { aggregate { count } } }"; + assert_eq!( + run(filtered).await.1["data"]["causal_query_views"], + json!([]) + ); + let empty_count = run(count).await.1; + assert!(empty_count.get("errors").is_none(), "{empty_count}"); + assert_eq!( + empty_count["data"]["causal_query_views_aggregate"]["aggregate"]["count"], + 0 + ); + let filled = store.metrics(); + run(filtered).await; + run(count).await; + assert_eq!(store.metrics().result_executions, filled.result_executions); + sqlx::query("UPDATE causal_query_views SET title='matching'") + .execute(fixture.repository.pool()) + .await + .unwrap(); + assert_eq!( + run(filtered).await.1["data"]["causal_query_views"][0]["title"], + "matching" + ); + assert_eq!( + run(count).await.1["data"]["causal_query_views_aggregate"]["aggregate"]["count"], + 1 + ); + } +} + +#[cfg(all(feature = "gateway-delivery", feature = "gateway-graphql-native"))] +#[tokio::test] +async fn hundred_reads_one_execution_with_cache_disabled() { + use axum::{routing::post, Router}; + use distributed::gateway::{delivery::FlightLimits, native::*, *}; + use distributed::graphql::delivery::GatewayVersionStore; + use tower::ServiceExt; + let fixture = protocol_fixture_with_retention(10).await; + let store = GatewayVersionStore::install( + &distributed::graphql::GraphqlPool::from(fixture.repository.pool().clone()), + "flight-test", + ["causal_query_views".into()], + ) + .await + .unwrap(); + let engine = Arc::new( + GraphqlEngine::builder(&fixture.repository) + .service_id(SERVICE_ID) + .protocol_token_key(TEST_PROTOCOL_TOKEN_KEY) + .roles(&["user"]) + .anonymous_role("user") + .subscriptions(false) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .client_projectors([projector()]) + .gateway_versions(store.clone()) + .build() + .unwrap(), + ); + let protocol_hash = engine + .delivery_identity( + &user_session(), + &Request::new("query Coalesced { causal_query_views { title } }"), + ) + .unwrap() + .protocol_hash; + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let handler_gate = gate.clone(); + let origin = Router::new().route( + "/graphql", + post(move |axum::Json(value): axum::Json| { + let engine = engine.clone(); + let gate = handler_gate.clone(); + async move { + if value["extensions"]["gatewayDelivery"]["action"] == "snapshot" { + gate.acquire().await.unwrap().forget(); + } + axum::Json( + serde_json::to_value( + engine + .execute( + &user_session(), + serde_json::from_value::(value).unwrap(), + ) + .await, + ) + .unwrap(), + ) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin_url = format!("http://{}", listener.local_addr().unwrap()); + struct Stop(tokio::task::JoinHandle<()>); + impl Drop for Stop { + fn drop(&mut self) { + self.0.abort(); + } + } + let _server = Stop(tokio::spawn(async move { + axum::serve(listener, origin).await.unwrap() + })); + let delivery = Arc::new(NativeDelivery::coalescing(FlightLimits::default()).unwrap()); + let config = GatewayConfig { + bindings: vec![Binding::new( + "api", + BindingKind::Graphql { + executor: GraphqlExecutor::Remote { origin: origin_url }, + capabilities: GraphqlCapabilities { + queries: true, + ..Default::default() + }, + delivery: DeliveryCapabilities { + coalescing: true, + ..Default::default() + }, + schema_extensions: vec![], + }, + )], + routes: vec![Route::new("api", RoutePath::prefix("/graphql"), "api")], + } + .build() + .unwrap(); + let router = NativeGateway::new( + config, + NativeOptions::new("http://public.invalid"), + [( + "api".into(), + NativeBinding::GraphqlWithDelivery( + GraphqlBinding::Remote(RemoteGraphql { + live_path: None, + ..Default::default() + }), + delivery.clone(), + ), + )], + NativeAuth::anonymous(), + ) + .unwrap() + .router(); + let document = "query Coalesced { causal_query_views { title } }"; + let spawn = |extension: Option| { + let router = router.clone(); + let mut request = json!({"query":document}); + if let Some(extension) = extension { + request["extensions"] = json!({"gatewayFreshness":extension}); + } + tokio::spawn(async move { + let response = router + .oneshot( + axum::http::Request::post("/graphql") + .header("content-type", "application/json") + .body(axum::body::Body::from(request.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + serde_json::from_slice::(&bytes).unwrap() + }) + }; + let wait = |groups, consumers| { + let delivery = delivery.clone(); + async move { + tokio::time::timeout(Duration::from_secs(10), async { + while delivery.flight_counts() != (groups, consumers) { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("all consumers admitted and joined before releasing result execution"); + } + }; + let mut consumers = Vec::new(); + for _ in 0..100 { + consumers.push(spawn(None)); + } + wait(1, 100).await; + assert_eq!(store.metrics().validations, 100); + assert_eq!(store.metrics().result_executions, 0); + gate.add_permits(1); + let first = consumers.pop().unwrap().await.unwrap(); + assert!(first.get("errors").is_none(), "{first}"); + for consumer in consumers { + assert_eq!(consumer.await.unwrap(), first); + } + assert_eq!( + store.metrics().result_executions, + 1, + "100 overlapping consumers must execute result SQL exactly once" + ); + assert_eq!(delivery.flight_counts(), (0, 0)); + // Cache is disabled: a subsequent nonoverlapping request executes again. + let next = spawn(None); + wait(1, 1).await; + gate.add_permits(1); + assert_eq!(next.await.unwrap(), first); + assert_eq!(store.metrics().result_executions, 2); + // One consumer can cancel without aborting the other's execution. + let cancelled = spawn(None); + let survivor = spawn(None); + wait(1, 2).await; + cancelled.abort(); + let _ = cancelled.await; + wait(1, 1).await; + gate.add_permits(1); + assert_eq!(survivor.await.unwrap(), first); + assert_eq!(store.metrics().result_executions, 3); + // A stronger floor must not join a flight admitted without that floor. + let protocol = &first["extensions"]["distributed"]; + let index = &protocol["snapshot"]["indexes"][0]; + let context = json!({"version":1,"schemaHash":protocol["schemaHash"],"protocolHash":protocol_hash, + "authorizationGeneration":protocol["authorizationGeneration"],"cacheScope":protocol["cacheScope"],"pending":[], + "minimum":[{"kind":"index","projection":index["projection"],"scopeToken":index["scopeToken"],"position":"1"}]}); + let old = spawn(None); + wait(1, 1).await; + let stronger = spawn(Some(context)); + wait(2, 2).await; + gate.add_permits(2); + assert_eq!(old.await.unwrap(), first); + let strong = stronger.await.unwrap(); + assert!(strong.get("errors").is_none(), "{strong}"); + assert_eq!(store.metrics().result_executions, 5); + assert_eq!(delivery.flight_counts(), (0, 0)); +} + +#[cfg(all(feature = "gateway-delivery", feature = "gateway-graphql-native"))] +#[tokio::test] +async fn hundred_subscribers_one_upstream_over_embedded_and_remote_websockets() { + use axum::Router; + use distributed::gateway::{delivery::LiveLimits, native::*, *}; + use distributed::graphql::{delivery::GatewayVersionStore, IdentityConfig, OidcConfig}; + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use rsa::{pkcs1::EncodeRsaPrivateKey, traits::PublicKeyParts, RsaPrivateKey, RsaPublicKey}; + let private = RsaPrivateKey::new(&mut rand::thread_rng(), 2048).unwrap(); + let public = RsaPublicKey::from(&private); + let encoding = EncodingKey::from_rsa_pem( + private + .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF) + .unwrap() + .as_bytes(), + ) + .unwrap(); + let jwks=json!({"keys":[{"kty":"RSA","kid":"live-test","alg":"RS256","use":"sig", + "n":base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()),"e":base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.e().to_bytes_be())}]}).to_string(); + let token = |subject: &str| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("live-test".into()); + encode(&header,&json!({"iss":"https://live-fixture.invalid","aud":"live-fixture","sub":subject,"iat":now-1,"nbf":now-1,"exp":now+3600,"roles":["user"]}),&encoding).unwrap() + }; + struct Server { + origin: String, + task: tokio::task::JoinHandle<()>, + } + impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } + } + async fn serve(router: Router) -> Server { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + Server { + origin, + task: tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }), + } + } + type Socket = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + async fn connect(origin: &str, token: &str, legacy: bool) -> Socket { + let mut request = format!("{}/graphql/ws", origin.replace("http:", "ws:")) + .into_client_request() + .unwrap(); + request.headers_mut().insert( + "sec-websocket-protocol", + if legacy { + "graphql-ws" + } else { + "graphql-transport-ws" + } + .parse() + .unwrap(), + ); + let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap(); + socket.send(WsMessage::Text(json!({"type":"connection_init","payload":{"authorization":format!("Bearer {token}")}}).to_string().into())).await.unwrap(); + let ack = tokio::time::timeout(Duration::from_secs(10), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!( + matches!(ack,WsMessage::Text(ref text) if serde_json::from_str::(text).unwrap()["type"]=="connection_ack"), + "{ack:?}" + ); + socket + } + async fn next(socket: &mut Socket, id: &str, title: &str) -> Value { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let message = socket.next().await.unwrap().unwrap(); + if let WsMessage::Text(text) = message { + let value: Value = serde_json::from_str(&text).unwrap(); + if matches!(value["type"].as_str(), Some("next" | "data")) { + assert_eq!(value["id"], id); + assert!(value["payload"].get("errors").is_none(), "{value}"); + if value["payload"]["data"]["causal_query_views"][0]["title"] == title { + return value["payload"].clone(); + } + } + } + } + }) + .await + .expect("matching full live frame") + } + for remote in [false, true] { + eprintln!( + "shared live transport: {}", + if remote { "remote" } else { "embedded" } + ); + let fixture = protocol_fixture_with_retention(10).await; + let versions = GatewayVersionStore::install( + &distributed::graphql::GraphqlPool::from(fixture.repository.pool().clone()), + "live-fixture", + ["causal_query_views".into()], + ) + .await + .unwrap(); + let oidc = OidcConfig::new("https://live-fixture.invalid", "live-fixture") + .with_static_jwks(jwks.clone()) + .engine_roles(&["user"]); + let engine = Arc::new( + GraphqlEngine::builder(&fixture.repository) + .service_id(SERVICE_ID) + .protocol_token_key(TEST_PROTOCOL_TOKEN_KEY) + .roles(&["user"]) + .anonymous_role("user") + .identity(IdentityConfig::oidc_bearer(oidc)) + .model::( + ModelPermissions::new().grant("user", read().all_columns()), + ) + .client_projectors([projector()]) + .change_stream(fixture.repository.read_model_changes()) + .gateway_versions(versions.clone()) + .build() + .unwrap(), + ); + let caps = GraphqlCapabilities { + queries: true, + live: true, + ..Default::default() + }; + let origin = serve(distributed::graphql::graphql_router_composed( + engine.clone(), + None, + None, + )) + .await; + let executor = if remote { + GraphqlExecutor::Remote { + origin: origin.origin.clone(), + } + } else { + GraphqlExecutor::Embedded + }; + let binding = if remote { + GraphqlBinding::Remote(RemoteGraphql::default()) + } else { + GraphqlBinding::Embedded(EmbeddedGraphql::new(engine.clone(), None, caps).unwrap()) + }; + let delivery = Arc::new(NativeDelivery::live(LiveLimits::default()).unwrap()); + let config = GatewayConfig { + bindings: vec![Binding::new( + "api", + BindingKind::Graphql { + executor, + capabilities: caps, + delivery: DeliveryCapabilities { + live_sharing: true, + ..Default::default() + }, + schema_extensions: vec![], + }, + )], + routes: vec![Route::new("api", RoutePath::prefix("/graphql"), "api")], + } + .build() + .unwrap(); + let gateway = serve( + NativeGateway::new( + config, + NativeOptions::new("http://public.invalid"), + [( + "api".into(), + NativeBinding::GraphqlWithDelivery(binding, delivery.clone()), + )], + NativeAuth::anonymous(), + ) + .unwrap() + .router(), + ) + .await; + let alice = token("alice"); + let bob = token("bob"); + let mut sockets = futures_util::future::join_all( + (0..100).map(|_| connect(&gateway.origin, &alice, false)), + ) + .await; + for (n, socket) in sockets.iter_mut().enumerate() { + socket.send(WsMessage::Text(json!({"id":format!("consumer-{n}"),"type":"subscribe","payload":{"query":LIVE_SUBSCRIPTION}}).to_string().into())).await.unwrap(); + } + let mut first = None; + for (n, socket) in sockets.iter_mut().enumerate() { + let payload = next(socket, &format!("consumer-{n}"), "causal row").await; + if let Some(first) = &first { + assert_eq!(&payload, first); + } else { + first = Some(payload); + } + } + assert_eq!( + (delivery.live_counts().0, delivery.live_counts().1), + (1, 100) + ); + assert_eq!(engine.live_subscriber_count(), 1); + assert_eq!(versions.metrics().validations, 100); + assert_eq!( + versions.metrics().result_executions, + 1, + "100 authenticated clients should own one origin live query" + ); + let first = first.unwrap(); + let mut bob_socket = connect(&gateway.origin, &bob, false).await; + bob_socket + .send(WsMessage::Text( + json!({"id":"consumer-0","type":"subscribe","payload":{"query":LIVE_SUBSCRIPTION}}) + .to_string() + .into(), + )) + .await + .unwrap(); + let bob_frame = next(&mut bob_socket, "consumer-0", "causal row").await; + assert_ne!( + first["extensions"]["distributed"]["cacheScope"], + bob_frame["extensions"]["distributed"]["cacheScope"] + ); + assert_eq!( + (delivery.live_counts().0, engine.live_subscriber_count()), + (2, 2), + "identical roles/data must not cross subject scopes" + ); + bob_socket + .send(WsMessage::Text( + json!({"id":"consumer-0","type":"complete"}) + .to_string() + .into(), + )) + .await + .unwrap(); + let _ = bob_socket.close(None).await; + project_item(&fixture.repository, &fixture.bus, 2, "committed-live").await; + for (n, socket) in sockets.iter_mut().enumerate() { + let frame = next(socket, &format!("consumer-{n}"), "committed-live").await; + assert_eq!( + frame["extensions"]["distributed"]["snapshot"]["indexes"][0]["position"], + "2" + ); + } + let mut resumed = connect(&gateway.origin, &alice, true).await; + resumed.send(WsMessage::Text(json!({"id":"legacy-resume","type":"start","payload":{"query":LIVE_SUBSCRIPTION,"extensions":{"distributed":{"resume":{"cursors":first["extensions"]["distributed"]["live"]["cursors"]}}}}}).to_string().into())).await.unwrap(); + let replay = next(&mut resumed, "legacy-resume", "committed-live").await; + assert_eq!(replay["extensions"]["distributed"]["live"]["reset"], false); + tokio::time::timeout(Duration::from_secs(5), async { + while delivery.live_counts().0 != 1 || engine.live_subscriber_count() != 1 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("independent replay handed off and released its origin producer"); + assert!(delivery.live_counts().6 >= 1); + for (n, socket) in sockets.iter_mut().enumerate() { + socket + .send(WsMessage::Text( + json!({"id":format!("consumer-{n}"),"type":"complete"}) + .to_string() + .into(), + )) + .await + .unwrap(); + let _ = socket.close(None).await; + } + tokio::time::timeout(Duration::from_secs(5), async { + while delivery.live_counts().1 != 1 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + assert_eq!( + engine.live_subscriber_count(), + 1, + "remaining resumed client retains upstream ownership" + ); + resumed + .send(WsMessage::Text( + json!({"id":"legacy-resume","type":"stop"}) + .to_string() + .into(), + )) + .await + .unwrap(); + let _ = resumed.close(None).await; + tokio::time::timeout(Duration::from_secs(5), async { + while delivery.live_counts().0 != 0 || engine.live_subscriber_count() != 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("last leave tears down gateway and origin live producers"); + } +} + +#[cfg(all(feature = "gateway-delivery", feature = "gateway-graphql-native"))] +#[tokio::test] +#[ignore = "requires pinned tests/gateway-worker npm installation and worker-build; run explicitly in Worker CI"] +async fn worker_coordinator_uses_actual_origin_projection_sql() { + use axum::{ + routing::{get, post}, + Router, + }; + use distributed::graphql::delivery::GatewayVersionStore; + let fixture = protocol_fixture_with_retention(10).await; + let store = GatewayVersionStore::install( + &distributed::graphql::GraphqlPool::from(fixture.repository.pool().clone()), + "worker-test", + ["causal_query_views".into()], + ) + .await + .unwrap(); + let engine = Arc::new( + GraphqlEngine::builder(&fixture.repository) + .service_id(SERVICE_ID) + .protocol_token_key(TEST_PROTOCOL_TOKEN_KEY) + .roles(&["user"]) + .anonymous_role("user") + .subscriptions(false) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .client_projectors([projector()]) + .gateway_versions(store.clone()) + .build() + .unwrap(), + ); + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let handler_gate = gate.clone(); + let blocking_gate = gate.clone(); + let pool = fixture.repository.pool().clone(); + let origin=Router::new().route("/graphql",post(move |axum::Json(value):axum::Json|{let engine=engine.clone();let gate=handler_gate.clone();async move{ + if value["extensions"]["gatewayDelivery"]["action"]=="snapshot"{gate.acquire().await.unwrap().forget();} + axum::Json(serde_json::to_value(engine.execute(&user_session(),serde_json::from_value::(value).unwrap()).await).unwrap()) + }})).route("/__metrics",get(move ||{let store=store.clone();async move{let metrics=store.metrics();axum::Json(json!({"validations":metrics.validations,"resultExecutions":metrics.result_executions}))}})) + .route("/__release",post(move ||{let gate=gate.clone();async move{gate.add_permits(1000);"released"}})) + .route("/__write",post(move ||{let pool=pool.clone();async move{sqlx::query("UPDATE causal_query_views SET title='external write'").execute(&pool).await.unwrap();"written"}})); + let origin = origin.route( + "/__block", + post(move || { + let gate = blocking_gate.clone(); + async move { + gate.forget_permits(gate.available_permits()); + "blocked" + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin_url = format!("http://{}", listener.local_addr().unwrap()); + struct Stop(tokio::task::JoinHandle<()>); + impl Drop for Stop { + fn drop(&mut self) { + self.0.abort(); + } + } + let _server = Stop(tokio::spawn(async move { + axum::serve(listener, origin).await.unwrap() + })); + let result = tokio::process::Command::new("node") + .arg("tests/gateway-worker/query-runtime.mjs") + .env("GATEWAY_ORIGIN", origin_url) + .kill_on_drop(true) + .output() + .await + .unwrap(); + println!("{}", String::from_utf8_lossy(&result.stdout)); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); +} + +#[cfg(all(feature = "gateway-delivery", feature = "gateway-graphql-native"))] +#[tokio::test] +#[ignore = "requires pinned local workerd fixture; run explicitly in Worker CI"] +async fn worker_live_coordinator_uses_actual_oidc_origin() { + use axum::Router; + use distributed::graphql::{delivery::GatewayVersionStore, IdentityConfig, OidcConfig}; + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use rsa::{pkcs1::EncodeRsaPrivateKey, traits::PublicKeyParts, RsaPrivateKey, RsaPublicKey}; + let private = RsaPrivateKey::new(&mut rand::thread_rng(), 2048).unwrap(); + let public = RsaPublicKey::from(&private); + let encoding = EncodingKey::from_rsa_pem( + private + .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF) + .unwrap() + .as_bytes(), + ) + .unwrap(); + let jwks=json!({"keys":[{"kty":"RSA","kid":"live-test","alg":"RS256","use":"sig", + "n":base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()),"e":base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.e().to_bytes_be())}]}).to_string(); + let token = |subject: &str| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("live-test".into()); + encode(&header,&json!({"iss":"https://live-fixture.invalid","aud":"live-fixture","sub":subject,"iat":now-1,"nbf":now-1,"exp":now+3600,"roles":["user"]}),&encoding).unwrap() + }; + + let fixture = protocol_fixture_with_retention(5).await; + let versions = GatewayVersionStore::install( + &distributed::graphql::GraphqlPool::from(fixture.repository.pool().clone()), + "worker-live-fixture", + ["causal_query_views".into()], + ) + .await + .unwrap(); + let oidc = OidcConfig::new("https://live-fixture.invalid", "live-fixture") + .with_static_jwks(jwks) + .engine_roles(&["user"]); + let engine = Arc::new( + GraphqlEngine::builder(&fixture.repository) + .service_id(SERVICE_ID) + .protocol_token_key(TEST_PROTOCOL_TOKEN_KEY) + .roles(&["user"]) + .identity(IdentityConfig::oidc_bearer(oidc)) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .client_projectors([projector()]) + .change_stream(fixture.repository.read_model_changes()) + .gateway_versions(versions.clone()) + .build() + .unwrap(), + ); + let router = distributed::graphql::graphql_router_composed(engine.clone(), None, None); + let repository = fixture.repository.clone(); + let bus = fixture.bus.clone(); + let control=Router::new().route("/__metrics",axum::routing::get(move ||{let engine=engine.clone();let versions=versions.clone();async move{let metrics=versions.metrics();axum::Json(json!({"producers":engine.live_subscriber_count(),"validations":metrics.validations,"resultExecutions":metrics.result_executions}))}})) + .route("/__commit",axum::routing::post(move ||{let repository=repository.clone();let bus=bus.clone();async move{project_item(&repository,&bus,2,"worker committed").await;"committed"}})); + let next_repository = fixture.repository.clone(); + let next_bus = fixture.bus.clone(); + let short_encoding = encoding.clone(); + let control=control.route("/__next/{position}",axum::routing::post(move |axum::extract::Path(position):axum::extract::Path|{let repository=next_repository.clone();let bus=next_bus.clone();async move{project_item(&repository,&bus,position,if position==23{"worker-22"}else{"worker update"}).await;"committed"}})) + .route("/__short_token",axum::routing::get(move ||{let encoding=short_encoding.clone();async move{ + let now=std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();let mut header=Header::new(Algorithm::RS256);header.kid=Some("live-test".into()); + axum::Json(json!({"token":encode(&header,&json!({"iss":"https://live-fixture.invalid","aud":"live-fixture","sub":"alice","iat":now-1,"nbf":now-1,"exp":now+3,"roles":["user"]}),&encoding).unwrap()})) + }})); + let router = router.merge(control); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin_url = format!("http://{}", listener.local_addr().unwrap()); + struct Stop(tokio::task::JoinHandle<()>); + impl Drop for Stop { + fn drop(&mut self) { + self.0.abort(); + } + } + let _server = Stop(tokio::spawn(async move { + axum::serve(listener, router).await.unwrap() + })); + let result = tokio::process::Command::new("node") + .arg("tests/gateway-worker/live-runtime.mjs") + .env("GATEWAY_ORIGIN", origin_url) + .env("GATEWAY_TOKEN_ALICE", token("alice")) + .env("GATEWAY_TOKEN_BOB", token("bob")) + .kill_on_drop(true) + .output() + .await + .unwrap(); + println!("{}", String::from_utf8_lossy(&result.stdout)); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); +} + +#[cfg(all(feature = "gateway-delivery", feature = "gateway-graphql-native"))] +mod load; diff --git a/tests/graphql_query_protocol_postgres/main.rs b/tests/graphql_query_protocol_postgres/main.rs index c7d7f141f..8b76bda23 100644 --- a/tests/graphql_query_protocol_postgres/main.rs +++ b/tests/graphql_query_protocol_postgres/main.rs @@ -18,7 +18,7 @@ use distributed::microsvc::{ CausalProjectorContext, HandlerError, Routes, Service, Session, ROLE_KEY, }; use distributed::projection_protocol::ProjectionChangeRetention; -use distributed::{ReadModelCatalog, PostgresRepository, ReadModel}; +use distributed::{PostgresRepository, ReadModel, ReadModelCatalog}; use futures_util::{stream::BoxStream, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; diff --git a/tests/graphql_sdl/main.rs b/tests/graphql_sdl/main.rs index b93e86000..e0fff7d71 100644 --- a/tests/graphql_sdl/main.rs +++ b/tests/graphql_sdl/main.rs @@ -26,6 +26,7 @@ fn players() -> TableSchema { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "weapons".into(), kind: RelationshipKind::HasMany, target_model: "PlayerWeaponView".into(), @@ -61,6 +62,7 @@ fn weapons() -> TableSchema { foreign_keys: vec![ForeignKey::new("players", "player_id")], indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "player".into(), kind: RelationshipKind::BelongsTo, target_model: "PlayerView".into(), @@ -150,6 +152,7 @@ fn m2m_requires_through_error() { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "tags".into(), kind: RelationshipKind::ManyToMany, target_model: "Tag".into(), diff --git a/tests/graphql_sqlite/main.rs b/tests/graphql_sqlite/main.rs index a599c4475..0df7c68d1 100644 --- a/tests/graphql_sqlite/main.rs +++ b/tests/graphql_sqlite/main.rs @@ -12,6 +12,71 @@ use distributed::{ use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePoolOptions; +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[table("boxed_accounts")] +struct BoxedAccount { + id: String, + #[readmodel(belongs_to = "BoxedProfile", foreign_key = "id")] + profile: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[table("boxed_profiles")] +struct BoxedProfile { + id: String, + owner_id: String, + #[readmodel(belongs_to = "BoxedAccount", foreign_key = "id")] + account: Option, +} + +#[tokio::test] +async fn boxed_singular_cycles_preserve_nested_graphql_permissions() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + for sql in [ + "CREATE TABLE boxed_accounts (id TEXT PRIMARY KEY)", + "CREATE TABLE boxed_profiles (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL)", + "INSERT INTO boxed_accounts VALUES ('a'), ('b')", + "INSERT INTO boxed_profiles VALUES ('a', 'alice'), ('b', 'bob')", + ] { + sqlx::query(sql).execute(&pool).await.unwrap(); + } + let engine = GraphqlEngine::builder(pool) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .model::( + ModelPermissions::new().grant( + "user", + read() + .all_columns() + .rows(col("owner_id").eq(distributed::graphql::claim("x-user-id"))), + ), + ) + .roles(&["user"]) + .build() + .unwrap(); + let response = engine + .execute( + &session_role("user", "alice"), + Request::new( + "{ boxed_accounts(order_by: [{ id: asc }]) { id profile { id account { id } } } }", + ), + ) + .await; + assert!(!response.is_err(), "{:?}", response.errors); + assert_eq!( + serde_json::to_value(response.data).unwrap(), + serde_json::json!({ + "boxed_accounts": [ + {"id": "a", "profile": {"id": "a", "account": {"id": "a"}}}, + {"id": "b", "profile": null} + ] + }) + ); +} + fn orders_schema() -> TableSchema { TableSchema { model_name: "OrderView".into(), @@ -260,6 +325,7 @@ fn parent_schema() -> TableSchema { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "children".into(), kind: RelationshipKind::HasMany, target_model: "ChildView".into(), @@ -488,6 +554,7 @@ fn post_schema() -> TableSchema { foreign_keys: Vec::new(), indexes: Vec::new(), relationships: vec![RelationshipDef { + references: None, field_name: "author".into(), kind: RelationshipKind::BelongsTo, target_model: "AuthorView".into(), @@ -540,8 +607,7 @@ async fn belongs_to_joins_source_fk_to_target_primary_key() { #[tokio::test] async fn permissions_filter_by_claim() { let schema = orders_schema(); - let manifest = - distributed::ReadModelCatalog::new("orders").table_schema(schema.clone()); + let manifest = distributed::ReadModelCatalog::new("orders").table_schema(schema.clone()); let pool = setup_pool().await; // Value-based path: grant_all then we need typed permission — use builder @@ -639,3 +705,92 @@ async fn domain_service_shaped_fixture() { assert_eq!(data["users"][0]["name"], "ada"); assert_eq!(data["orders"][0]["name"], "widget"); } + +#[cfg(feature = "gateway-delivery")] +#[tokio::test] +async fn gateway_dependency_inventory_includes_empty_relationship_filters() { + use distributed::graphql::{delivery::GatewayVersionStore, GraphqlPool}; + use serde_json::{json, Value}; + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + for sql in [ + "CREATE TABLE m2m_players (player_id TEXT PRIMARY KEY, name TEXT NOT NULL)", + "CREATE TABLE m2m_weapons (weapon_id TEXT PRIMARY KEY, name TEXT NOT NULL)", + "CREATE TABLE m2m_player_weapon_links (player_ref TEXT NOT NULL, weapon_ref TEXT NOT NULL)", + "INSERT INTO m2m_players VALUES ('p1', 'Ada')", + "INSERT INTO m2m_weapons VALUES ('w1', 'Compiler')", + ] { + sqlx::query(sql).execute(&pool).await.unwrap(); + } + let store = GatewayVersionStore::install( + &GraphqlPool::Sqlite(pool.clone()), + "relationship-test", + [ + "m2m_players".into(), + "m2m_weapons".into(), + "m2m_player_weapon_links".into(), + ], + ) + .await + .unwrap(); + let engine = GraphqlEngine::builder(pool.clone()) + .service_id("relationship-test") + .protocol_token_key([17; 32]) + .table_schema(m2m_link_schema()) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .roles(&["user"]) + .gateway_versions(store) + .build() + .unwrap(); + let document = r#"{ m2m_players(where: { weapons: { name: { _eq: "Compiler" } } }) { name } }"#; + let validate = || async { + let mut request = Request::new(document); + request.extensions.insert( + "gatewayDelivery".into(), + async_graphql::Value::from_json(json!({"action":"validate"})).unwrap(), + ); + let response: Value = + serde_json::to_value(engine.execute(&session_role("user", "u1"), request).await) + .unwrap(); + assert_eq!( + response["extensions"]["gatewayDelivery"]["eligible"], true, + "{response}" + ); + response["extensions"]["gatewayDelivery"]["admission"]["validator"].clone() + }; + let empty = validate().await; + sqlx::query("INSERT INTO m2m_player_weapon_links VALUES ('p1','w1')") + .execute(&pool) + .await + .unwrap(); + let linked = validate().await; + assert_ne!(linked, empty); + let rows = engine + .execute(&session_role("user", "u1"), Request::new(document)) + .await; + assert_eq!( + serde_json::to_value(rows.data).unwrap()["m2m_players"], + json!([{"name":"Ada"}]) + ); + sqlx::query("UPDATE m2m_weapons SET name='Debugger'") + .execute(&pool) + .await + .unwrap(); + let changed_target = validate().await; + assert_ne!(changed_target, linked); + sqlx::query("DELETE FROM m2m_player_weapon_links") + .execute(&pool) + .await + .unwrap(); + assert_ne!(validate().await, changed_target); + let response = engine + .execute(&session_role("user", "u1"), Request::new(document)) + .await; + assert_eq!( + serde_json::to_value(response.data).unwrap()["m2m_players"], + json!([]) + ); +} diff --git a/tests/graphql_subscriptions_sqlite/main.rs b/tests/graphql_subscriptions_sqlite/main.rs index 266cac4b4..53ca427e4 100644 --- a/tests/graphql_subscriptions_sqlite/main.rs +++ b/tests/graphql_subscriptions_sqlite/main.rs @@ -62,8 +62,7 @@ async fn setup_fixed() -> ( let change_rx = repo.read_model_changes(); - let manifest = - distributed::ReadModelCatalog::new("items").table_schema(items_schema()); + let manifest = distributed::ReadModelCatalog::new("items").table_schema(items_schema()); let engine = GraphqlEngine::from_schema_catalog(&manifest, pool.clone()) .unwrap() .roles(&["user"]) diff --git a/tests/graphql_unique_key_live/main.rs b/tests/graphql_unique_key_live/main.rs new file mode 100644 index 000000000..683548dd9 --- /dev/null +++ b/tests/graphql_unique_key_live/main.rs @@ -0,0 +1,251 @@ +#![cfg(all(feature = "graphql", feature = "sqlite"))] + +use distributed::bus::{Bus, InMemoryBus, Message, MessageKind, RunOptions}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions, SurfaceProjector}; +use distributed::microsvc::{CausalProjectorContext, HandlerError, Routes, Service}; +use distributed::{ReadModel, ReadModelCatalog, SqliteRepository}; +use distributed_cli::{compile_client, ClientCompileInput, ClientDocument, ClientSurfaceSelector}; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "revision_views", primary_key = ["id"])] +#[unique(columns = ["namespace", "revision"])] +struct RevisionView { + id: String, + namespace: String, + revision: String, + body: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "reference_views", primary_key = ["id"])] +struct ReferenceView { + id: String, + namespace: String, + revision: String, + #[readmodel( + belongs_to = "RevisionView", + foreign_key = "namespace,revision", + references = "namespace,revision" + )] + target: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct RevisionPublished { + id: String, + namespace: String, + revision: String, + body: String, +} + +fn projector() -> SurfaceProjector { + SurfaceProjector::new("publish_revision") + .facts(["revision.published"]) + .models(["ReferenceView", "RevisionView"]) + .change_epoch("revision-test-v1") +} + +async fn publish(repository: &SqliteRepository, bus: &InMemoryBus, revision: &str) { + let routes = Routes::new() + .with_read_model_store(repository.clone()) + .causal_projector::(projector()) + .model::() + .model::() + .handle( + |context: CausalProjectorContext, fact: RevisionPublished| async move { + context + .project(&RevisionView { + id: fact.id, + namespace: fact.namespace.clone(), + revision: fact.revision.clone(), + body: fact.body, + }) + .await?; + context + .project(&ReferenceView { + id: "reference-stable".into(), + namespace: fact.namespace, + revision: fact.revision, + target: None, + }) + .await?; + Ok::<(), HandlerError>(()) + }, + ); + bus.publish_message( + Message::new( + "revision.published", + MessageKind::Event, + serde_json::to_vec(&RevisionPublished { + id: format!("opaque-{revision}"), + namespace: "team-a".into(), + revision: revision.into(), + body: format!("Content {revision}"), + }) + .unwrap(), + ) + .with_id(format!("published-{revision}")) + .with_metadata( + distributed::trace_context::CAUSATION_ID, + format!("publish-command-{revision}"), + ), + ) + .await + .unwrap(); + Service::new() + .named("unique-key-live") + .routes(routes) + .with_bus(bus.clone()) + .run(RunOptions::idempotent()) + .await + .unwrap(); +} + +async fn fixture() -> ( + SqliteRepository, + InMemoryBus, + GraphqlEngine, + distributed_cli::GeneratedClientProject, +) { + let repository = SqliteRepository::connect_and_migrate("sqlite::memory:") + .await + .unwrap(); + let catalog = ReadModelCatalog::new("unique-key-live") + .read_model::() + .read_model::(); + repository + .bootstrap_table_schema_for_dev(&catalog.table_registry().unwrap()) + .await + .unwrap(); + let bus = InMemoryBus::new(); + publish(&repository, &bus, "one").await; + let engine = GraphqlEngine::builder(&repository) + .service_id("unique-key-live") + .protocol_token_key([0x37; 32]) + .roles(&["user"]) + .anonymous_role("user") + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .client_projectors([projector()]) + .change_stream(repository.read_model_changes()) + .build() + .unwrap(); + let generated = compile_client(ClientCompileInput::new( + serde_json::to_value(engine.client_manifest_for_role("user").unwrap()).unwrap(), + ClientSurfaceSelector::role("user"), + vec![ClientDocument::new( + "src/routes/references/+page.graphql", + "query References @load @live { reference_views { id target { id body } } }", + )], + )) + .unwrap(); + assert_eq!(generated.operations.len(), 1); + assert!(generated.operations[0].live_operation_hash.is_some()); + (repository, bus, engine, generated) +} + +#[tokio::test] +async fn projected_candidate_keys_generate_a_live_client() { + let (repository, bus, engine, _) = fixture().await; + let mut session = distributed::microsvc::Session::new(); + session.set(distributed::microsvc::ROLE_KEY, "user"); + let result = engine + .execute( + &session, + async_graphql::Request::new("{ reference_views { id target { id body } } }"), + ) + .await; + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert_eq!( + result.data.into_json().unwrap(), + serde_json::json!({"reference_views": [ + {"id": "reference-stable", "target": {"id": "opaque-one", "body": "Content one"}} + ]}) + ); + let mut live = Box::pin(engine.execute_stream( + &session, + async_graphql::Request::new("subscription { reference_views { id target { id body } } }"), + )); + let first = tokio::time::timeout(std::time::Duration::from_secs(3), live.next()) + .await + .unwrap() + .unwrap(); + assert!(first.errors.is_empty(), "{:?}", first.errors); + publish(&repository, &bus, "two").await; + let next = tokio::time::timeout(std::time::Duration::from_secs(3), live.next()) + .await + .unwrap() + .unwrap(); + assert!(next.errors.is_empty(), "{:?}", next.errors); + assert_eq!( + next.data.into_json().unwrap(), + serde_json::json!({"reference_views": [ + {"id": "reference-stable", "target": {"id": "opaque-two", "body": "Content two"}} + ]}) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires built JS and Playwright Chromium; explicitly run by browser CI"] +async fn projected_candidate_keys_reach_chromium_over_http_and_websocket() { + use axum::{ + routing::{get, post}, + Json, + }; + let (repository, bus, engine, generated) = fixture().await; + let operation = &generated.operations[0]; + let module = generated + .files + .iter() + .find(|file| file.path == operation.module_path) + .unwrap() + .contents + .clone(); + let export_name = operation.export_name.clone(); + let app = distributed::microsvc::router(std::sync::Arc::new( + Service::new() + .named("unique-key-live") + .try_with_graphql(engine) + .unwrap(), + )) + .route( + "/references", + get(|| async { axum::response::Html("") }), + ) + .route( + "/fixture", + get(move || { + let module = module.clone(); + let export_name = export_name.clone(); + async move { Json(serde_json::json!({"module": module, "exportName": export_name})) } + }), + ) + .route( + "/publish", + post(move || { + let repository = repository.clone(); + let bus = bus.clone(); + async move { + publish(&repository, &bus, "two").await; + Json(serde_json::json!({"ok": true})) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let status = tokio::task::spawn_blocking(move || { + std::process::Command::new("node") + .arg("tests/e2e-ui/scripts/unique-key-connected.mjs") + .arg(origin) + .status() + }) + .await + .unwrap(); + server.abort(); + assert!(status.unwrap().success(), "connected browser proof failed"); +} diff --git a/tests/legacy_authoring_absence.rs b/tests/legacy_authoring_absence.rs index 4e23bfef9..47f573879 100644 --- a/tests/legacy_authoring_absence.rs +++ b/tests/legacy_authoring_absence.rs @@ -100,6 +100,7 @@ fn projection_read_model_workspace_is_gone() { #[test] fn dead_effects_authoring_types_are_not_publicly_reexported() { let graphql_mod = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/graphql/mod.rs")); + let command_mod = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/command/mod.rs")); for forbidden in [ "CompiledCommandEffects", "CompiledConfirmationPlan", @@ -109,8 +110,8 @@ fn dead_effects_authoring_types_are_not_publicly_reexported() { "__effect_patch", ] { assert!( - !graphql_mod.contains(forbidden), - "{forbidden} must not be re-exported from graphql::" + !graphql_mod.contains(forbidden) && !command_mod.contains(forbidden), + "{forbidden} must not be re-exported from command:: or graphql::" ); } } @@ -119,7 +120,7 @@ fn dead_effects_authoring_types_are_not_publicly_reexported() { fn typed_command_has_no_public_effects_or_confirmations_builder() { let typed = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/src/graphql/command_contract/typed_command.rs" + "/src/command/typed_command.rs" )); assert!( !typed.contains("pub fn effects("), diff --git a/tests/metrics_exposition/main.rs b/tests/metrics_exposition/main.rs index c28c48d61..d8cf988ae 100644 --- a/tests/metrics_exposition/main.rs +++ b/tests/metrics_exposition/main.rs @@ -45,12 +45,15 @@ impl MessagePublisher for SelectivePublisher { async fn spawn_http_service(name: &str) -> String { let service = Arc::new( - Service::new().named(name).with_http_command_routes().routes( - Routes::new() - .with_dependencies(()) - .command("orders.create") - .handle(|_ctx: &Context<()>| async move { Ok(json!({"ok": true})) }), - ), + Service::new() + .named(name) + .with_http_command_routes() + .routes( + Routes::new() + .with_dependencies(()) + .command("orders.create") + .handle(|_ctx: &Context<()>| async move { Ok(json!({"ok": true})) }), + ), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/tests/microsvc/transport_http.rs b/tests/microsvc/transport_http.rs index 823fccc2a..e4811c31d 100644 --- a/tests/microsvc/transport_http.rs +++ b/tests/microsvc/transport_http.rs @@ -12,12 +12,16 @@ use crate::handlers; use crate::models::counter::Counter; fn counter_service() -> Arc { - Arc::new(Service::new().with_http_command_routes().routes(distributed::routes!( - Routes::new().with_repo(InMemoryRepository::new().queued().aggregate::()), - command handlers::counter_create, - command handlers::counter_increment, - command handlers::whoami, - ))) + Arc::new( + Service::new() + .with_http_command_routes() + .routes(distributed::routes!( + Routes::new().with_repo(InMemoryRepository::new().queued().aggregate::()), + command handlers::counter_create, + command handlers::counter_increment, + command handlers::whoami, + )), + ) } /// Bind to port 0 and return the actual address. diff --git a/tests/nats_transport/main.rs b/tests/nats_transport/main.rs index d434fa95f..9b6c293b5 100644 --- a/tests/nats_transport/main.rs +++ b/tests/nats_transport/main.rs @@ -28,6 +28,103 @@ fn nats_url() -> Option { env_support::broker_env("NATS_URL", "nats transport test") } +#[tokio::test] +async fn derived_facts_round_trip_after_interrupted_publish_prefix() { + let Some(url) = nats_url() else { return }; + #[derive(serde::Serialize, distributed::DomainEvent)] + #[domain_event(name = "document.indexed", version = 1)] + struct Indexed { + document_id: String, + words: u64, + } + let mut entity = distributed::Entity::with_id("document-1"); + entity.set_causation_id("0190a000-0000-7000-8000-000000000094"); + entity.digest("document.uploaded", &()).unwrap(); + entity + .capture_domain_event( + "document", + &Indexed { + document_id: "one".into(), + words: 0, + }, + ) + .unwrap(); + let parent = entity.pending_domain_events()[0].clone(); + let outputs = || { + ["one", "two"].map(|key| { + parent + .derive( + "indexer", + key, + &Indexed { + document_id: key.into(), + words: 10, + }, + ) + .unwrap() + }) + }; + let initial = outputs(); + let retry = outputs(); + assert_eq!(initial, retry); + let subject = unique("derived.document.indexed"); + let source = NatsJetStreamSource::connect( + &url, + &unique("STREAM"), + vec![subject.clone()], + &unique("consumer"), + ) + .await + .unwrap() + .with_fetch_timeout(Duration::from_millis(800)); + let publisher = NatsPublisher::connect(&url).await.unwrap(); + // Simulate stopping after one accepted output. A new attempt regenerates + // both facts; JetStream receives the repeated stable message ID. + for output in [&initial[0], &retry[0], &retry[1]] { + publisher + .publish( + Message::new( + &subject, + MessageKind::Event, + output.canonical_bytes().unwrap(), + ) + .with_id(output.id()) + .with_metadata( + distributed::trace_context::CAUSATION_ID, + output.causation_id().unwrap(), + ), + ) + .await + .unwrap(); + } + let seen = Arc::new(Mutex::new(Vec::new())); + let captured = seen.clone(); + let service = Arc::new( + Service::new().routes( + Routes::new() + .with_dependencies(()) + .event(Box::leak(subject.into_boxed_str())) + .handle(move |ctx: &Context<()>| { + let decoded = distributed::DomainEventOccurrence::from_canonical_bytes( + ctx.message().payload(), + ) + .unwrap(); + assert_eq!(ctx.message().id(), Some(decoded.id())); + assert_eq!(ctx.message().causation_id(), decoded.causation_id()); + captured.lock().unwrap().push(decoded); + async { Ok(json!({})) } + }), + ), + ); + run_source(service, source, RunOptions::idempotent()) + .await + .unwrap(); + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 2, "broker deduplicates the accepted prefix"); + assert_eq!(seen[0], initial[0]); + assert_eq!(seen[1], initial[1]); +} + #[tokio::test] async fn publish_then_consume_round_trips_through_jetstream() { let Some(url) = nats_url() else { return }; @@ -105,6 +202,8 @@ async fn message_id_and_metadata_survive_the_round_trip() { TRACEPARENT, "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", ); + let mut message = message; + message.content_type = "application/vnd.example+binary".into(); publisher.publish(message).await.expect("publish"); let observed = Arc::new(Mutex::new(None)); @@ -121,6 +220,7 @@ async fn message_id_and_metadata_survive_the_round_trip() { m.correlation_id().map(str::to_string), m.traceparent().map(str::to_string), m.payload().to_vec(), + m.content_type.clone(), )); *o.lock().unwrap() = recorded; async move { Ok(json!({})) } @@ -139,6 +239,7 @@ async fn message_id_and_metadata_survive_the_round_trip() { Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") ); assert_eq!(got.3, br#"{"k":"v"}"#.to_vec()); + assert_eq!(got.4, "application/vnd.example+binary"); } /// Build a namespaced `NatsBus` for `group` (empty `group` = no group), with diff --git a/tests/persistent_repository_conformance/outbox.rs b/tests/persistent_repository_conformance/outbox.rs index 593d0a635..b993cc8d9 100644 --- a/tests/persistent_repository_conformance/outbox.rs +++ b/tests/persistent_repository_conformance/outbox.rs @@ -223,10 +223,9 @@ where .complete(&claim) .await .expect("owning worker should complete the claim"); - let published = find_outbox_by_id(&outbox, &complete_message_id) + assert!(find_outbox_by_id(&outbox, &complete_message_id) .await - .expect("completed message should still be queryable"); - assert_eq!(published.status, OutboxMessageStatus::Published); + .is_none()); let retry_message_id = unique_id("retry-outbox"); commit_outbox_for_seat( @@ -375,20 +374,15 @@ where .await .expect("batched complete should settle every active claim"); for message_id in &message_ids { - let published = find_outbox_by_id(&outbox, message_id) - .await - .expect("completed message should still be queryable"); - assert_eq!(published.status, OutboxMessageStatus::Published); - assert_eq!(published.worker_id, None); + assert!(find_outbox_by_id(&outbox, message_id).await.is_none()); } - // Re-settling the now-published rows is a stale batch: same - // InvalidState surface as a serial `complete` of a settled row. + // Re-settling deleted delivery rows is NotFound, as with serial completion. let stale_err = outbox .complete_many(&claims) .await .expect_err("stale batch should not complete again"); - assert!(matches!(stale_err, RepositoryError::InvalidState { .. })); + assert!(matches!(stale_err, RepositoryError::NotFound { .. })); // An empty batch is a no-op, not an error. outbox @@ -461,14 +455,7 @@ where .complete(&claim_b) .await .expect("the reclaiming worker should complete the row"); - let published = find_outbox_by_id(&outbox, &message_id) - .await - .expect("reclaimed message should still be queryable"); - assert_eq!( - published.status, - OutboxMessageStatus::Published, - "row is published by the worker that reclaimed it, not by the crashed one" - ); + assert!(find_outbox_by_id(&outbox, &message_id).await.is_none()); } pub async fn publish_failure_after_commit_retains_outbox_row_until_delivered( @@ -526,14 +513,7 @@ pub async fn publish_failure_after_commit_retains_outbox_row_until_delivered, + #[readmodel( + belongs_to = "DirectTableView", + foreign_key = "target_tenant,target_slug", + references = "tenant_id,slug" + )] + target: Option, +} + +#[test] +fn authored_candidate_key_relationship_preserves_surrogate_identity() { + let source = DirectTableReference::schema(); + let target = DirectTableView::schema(); + let relation = &source.relationships[0]; + assert_eq!(relation.references.as_deref(), Some("tenant_id,slug")); + assert_eq!(target.primary_key.columns, ["direct_id"]); + let pairs = distributed::table::resolve_direct_join_keys(source, relation, target).unwrap(); + assert_eq!( + pairs, + vec![ + distributed::table::DirectJoinPair::new("target_tenant", "tenant_id"), + distributed::table::DirectJoinPair::new("target_slug", "slug"), + ] + ); +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] #[table("binary_assets")] struct BinaryAsset { diff --git a/tests/read_model_relationship_includes/main.rs b/tests/read_model_relationship_includes/main.rs index a435d1a27..9cd095ba4 100644 --- a/tests/read_model_relationship_includes/main.rs +++ b/tests/read_model_relationship_includes/main.rs @@ -10,6 +10,65 @@ use distributed::{ }; use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +struct Account { + id: String, + #[readmodel(belongs_to = "Profile", foreign_key = "id")] + profile: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +struct Profile { + id: String, + #[readmodel(belongs_to = "Account", foreign_key = "id")] + account: Option, +} + +#[test] +fn cyclic_singular_relationships_hydrate_and_serialize_without_box_metadata() { + use distributed::{RelationalReadModel, RelationalReadModelIncludes}; + let mut account = Account { + id: "a".into(), + profile: None, + }; + let profile = Profile { + id: "a".into(), + account: None, + }; + let row = profile.to_row().unwrap(); + account + .hydrate_include("profile", vec![row.clone()]) + .unwrap(); + assert_eq!(account.profile.as_deref(), Some(&profile)); + assert_eq!(account.include_rows("profile").unwrap(), vec![row.clone()]); + assert_eq!( + Account::include_target_schema("profile").unwrap(), + Profile::schema() + ); + assert!(!Account::schema() + .columns + .iter() + .any(|column| column.field_name == "profile")); + account.hydrate_include("profile", vec![]).unwrap(); + assert_eq!(account.profile, None); + assert!(account + .hydrate_include("profile", vec![row.clone(), row]) + .is_err()); + let mut profile = profile; + profile + .hydrate_include( + "account", + vec![Account { + id: "a".into(), + profile: None, + } + .to_row() + .unwrap()], + ) + .unwrap(); + assert_eq!(profile.account.as_ref().unwrap().id, "a"); +} + fn block_on(future: F) -> F::Output { use std::ptr; use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; diff --git a/tests/sourced/flat_preview.rs b/tests/sourced/flat_preview.rs new file mode 100644 index 000000000..0dafe7cc9 --- /dev/null +++ b/tests/sourced/flat_preview.rs @@ -0,0 +1,264 @@ +use distributed::{command::CommandEventSet, Entity}; +use serde_json::{json, Value}; + +#[derive(Default)] +struct Review { + entity: Entity, + status: String, +} + +#[distributed::sourced(entity, events = "ReviewEvent", aggregate_type = "review")] +impl Review { + pub fn approve(&mut self, id: String) -> distributed::SourcedResult { + self.record_status( + id, + "approved".into(), + true, + 25, + ::core::option::Option::Some(0.1), + ::core::option::Option::None, + )?; + Ok(()) + } + + pub fn reject(&mut self, id: String) -> distributed::SourcedResult { + self.record_status( + id, + "rejected".to_owned(), + false, + 0, + ::std::option::Option::None, + ::std::option::Option::Some('x'), + )?; + Ok(()) + } + + pub fn conflicting(&mut self, id: String, approve: bool) -> distributed::SourcedResult { + if approve { + self.record_status(id, "approved".into(), true, 1, None, None)?; + } else { + self.record_status(id, "rejected".into(), false, 1, None, None)?; + } + Ok(()) + } + + pub fn dynamic(&mut self, id: String, status: String) -> distributed::SourcedResult { + self.record_status(id.clone(), "approved".into(), true, 1, None, None)?; + self.record_status(id, status, false, 1, None, None)?; + Ok(()) + } + + pub fn executable(&mut self, id: String) -> distributed::SourcedResult { + self.record_status(id, never_execute(), true, 1, None, None)?; + Ok(()) + } + + pub fn shadowed_constructor(&mut self, id: String) -> distributed::SourcedResult { + #[allow(non_snake_case)] + fn Some(_score: f32) -> Option { + panic!("not an Option constructor") + } + self.record_status(id, "approved".into(), true, 1, Some(0.1), None)?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + #[event("review.status_recorded", version = 1, domain = event)] + fn record_status( + &mut self, + id: String, + status: String, + approved: bool, + count: u64, + score: Option, + mark: Option, + ) { + self.entity.set_id(id); + self.status = status; + let _ = (approved, count, score, mark); + } +} + +fn never_execute() -> String { + panic!("building metadata must not execute transition expressions") +} + +fn fields() -> Value { + let previews = serde_json::to_value(T::command_event_known_values()).unwrap(); + let mut fields = serde_json::Map::new(); + for preview in previews.as_array().unwrap() { + for field in preview["fields"].as_array().unwrap() { + fields.insert( + field["body_path"][0].as_str().unwrap().into(), + field["source"].clone(), + ); + } + } + Value::Object(fields) +} + +#[test] +fn flat_constants_match_recorded_body_without_running_the_command() { + let values = fields::(); + assert_eq!( + values["status"], + json!({"kind":"constant", "value":{"type":"string","value":"approved"}}) + ); + assert_eq!(values["approved"]["value"]["value"], true); + assert_eq!(values["count"]["value"]["value"], "25"); + assert!( + values.get("id").is_none(), + "server-computed/input identity is not a constant" + ); + let mut review = Review::default(); + review.approve("r1".into()).unwrap(); + let body: Value = review.entity.pending_domain_events()[0] + .decode_body() + .unwrap(); + for (name, source) in values.as_object().unwrap() { + if source["kind"] == "constant" { + let typed = &source["value"]; + let value = if matches!(typed["type"].as_str(), Some("u64" | "i64" | "f64")) { + serde_json::from_str(typed["value"].as_str().unwrap()).unwrap() + } else { + typed["value"].clone() + }; + assert_eq!(body[name], value, "wire value for {name}"); + } else { + assert_eq!(source["kind"], "null"); + assert!(body[name].is_null()); + } + } + let rejected = fields::(); + assert_eq!(rejected["status"]["value"]["value"], "rejected"); + assert_eq!(rejected["mark"]["value"]["value"], "x"); +} + +#[test] +fn conflicting_dynamic_and_executable_values_are_not_constants() { + for values in [ + fields::(), + fields::(), + ] { + assert!(values.get("status").is_none()); + assert!(values.get("approved").is_none()); + assert_eq!(values["count"]["value"]["value"], "1"); + } + assert!(fields::() + .get("status") + .is_none()); + let _never_call: fn(&mut Review, String) -> distributed::SourcedResult = Review::executable; + assert!(fields::() + .get("score") + .is_none()); + let _shadowed: fn(&mut Review, String) -> distributed::SourcedResult = + Review::shadowed_constructor; + // Exercise the commands too: inference does not change their real behavior. + let mut review = Review::default(); + review.conflicting("r1".into(), false).unwrap(); + review.dynamic("r1".into(), "custom".into()).unwrap(); + review.reject("r1".into()).unwrap(); + assert_eq!(review.status, "rejected"); +} + +#[cfg(feature = "graphql")] +mod client_contract { + use super::*; + use distributed::command::{typed_command, Eventual, PreparedCommand}; + use distributed::graphql::{ + build_surface, surface_for_role, ClientProjectionPreviewSource, ClientProjectionValue, + DistributedClientSurfaceExport, RoleGrant, SurfaceOptions, + }; + use distributed::microsvc::{CausalCommandContext, HandlerError, Routes, Service}; + use distributed::projection::lower::{EventualOnly, ProjectionDescriptor}; + use distributed::{ + AggregateRepository, InMemoryRepository, LocalProjectionMountsBuilder, Mutation, + RelationalReadModel, + }; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Default, Serialize, Deserialize, distributed::ReadModel)] + #[readmodel(table = "flat_reviews", primary_key = ["id"])] + struct FlatReviews { + id: String, + status: String, + } + + #[derive(Deserialize, distributed::CommandInput)] + struct Input { + id: String, + } + #[derive(Serialize, distributed::CommandOutput)] + struct Output { + id: String, + } + + #[allow(non_snake_case)] + fn SaveFlatReview() -> Mutation<()> { + distributed::mutation_file!("tests/fixtures/flat_review_save.graphql") + } + distributed::projection! { + const REVIEWS: ProjectionDescriptor = { + name: "flat_reviews", version: 1, epoch: "flat-reviews-v1", + model: FlatReviews, source: aggregate_snapshot, + on { events: [ReviewStatusRecordedDomainEvent], mutation: SaveFlatReview, + input: {review: body}, }, + }; + } + + async fn metadata_only( + _ctx: &CausalCommandContext<'_, Review>, + _input: Input, + ) -> Result>, HandlerError> { + let _ = _input.id; + panic!("manifest compilation must not execute a command") + } + + #[test] + fn flat_domain_constant_reaches_generated_projection_slots() { + let mounts = LocalProjectionMountsBuilder::new("reviews", "events") + .unwrap() + .eventual_model::("flat_reviews", REVIEWS, REVIEWS.epoch()) + .unwrap() + .build() + .unwrap(); + let service = Service::new().named("reviews").routes( + Routes::new() + .with_repo(AggregateRepository::<_, Review>::new( + InMemoryRepository::new(), + )) + .typed_command( + typed_command::>("review.approve") + .emits_events::(), + ) + .handle(metadata_only), + ); + let surface = build_surface( + &[FlatReviews::schema().clone()], + &SurfaceOptions::postgres(), + ) + .unwrap() + .with_projectors([mounts.projector("flat_reviews").unwrap()]) + .unwrap() + .with_service(&service) + .unwrap(); + let selected = surface_for_role( + &surface, + "anonymous", + &std::collections::BTreeMap::from([("FlatReviews".into(), RoleGrant::all_columns())]), + ) + .unwrap(); + let manifest = DistributedClientSurfaceExport::from_selected("reviews", selected) + .unwrap() + .manifest() + .unwrap(); + let projection = manifest.commands[0].extensions.projection.as_ref().unwrap(); + assert_eq!(projection.preview_occurrences.len(), 1); + assert!(projection.preview_occurrences[0].values.iter().any( + |field| matches!(&field.source, ClientProjectionPreviewSource::Constant { + value: ClientProjectionValue::String(status) } if status == "approved") + )); + assert!(projection.preview_occurrences[0].values.iter().any(|field| + matches!(&field.source, ClientProjectionPreviewSource::Input { path } if path == &["id"]))); + } +} diff --git a/tests/sourced/main.rs b/tests/sourced/main.rs index 64be90736..81744562b 100644 --- a/tests/sourced/main.rs +++ b/tests/sourced/main.rs @@ -1,5 +1,6 @@ mod aggregate; mod domain_events; +mod flat_preview; use aggregate::{Todo, TodoEvent}; use distributed::{ diff --git a/tests/sqlite_repository/main.rs b/tests/sqlite_repository/main.rs index 3a288f293..55e7fd3c9 100644 --- a/tests/sqlite_repository/main.rs +++ b/tests/sqlite_repository/main.rs @@ -201,7 +201,7 @@ async fn projected_command_ledger_rows_upgrade_to_atomic_without_schema_drift() .fetch_one(repo.pool()) .await .unwrap(); - assert_eq!(latest_version, 4); + assert_eq!(latest_version, 6); let created_at_type: String = sqlx::query_scalar( "SELECT typeof(created_at) FROM command_ledger WHERE service_id = 'service'", diff --git a/tests/todos/main.rs b/tests/todos/main.rs index 959b0e41a..985938d4e 100644 --- a/tests/todos/main.rs +++ b/tests/todos/main.rs @@ -68,7 +68,7 @@ fn dispatcher( ) } -async fn load_outbox_message(repo: &InMemoryRepository, id: &str) -> OutboxMessage { +async fn load_outbox_message(repo: &InMemoryRepository, id: &str) -> Option { let store = repo.outbox_store(); for status in [ OutboxMessageStatus::Pending, @@ -83,10 +83,10 @@ async fn load_outbox_message(repo: &InMemoryRepository, id: &str) -> OutboxMessa .into_iter() .find(|message| message.id() == id) { - return message; + return Some(message); } } - panic!("outbox message `{id}` should exist") + None } #[tokio::test] @@ -263,9 +263,8 @@ async fn outbox_dispatch_publishes_and_completes_committed_row() { assert_eq!(sent.len(), 1); assert_eq!(sent[0].name(), "todo.initialized"); - // Check record is marked as published - let published = load_outbox_message(&repo, &message_id).await; - assert!(published.is_published()); + // Delivered work is removed; publication evidence belongs to the bus. + assert!(load_outbox_message(&repo, &message_id).await.is_none()); } #[tokio::test] @@ -519,8 +518,7 @@ async fn outbox_dispatch_drains_one_row_at_a_time() { assert_eq!(processed, 3); for id in &message_ids { - let message = load_outbox_message(&repo, id).await; - assert!(message.is_published()); + assert!(load_outbox_message(&repo, id).await.is_none()); } assert_eq!( repo.outbox_store().pending(usize::MAX).await.unwrap().len(), diff --git a/tests/transport_conformance/mod.rs b/tests/transport_conformance/mod.rs index 9762a5214..e030536d0 100644 --- a/tests/transport_conformance/mod.rs +++ b/tests/transport_conformance/mod.rs @@ -385,10 +385,7 @@ pub async fn dispatcher_completes_only_after_publish_success() { dispatcher.publisher().published_ids(), vec!["evt-1".to_string()] ); - assert_eq!( - outbox_status(&repo, &id).await, - Some(OutboxMessageStatus::Published) - ); + assert_eq!(outbox_status(&repo, &id).await, None); } pub async fn dispatcher_unknown_outcome_stays_retryable() { @@ -421,10 +418,7 @@ pub async fn dispatcher_claims_explicit_ids_before_publish() { .unwrap(); assert_eq!(outcome.claimed, 1); assert_eq!(outcome.published, 1); - assert_eq!( - outbox_status(&repo, &wanted).await, - Some(OutboxMessageStatus::Published) - ); + assert_eq!(outbox_status(&repo, &wanted).await, None); // The unrequested row is untouched (claimed before publish, by id). assert_eq!( outbox_status(&repo, &other).await, @@ -575,7 +569,7 @@ pub async fn publish_then_crash_republishes_and_consumer_inbox_dedupes() { assert_eq!(outcome.published, 1, "the reclaimed row is republished"); assert_eq!( outbox_status(&producer, &message_id).await, - Some(OutboxMessageStatus::Published), + None, "the row completes only after the successful pass" ); diff --git a/tests/typed_commands/main.rs b/tests/typed_commands/main.rs index f0011eab6..626da1b27 100644 --- a/tests/typed_commands/main.rs +++ b/tests/typed_commands/main.rs @@ -9,14 +9,16 @@ use async_graphql::futures_util::StreamExt; use async_graphql::Request; use axum::body::Body; use axum::http::Request as HttpRequest; +use distributed::command::{ + typed_command, Atomic, CommandInputType, CommandOutputType, CommandTypeDef, CommandTypeField, + EffectInputFieldMarker, EffectModelFieldMarker, Eventual, PreparedCommand, Succeeded, +}; use distributed::graphql::{ - build_surface, graphql_router_with_service, read, surface_for_role, typed_command, Atomic, - ClientProjectionAssignment, ClientProjectionExecutionClass, ClientProjectionExpression, - ClientProjectionFallback, ClientProjectionInvalidation, ClientProjectionMutationKind, - ClientProjectionPartition, ClientProjectionPreviewSource, ClientProjectionValue, - ClientProjectionValueType, DistributedClientSurfaceExport, EffectInputFieldMarker, - EffectModelFieldMarker, Eventual, GraphqlEngine, GraphqlInputType, GraphqlOutputType, - GraphqlTypeDef, GraphqlTypeField, ModelPermissions, PreparedCommand, RoleGrant, Succeeded, + build_surface, graphql_router_with_service, read, surface_for_role, ClientProjectionAssignment, + ClientProjectionExecutionClass, ClientProjectionExpression, ClientProjectionFallback, + ClientProjectionInvalidation, ClientProjectionMutationKind, ClientProjectionPartition, + ClientProjectionPreviewSource, ClientProjectionValue, ClientProjectionValueType, + DistributedClientSurfaceExport, GraphqlEngine, ModelPermissions, RoleGrant, SurfaceDirectProjection, SurfaceModeledProjection, SurfaceOptions, SurfaceProjector, }; use distributed::microsvc::{CausalCommandContext, HandlerError, Routes, Service}; @@ -37,12 +39,12 @@ use distributed::{ body_bindings_for_model, body_field_binding, command_input_defaults, compile_projection, descriptor_from_factories, inventory_single_model, lower_single_model, resolve_mutation_program, state_upsert_program_for_model, Aggregate, AggregateRepository, - ReadModelCatalog, DomainEventDescriptor, DomainEventOccurrence, Entity, EventRecord, - GraphqlInput, GraphqlOutput, InMemoryRepository, MutationAssignment, MutationEventBinding, - MutationExpression, MutationField, MutationKeyField, MutationKind, MutationOperation, - MutationProgram, MutationProgramError, ProjectionExpression, ProjectionHandler, - ProjectionPartition, ProjectionProgram, ProjectionProgramError, ProjectionValue, - ProjectionValueType, ReadModel, RelationalReadModel, ResolvedProjectionPlan, SqliteRepository, + CommandInput, CommandOutput, DomainEventDescriptor, DomainEventOccurrence, Entity, EventRecord, + InMemoryRepository, MutationAssignment, MutationEventBinding, MutationExpression, + MutationField, MutationKeyField, MutationKind, MutationOperation, MutationProgram, + MutationProgramError, ProjectionExpression, ProjectionHandler, ProjectionPartition, + ProjectionProgram, ProjectionProgramError, ProjectionValue, ProjectionValueType, ReadModel, + ReadModelCatalog, RelationalReadModel, ResolvedProjectionPlan, SqliteRepository, }; use serde::{Deserialize, Serialize}; use tower::util::ServiceExt; @@ -100,19 +102,19 @@ struct OutputB { id: String, } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct PlanInput { id: String, title: String, } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct RenamedDefaultInput { #[serde(rename = "todoId")] id: String, } -#[derive(Serialize, GraphqlOutput)] +#[derive(Serialize, CommandOutput)] struct PlanOutput { id: String, } @@ -134,12 +136,12 @@ enum PlanStatus { Closed, } -impl GraphqlOutputType for PlanView { - fn graphql_type() -> GraphqlTypeDef { - GraphqlTypeDef::new( +impl CommandOutputType for PlanView { + fn command_type() -> CommandTypeDef { + CommandTypeDef::new( "PlanView", vec![ - GraphqlTypeField { + CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -147,7 +149,7 @@ impl GraphqlOutputType for PlanView { item_nullable: false, nested: None, }, - GraphqlTypeField { + CommandTypeField { name: "title".into(), type_name: "String".into(), nullable: false, @@ -155,7 +157,7 @@ impl GraphqlOutputType for PlanView { item_nullable: false, nested: None, }, - GraphqlTypeField { + CommandTypeField { name: "count".into(), type_name: "BigInt".into(), nullable: false, @@ -163,7 +165,7 @@ impl GraphqlOutputType for PlanView { item_nullable: false, nested: None, }, - GraphqlTypeField { + CommandTypeField { name: "status".into(), type_name: "String".into(), nullable: false, @@ -177,7 +179,7 @@ impl GraphqlOutputType for PlanView { } } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct ForgedInput { id: String, title: String, @@ -190,12 +192,12 @@ struct ForgedView { count: i64, } -#[derive(Clone, Serialize, Deserialize, GraphqlInput)] +#[derive(Clone, Serialize, Deserialize, CommandInput)] struct JsonDocument { label: String, } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct JsonPatchInput { id: String, tags: Vec, @@ -213,7 +215,7 @@ struct JsonView { details: JsonDocument, } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct BigIntKeyInput { key: i64, title: String, @@ -228,7 +230,7 @@ struct BigIntKeyView { title: String, } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct BigIntRelationshipInput { source_key: i64, target_id: String, @@ -254,7 +256,7 @@ struct BigIntRelationshipSource { targets: Vec, } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct NullableKeyInput { key: Option, } @@ -267,7 +269,7 @@ struct NullableKeyView { title: String, } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct CompositeKeyInput { tenant_id: String, id: String, @@ -286,7 +288,7 @@ struct CompositeKeyView { title: String, } -#[derive(Clone, Deserialize, GraphqlInput)] +#[derive(Clone, Deserialize, CommandInput)] struct FloatEffectInput { id: String, } @@ -971,9 +973,9 @@ impl EffectInputFieldMarker for ForgedTitleMarker { type Input = ForgedInput; type Value = String; type NonNullValue = String; - type Nullability = distributed::graphql::EffectRequired; - type PathKind = distributed::graphql::EffectInputTerminalKind; - type Wire = distributed::graphql::EffectWireString; + type Nullability = distributed::command::EffectRequired; + type PathKind = distributed::command::EffectInputTerminalKind; + type Wire = distributed::command::EffectWireString; type Nested = String; fn path() -> Vec<&'static str> { @@ -987,7 +989,7 @@ impl EffectModelFieldMarker for ForgedCountMarker { type Model = ForgedView; // Deliberately lies about the independently-derived SQL/GraphQL field. type Value = String; - type Wire = distributed::graphql::EffectWireString; + type Wire = distributed::command::EffectWireString; const FIELD: &'static str = "count"; } @@ -997,9 +999,9 @@ impl EffectInputFieldMarker for ForgedDefaultMarker { type Input = PlanInput; type Value = String; type NonNullValue = String; - type Nullability = distributed::graphql::EffectRequired; - type PathKind = distributed::graphql::EffectInputTerminalKind; - type Wire = distributed::graphql::EffectWireString; + type Nullability = distributed::command::EffectRequired; + type PathKind = distributed::command::EffectInputTerminalKind; + type Wire = distributed::command::EffectWireString; type Nested = String; fn path() -> Vec<&'static str> { @@ -1007,10 +1009,10 @@ impl EffectInputFieldMarker for ForgedDefaultMarker { } } -fn object_type(name: &str) -> GraphqlTypeDef { - GraphqlTypeDef::new( +fn object_type(name: &str) -> CommandTypeDef { + CommandTypeDef::new( name, - vec![GraphqlTypeField { + vec![CommandTypeField { name: "id".into(), type_name: "String".into(), nullable: false, @@ -1022,26 +1024,26 @@ fn object_type(name: &str) -> GraphqlTypeDef { .with_type_id(TypeId::of::()) } -impl GraphqlInputType for InputA { - fn graphql_type() -> GraphqlTypeDef { +impl CommandInputType for InputA { + fn command_type() -> CommandTypeDef { object_type::("CommandInput") } } -impl GraphqlOutputType for OutputA { - fn graphql_type() -> GraphqlTypeDef { +impl CommandOutputType for OutputA { + fn command_type() -> CommandTypeDef { object_type::("CommandOutput") } } -impl GraphqlInputType for InputB { - fn graphql_type() -> GraphqlTypeDef { +impl CommandInputType for InputB { + fn command_type() -> CommandTypeDef { object_type::("CommandInput") } } -impl GraphqlOutputType for OutputB { - fn graphql_type() -> GraphqlTypeDef { +impl CommandOutputType for OutputB { + fn command_type() -> CommandTypeDef { object_type::("CommandOutput") } } @@ -1216,16 +1218,16 @@ fn direct_plan_projection() -> SurfaceDirectProjection { .change_epoch("plan-direct-v1") } -fn plan_input_defaults() -> distributed::graphql::CompiledInputDefaults { +fn plan_input_defaults() -> distributed::command::CompiledInputDefaults { command_input_defaults! { input: PlanInput; default input.id = uuid_v7(); } } -fn forged_input_defaults() -> distributed::graphql::CompiledInputDefaults { - distributed::graphql::__command_input_defaults::([ - distributed::graphql::__input_default_uuid_v7::(), +fn forged_input_defaults() -> distributed::command::CompiledInputDefaults { + distributed::command::__command_input_defaults::([ + distributed::command::__input_default_uuid_v7::(), ]) } @@ -1628,8 +1630,7 @@ fn pool_free_typed_export_preserves_service_provenance_and_rejects_relabeling() assert_eq!(manifest.service_id, "plans"); assert_eq!(manifest.commands[0].name, "plan.create"); - let error = DistributedClientSurfaceExport::from_selected("other-plans", selected) - .unwrap_err(); + let error = DistributedClientSurfaceExport::from_selected("other-plans", selected).unwrap_err(); assert!(error .to_string() .contains("does not match typed Surface provenance"));