diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c2198b8d6..b7e5e67c1 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -16,7 +16,7 @@ existing unbounded-tech quality provider plus the integration/* jobs below — | [`integration-js.yaml`](./integration-js.yaml) | yes | **This repo:** install, typecheck, test, build, and packed-consumer smoke test for `js/` | | [`on-pr-quality.yaml`](./on-pr-quality.yaml) | entry | **This repo** PR gate (not the consumer quality contract) | | [`on-push-main-version-and-tag.yaml`](./on-push-main-version-and-tag.yaml) | entry | **This repo** main → **vnext** tag | -| [`on-v-tag-publish.yaml`](./on-v-tag-publish.yaml) | entry | **This repo** crates.io + npm + `dctl` binary release | +| [`on-v-tag-publish.yaml`](./on-v-tag-publish.yaml) | entry | **This repo** crates.io + npm + `distributed` binary release | **Version tagging (anywhere):** `unbounded-tech/workflow-vnext-tag` **GitHub Release only (domain crates):** `unbounded-tech/workflow-simple-release` @@ -136,4 +136,4 @@ quality contract. ## Out of scope (for now) - crates.io publish reusable (framework still uses `unbounded-tech` publish helpers in `on-v-tag-publish.yaml`) -- image / GitOps promote (service scaffolds via `dctl`) +- image / GitOps promote (service scaffolds via `distributed`) diff --git a/.github/workflows/integration-distributed-cli.yaml b/.github/workflows/integration-distributed-cli.yaml index 1cca78beb..fd48c4874 100644 --- a/.github/workflows/integration-distributed-cli.yaml +++ b/.github/workflows/integration-distributed-cli.yaml @@ -1,7 +1,7 @@ name: distributed_cli Integration Tests # Reusable workflow: referenced via `uses: ./.github/workflows/integration-distributed-cli.yaml`. -# Runs the `dctl` integration tests, including the `#[ignore]`d manifest-harness +# Runs the `distributed` integration tests, including the `#[ignore]`d manifest-harness # e2e tests (describe/schema) that compile a fixture service via nested cargo. on: workflow_call: diff --git a/.github/workflows/integration-e2e-ui.yaml b/.github/workflows/integration-e2e-ui.yaml index ec4b0ffc1..19cd5470a 100644 --- a/.github/workflows/integration-e2e-ui.yaml +++ b/.github/workflows/integration-e2e-ui.yaml @@ -28,12 +28,16 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: toolchain: stable + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: workspaces: tests/e2e-ui -> target shared-key: e2e-ui-offline + - name: Install wasm-pack + run: cargo install wasm-pack --locked || cargo install wasm-pack + - uses: actions/setup-node@v4 with: node-version: "22" @@ -62,12 +66,16 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: toolchain: stable + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: workspaces: tests/e2e-ui -> target shared-key: e2e-ui-browser + - name: Install wasm-pack + run: cargo install wasm-pack --locked || cargo install wasm-pack + - uses: actions/setup-node@v4 with: node-version: "22" diff --git a/.github/workflows/integration-observability.yaml b/.github/workflows/integration-observability.yaml index a6d5c37f7..9d98949c6 100644 --- a/.github/workflows/integration-observability.yaml +++ b/.github/workflows/integration-observability.yaml @@ -89,7 +89,7 @@ jobs: kubeconform -v - name: Scaffold service with metrics and tracing run: | - cargo run -p distributed_cli --bin dctl -- scaffold observability-orders \ + cargo run -p distributed_cli --bin distributed -- scaffold observability-orders \ --path target/tmp/scaffold-observability \ --store in-memory --transport http \ --metrics prometheus --tracing --gitops \ diff --git a/.github/workflows/on-pr-quality.yaml b/.github/workflows/on-pr-quality.yaml index 37c13ad4b..56061b40a 100644 --- a/.github/workflows/on-pr-quality.yaml +++ b/.github/workflows/on-pr-quality.yaml @@ -8,6 +8,30 @@ concurrency: cancel-in-progress: true jobs: + contracts: + name: Run fail-fast contract lifecycle check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Resolve PR merge-base + id: base + run: | + git fetch origin "${{ github.base_ref }}" --depth=1 + base=$(git merge-base HEAD "origin/${{ github.base_ref }}" || true) + echo "sha=${base}" >> "$GITHUB_OUTPUT" + echo "merge-base=${base}" + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Run contracts check + run: | + if [ -f contracts/catalog.json ]; then + cargo run -p distributed_cli --quiet -- contracts check --root . --catalog contracts/catalog.json --output json + else + echo "No contracts/catalog.json yet; gate is present and ready for producers." + fi + # Framework monorepo keeps its existing quality provider. The reusable # quality.yaml in this directory is the contract for *consumers* (domain # crates / Distributed libraries), not for this workspace's entry pipeline. @@ -34,16 +58,20 @@ jobs: uses: ./.github/workflows/integration-kafka.yaml distributed-cli: + needs: [contracts] uses: ./.github/workflows/integration-distributed-cli.yaml observability: uses: ./.github/workflows/integration-observability.yaml graphql: + needs: [contracts] uses: ./.github/workflows/integration-graphql.yaml e2e-ui: + needs: [contracts] uses: ./.github/workflows/integration-e2e-ui.yaml js-client: + needs: [contracts] uses: ./.github/workflows/integration-js.yaml diff --git a/.github/workflows/on-v-tag-publish.yaml b/.github/workflows/on-v-tag-publish.yaml index 7be814a03..28c35b710 100644 --- a/.github/workflows/on-v-tag-publish.yaml +++ b/.github/workflows/on-v-tag-publish.yaml @@ -40,7 +40,7 @@ jobs: manifest_path: distributed_macros/Cargo.toml cargo_publish_args: "--locked" - # distributed_cli (the `dctl` binary + generation library) has no internal + # distributed_cli (the `distributed` binary + generation library) has no internal # workspace dependencies, so it publishes independently of the macros/core crates. publish-cli: needs: release-preflight @@ -116,6 +116,6 @@ jobs: contents: write uses: unbounded-tech/workflows-rust/.github/workflows/release.yaml@v2.3.0 with: - binary_name: dctl + binary_name: distributed # Cargo.lock is intentionally untracked for this library workspace. - build_args: "--release --package distributed_cli --bin dctl" + build_args: "--release --package distributed_cli --bin distributed" diff --git a/.gitignore b/.gitignore index 3f13a57ae..619edf85f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ tests/workshop-service/ui/build/ tests/workshop-service/ui/.svelte-kit/ tests/workshop-service/**/target/ tests/workshop-service/*.db + +# fixture crate build artifacts +tests/fixtures/**/target/ diff --git a/Cargo.toml b/Cargo.toml index 715dd97ff..79c66361b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ required-features = ["graphql", "sqlite"] [features] default = [] +application-runtime = [] +runtime = ["application-runtime"] emitter = ["dep:event-emitter-rs"] metrics = [] http = ["dep:axum", "dep:reqwest", "dep:tokio"] @@ -48,6 +50,7 @@ graphql = ["dep:async-graphql", "dep:async-graphql-axum", "dep:hmac", "dep:jsonw [dependencies] async-nats = { version = "0.49", optional = true } +async-trait = "0.1" async-graphql = { version = "7", optional = true } async-graphql-axum = { version = "7", optional = true } axum = { version = "0.8", optional = true } @@ -77,6 +80,9 @@ tracing-opentelemetry = { version = "0.33", default-features = false, optional = uuid = { version = "1", features = ["v7"] } [build-dependencies] +serde = { version = "1.0.210", features = ["derive"] } +serde_json = "1.0.128" +sha2 = "0.10" tonic-build = { version = "0.14", default-features = false, features = ["transport"] } [dev-dependencies] diff --git a/Makefile b/Makefile index 6ae192f07..5262d84a7 100644 --- a/Makefile +++ b/Makefile @@ -43,3 +43,9 @@ compose-up: compose-down: $(DOCKER_COMPOSE) down $(COMPOSE_DOWN_FLAGS) + +.PHONY: contracts-check + +## Read-only aggregate contract lifecycle check (never writes tracked files). +contracts-check: + $(CARGO) run -p distributed_cli --quiet -- contracts check --root . --catalog contracts/catalog.json --output human diff --git a/README.md b/README.md index aafd80344..4538428fb 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ stack of deliberate files. Start here: │ │ │ @hops-ops/distributed ├── mutations → aggregates │ causal replica + commands ├── Atomic rows (blob) with events - │ dctl-generated ops └── projector rows (todos, chat) + │ distributed-generated ops └── projector rows (todos, chat) ``` JS package deep-dive: [`js/README.md`](js/README.md). @@ -93,7 +93,7 @@ e2e-ui boots **Zitadel** for the browser path; the three stacks prove the same | GraphQL query service | Filters, order, pagination, relationships, RBAC, live subs, causal mutations | | npm JS client | Artifacts, HTTP/WS transport, **causal replica**, diagnostics, SvelteKit/React | | microsvc | One handler inventory on HTTP, gRPC, bus, GraphQL, or direct dispatch | -| `dctl` | Scaffold, SQL/Atlas/SDL, **client-manifest / client** codegen | +| `distributed` | Scaffold, SQL/Atlas/SDL, **client-manifest / client** codegen | ## Use as a Dependency @@ -184,7 +184,7 @@ Most application crates should depend on `distributed` only. The proc macros (`#[sourced]`, `#[digest]`, `#[derive(ReadModel)]`, `#[derive(Snapshot)]`) are re-exported from `distributed`; do not add `distributed_macros` directly unless you are working on the macro crate itself. The `distributed_cli` crate installs -the `dctl` tooling and is not needed as a runtime dependency unless you are +the `distributed` tooling and is not needed as a runtime dependency unless you are embedding the CLI in another command such as `hops service`. ## Quick Start (library) @@ -1515,7 +1515,7 @@ let loaded = repo - **Internal loads:** PK-anchored includes — `store.workspace().load(...).include(...).one()` (one-level, opt-in). - **Schema lifecycle:** `ReadModelSchemaRegistry` + adapter for migration artifacts - and startup verification; `dctl schema` / `distributed_manifest()` for SQL. + and startup verification; `distributed schema` / `read_model_catalog()` for SQL. - **Non-goals:** public query APIs belong on the GraphQL layer below (not the ORM include loader); do not write projections outside the projection path. @@ -1531,7 +1531,7 @@ This is the public query/command edge for full-stack apps. The companion TypeScript package [`@hops-ops/distributed`](js/) (see [`js/README.md`](js/README.md)) supplies transport, a normalized causal replica, command runtime, diagnostics, and SvelteKit/React adapters. End-to-end template: -[`tests/e2e-ui/`](tests/e2e-ui/). Scaffold with `dctl scaffold … --query-api`. +[`tests/e2e-ui/`](tests/e2e-ui/). Scaffold with `distributed scaffold … --query-api`. Example playground: `cargo run --example graphiql --features "graphql,sqlite"`. ### Enable @@ -1546,7 +1546,7 @@ distributed = { version = "0.1", features = ["graphql", "postgres"] } `graphql` implies `http` (Axum router, including `/graphql/ws`). SDL helpers under `distributed::graphql::{naming,sdl}` compile without the feature so -`dctl schema --format graphql` works in tooling crates. +`distributed schema --format graphql` works in tooling crates. ### Scope @@ -1591,7 +1591,7 @@ let service = Service::new() // Optional: keep commands on GraphQL/bus/direct dispatch only. .without_http_command_routes(); -let engine = GraphqlEngine::from_manifest(&manifest, &repository)? +let engine = GraphqlEngine::from_schema_catalog(&manifest, &repository)? // This exact executable inventory is the only mutation source. .service(&service) // Stable nonzero deployment secret shared by replicas of this endpoint. @@ -1704,14 +1704,14 @@ GraphiQL is a **developer** tool. Default headers in the playground trust ```bash # Optional human-readable GraphQL SDL artifact -dctl schema --format graphql --out schema.graphql +distributed schema --format graphql --out schema.graphql git diff --exit-code schema.graphql # drift gate ``` The Rust `Service` inventory and GraphQL `Surface` IR are the source of truth for schema, authorization, commands, optimistic effects, and client artifacts. -`dctl client-manifest` exports one role or named application surface, and -`dctl client` compiles that manifest with co-located `.graphql` operations into +`distributed client-manifest` exports one role or named application surface, and +`distributed client` compiles that manifest with co-located `.graphql` operations into typed query/live/command modules. Common and elevated applications use separate manifest entrypoints, document sets, generated directories, virtual modules, and request-local replicas; an admin superset is never bundled into the common @@ -1767,8 +1767,8 @@ make check-client # generated user/admin clients are current Generate app clients from the Rust surface: ```bash -dctl client-manifest … # export role/app surface IR -dctl client … # compile co-located .graphql → typed modules +distributed client-manifest … # export role/app surface IR +distributed client … # compile co-located .graphql → typed modules ``` See [`js/README.md`](js/README.md) for package API and packaging. @@ -1934,17 +1934,18 @@ A v1 event automatically chains through v1→v2→v3; a v2 event only goes throu - **No stored data modified**: Upcasters are read-time transformations. - **Zero overhead when unused**: Aggregates with no upcasters take the fast hydration path. -## Service CLI (`dctl`) +## Service CLI (`distributed`) -The [`distributed_cli`](distributed_cli/) crate ships `dctl` — tooling to scaffold -services, inspect a service's project manifest, and render schema artifacts. It is +The [`distributed_cli`](distributed_cli/) crate ships `distributed` — tooling to scaffold +services, inspect a service's logical application artifact, and render physical +read-model schema artifacts. It is also a library, so `hops` mounts the same commands under `hops service` (anything -below as `dctl ` works as `hops service `). +below as `distributed ` works as `hops service `). The CLI exists to keep the generated and handwritten parts of a back-end service separate. A Distributed service should usually reduce to a small custom surface: aggregate models, command/event handlers, read models, and the occasional -handwritten integration. The framework, macros, manifest, and CLI generate the +handwritten integration. The framework, macros, application artifacts, and CLI generate the repeatable wiring around that surface. That boundary matters for AI-assisted development. AI generation is @@ -1957,9 +1958,9 @@ shapes. Boilerplate service setup, manifest discovery, schema output, and GitOps artifacts stay deterministic. ```bash -cargo install distributed_cli # installs `dctl` +cargo install distributed_cli # installs `distributed` -dctl scaffold orders \ +distributed scaffold orders \ --model order \ --read-models \ --command order.submit \ @@ -1972,8 +1973,8 @@ dctl scaffold orders \ cd orders cargo test -dctl describe # print the project manifest as JSON -dctl schema --dialect postgres # render migration SQL from read models +distributed describe # print the ApplicationManifest as JSON +distributed schema --dialect postgres # render migration SQL from read models ``` Use the event-storming board as the input: @@ -2004,26 +2005,28 @@ a **private** listener — unauthenticated by design. applicable. Do **not** label metrics with `user_id`, `tenant_id`, free-form paths, or raw command input (unknown commands bucket as `message=unknown`). -`describe`/`schema` compile your crate and call its `distributed_manifest()` -entrypoint (override with `--entrypoint`), which registers the [read -models](#read-models) and tables that define the schema: +`describe`/`schema` compile your crate and call explicit artifact entrypoints +(override with `--entrypoint`). `describe` reads the logical +`application_manifest()` owner; `schema` reads the separate +`read_model_catalog()` owner that registers the [read models](#read-models) and +tables defining physical schema: ```rust,ignore -pub fn distributed_manifest() -> distributed::DistributedProjectManifest { - distributed::DistributedProjectManifest::new("orders").read_model::() +pub fn read_model_catalog() -> distributed::ReadModelCatalog { + distributed::ReadModelCatalog::new("orders").read_model::() } ``` ### Apply schema in-cluster with Atlas -`dctl schema --format atlas` wraps the desired-state SQL into an `AtlasSchema` +`distributed schema --format atlas` wraps the desired-state SQL into an `AtlasSchema` (`db.atlasgo.io/v1alpha1`) for the [ariga atlas-operator](https://github.com/ariga/atlas-operator), so migrations apply declaratively in-cluster. The resource is written to **stdout** — redirect it wherever you keep schema manifests (a file, or a separate -GitOps repo); `dctl` does not choose a location for it. +GitOps repo); `distributed` does not choose a location for it. ```bash -dctl schema --format atlas --name orders --db-secret orders-db > orders.schema.yaml +distributed schema --format atlas --name orders --db-secret orders-db > orders.schema.yaml ``` Use `--db-secret`/`--db-secret-key` for a Secret reference (GitOps-friendly) or diff --git a/build.rs b/build.rs index eff542961..a0f3b91bb 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,79 @@ +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +const INVENTORY_PATH: &str = "migrations/inventory.json"; +const INVENTORY_VERSION: u32 = 1; +const MAX_INVENTORY_BYTES: usize = 1024 * 1024; +const MAX_SQL_BYTES: usize = 4 * 1024 * 1024; +const MAX_MIGRATIONS: usize = 256; +const MAX_JSON_DEPTH: usize = 24; +const MAX_TOTAL_ENTRIES: usize = MAX_MIGRATIONS * 4; +const MAX_TOP_LEVEL_ENTRIES: usize = 64; +const MAX_DIRECTORIES: usize = 4_096; +const MAX_SQL_FILES: usize = MAX_MIGRATIONS * 2; +const REDACTED_MIGRATION_PATH: &str = ""; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Inventory { + schema_version: u32, + migrations: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Migration { + version: u64, + description: String, + sqlite: MigrationFile, + postgres: MigrationFile, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MigrationFile { + path: String, + sha256: String, +} + +#[derive(Clone, Copy)] +enum Dialect { + Sqlite, + Postgres, +} + +impl Dialect { + const ALL: [Self; 2] = [Self::Sqlite, Self::Postgres]; + + const fn name(self) -> &'static str { + match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + } + } + + const fn directory(self) -> &'static str { + match self { + Self::Sqlite => "migrations/sqlite", + Self::Postgres => "migrations/postgres", + } + } + + fn file(self, migration: &Migration) -> &MigrationFile { + match self { + Self::Sqlite => &migration.sqlite, + Self::Postgres => &migration.postgres, + } + } +} + fn main() { + emit_migration_inventory(); + // Only run gRPC codegen when the "grpc" feature is enabled. // Cargo sets CARGO_FEATURE_GRPC when compiling with --features grpc. if std::env::var("CARGO_FEATURE_GRPC").is_ok() { @@ -28,3 +103,473 @@ fn main() { tonic_build::manual::Builder::new().compile(&[service]); } } + +fn emit_migration_inventory() { + println!("cargo:rerun-if-changed={INVENTORY_PATH}"); + for dialect in Dialect::ALL { + // Watching the dialect roots also notices an unregistered SQL file + // being added; the explicit file lines below keep declared inputs + // visible in Cargo's build explanation. + println!("cargo:rerun-if-changed={}", dialect.directory()); + } + let manifest_dir = PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR") + .expect("Cargo must provide CARGO_MANIFEST_DIR to the build script"), + ); + let root = fs::canonicalize(&manifest_dir) + .unwrap_or_else(|error| panic!("resolve repository root for migrations: {error}")); + let inventory_path = root.join(INVENTORY_PATH); + let bytes = read_bounded_file( + &root, + &inventory_path, + MAX_INVENTORY_BYTES, + "migration inventory", + ); + validate_json_nesting(&bytes).unwrap_or_else(|error| panic!("parse {INVENTORY_PATH}: {error}")); + let inventory: Inventory = serde_json::from_slice(&bytes) + .unwrap_or_else(|error| panic!("parse {INVENTORY_PATH}: {error}")); + validate_inventory(&root, &inventory); + + for migration in &inventory.migrations { + for dialect in Dialect::ALL { + println!("cargo:rerun-if-changed={}", dialect.file(migration).path); + } + } + + let out_dir = PathBuf::from( + std::env::var_os("OUT_DIR").expect("Cargo must provide OUT_DIR to the build script"), + ); + let generated_path = out_dir.join("migration_inventory.rs"); + let mut generated = + String::from("// Generated by build.rs from migrations/inventory.json; do not edit.\n"); + emit_dialect( + &mut generated, + "SQLITE_MIGRATIONS", + Dialect::Sqlite, + &inventory, + ); + emit_dialect( + &mut generated, + "POSTGRES_MIGRATIONS", + Dialect::Postgres, + &inventory, + ); + let mut file = File::create(&generated_path) + .unwrap_or_else(|error| panic!("create generated migration registration: {error}")); + file.write_all(generated.as_bytes()) + .unwrap_or_else(|error| panic!("write generated migration registration: {error}")); +} + +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"); + generated.push_str("pub(crate) const "); + generated.push_str(name); + generated.push_str(": &[EmbeddedMigration] = &[\n"); + for migration in &inventory.migrations { + let file = dialect.file(migration); + generated.push_str(" EmbeddedMigration { version: "); + generated.push_str(&migration.version.to_string()); + generated.push_str(", description: "); + generated.push_str(&format!("{:?}", migration.description)); + generated.push_str(", sql: include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/\", "); + generated.push_str(&format!("{:?}", file.path)); + generated.push_str(")), },\n"); + } + generated.push_str("];\n"); +} + +fn validate_inventory(root: &Path, inventory: &Inventory) { + if inventory.schema_version != INVENTORY_VERSION { + panic!( + "{INVENTORY_PATH} schema version {} is unsupported; expected {INVENTORY_VERSION}", + inventory.schema_version + ); + } + if inventory.migrations.is_empty() || inventory.migrations.len() > MAX_MIGRATIONS { + panic!("{INVENTORY_PATH} must contain 1..={MAX_MIGRATIONS} migrations"); + } + let mut paths = BTreeMap::new(); + for (index, migration) in inventory.migrations.iter().enumerate() { + let expected = (index + 1) as u64; + if migration.version != expected { + panic!( + "{INVENTORY_PATH} versions must be consecutive: expected {expected}, observed {}", + migration.version + ); + } + if migration.version > i64::MAX as u64 + || migration.description.is_empty() + || migration.description.trim() != migration.description + || migration.description.len() > 4 * 1024 + || migration.description.contains('\0') + || is_secret_like(&migration.description) + { + panic!( + "{INVENTORY_PATH} migration {} has an invalid description or version", + migration.version + ); + } + for dialect in Dialect::ALL { + let file = dialect.file(migration); + let display_path = declared_path_display(&file.path); + validate_path(root, dialect, file); + if file.sha256.len() != 64 + || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + || file + .sha256 + .chars() + .any(|character| character.is_ascii_uppercase()) + { + panic!( + "{INVENTORY_PATH} {} migration `{}` has an invalid SHA-256", + dialect.name(), + display_path + ); + } + if paths + .insert(file.path.clone(), (migration.version, dialect.name())) + .is_some() + { + panic!( + "{INVENTORY_PATH} migration path `{}` is declared more than once", + display_path + ); + } + let sql_path = root.join(&file.path); + let sql = read_bounded_file(root, &sql_path, MAX_SQL_BYTES, "migration SQL"); + if std::str::from_utf8(&sql).is_err() { + panic!("migration SQL `{display_path}` is not UTF-8"); + } + let observed = sha256_hex(&sql); + if observed != file.sha256 { + panic!( + "{INVENTORY_PATH} {} migration `{}` checksum mismatch: expected {}, observed {}", + dialect.name(), + display_path, + file.sha256, + observed + ); + } + } + } + for dialect in Dialect::ALL { + let actual = collect_sql_files(root, dialect); + for path in actual.keys() { + if !paths.contains_key(path) { + let display_path = declared_path_display(path); + panic!( + "{INVENTORY_PATH} extra {} migration file `{display_path}` is not registered", + dialect.name() + ); + } + } + } + validate_dialect_directories(root); +} + +fn validate_path(root: &Path, dialect: Dialect, file: &MigrationFile) { + let path = Path::new(&file.path); + let display_path = declared_path_display(&file.path); + if file.path.is_empty() + || file.path.trim() != file.path + || file.path.len() > 4 * 1024 + || file.path.contains('\0') + || file.path.contains('\\') + || !file.path.ends_with(".sql") + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || !path.starts_with(dialect.directory()) + || is_secret_like(&file.path) + { + panic!( + "{INVENTORY_PATH} {} migration path `{}` is outside `{}`", + dialect.name(), + display_path, + dialect.directory() + ); + } + let mut current = root.to_path_buf(); + for component in path.components() { + let Component::Normal(component) = component else { + unreachable!("validated migration path components"); + }; + current.push(component); + let metadata = fs::symlink_metadata(¤t) + .unwrap_or_else(|error| panic!("inspect migration path `{display_path}`: {error}")); + if metadata.file_type().is_symlink() { + panic!("migration path `{display_path}` must not be a symlink"); + } + } +} + +fn declared_path_display(path: &str) -> String { + let path_value = Path::new(path); + if is_secret_like(path) + || path_value.is_absolute() + || path.contains('\\') + || path_value + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + REDACTED_MIGRATION_PATH.to_string() + } else { + path.to_string() + } +} + +fn is_secret_like(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("postgres://") + || lower.contains("postgresql://") + || lower.contains("mysql://") + || lower.contains("mongodb://") + || lower.contains("bearer ") + || lower.contains("password=") + || lower.contains("token=") + || lower.contains("secret=") + || lower.contains("-----begin ") +} + +fn validate_json_nesting(input: &[u8]) -> Result<(), &'static str> { + let mut depth = 0usize; + let mut escaped = false; + let mut in_string = false; + + for byte in input { + if in_string { + if escaped { + escaped = false; + } else if *byte == b'\\' { + escaped = true; + } else if *byte == b'"' { + in_string = false; + } + continue; + } + + match *byte { + b'"' => in_string = true, + b'{' | b'[' => { + depth = depth.saturating_add(1); + if depth > MAX_JSON_DEPTH { + return Err("migration inventory exceeds maximum JSON nesting depth"); + } + } + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + } + + Ok(()) +} + +fn relative_path_display(root: &Path, path: &Path) -> String { + let relative = path + .strip_prefix(root) + .map(|relative| { + relative + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") + }) + .unwrap_or_else(|_| "".to_string()); + declared_path_display(&relative) +} + +fn read_bounded_file(root: &Path, path: &Path, limit: usize, label: &str) -> Vec { + let relative = relative_path_display(root, path); + let metadata = fs::symlink_metadata(path) + .unwrap_or_else(|error| panic!("read {label} `{relative}`: {error}")); + if metadata.file_type().is_symlink() { + panic!("{label} `{relative}` must not be a symlink"); + } + if !metadata.is_file() { + panic!("{label} `{relative}` is not a regular file"); + } + if metadata.len() > limit as u64 { + panic!("{label} `{relative}` exceeds {limit} bytes"); + } + let file = + File::open(path).unwrap_or_else(|error| panic!("read {label} `{relative}`: {error}")); + let opened_metadata = file + .metadata() + .unwrap_or_else(|error| panic!("inspect opened {label} `{relative}`: {error}")); + if !opened_metadata.is_file() { + panic!("opened {label} `{relative}` is not a regular file"); + } + let opened_size = opened_metadata.len(); + if opened_size > limit as u64 { + panic!("opened {label} `{relative}` exceeds {limit} bytes"); + } + let mut bytes = Vec::with_capacity(opened_size as usize); + file.take(limit as u64 + 1) + .read_to_end(&mut bytes) + .unwrap_or_else(|error| panic!("read {label} `{relative}`: {error}")); + if bytes.len() > limit { + panic!("{label} `{relative}` exceeds {limit} bytes"); + } + bytes +} + +fn collect_sql_files(root: &Path, dialect: Dialect) -> BTreeMap { + let mut pending = vec![root.join(dialect.directory())]; + let mut files = BTreeMap::new(); + let mut directories = 0usize; + let mut entries_seen = 0usize; + while let Some(directory) = pending.pop() { + directories += 1; + if directories > MAX_DIRECTORIES { + panic!( + "{} migration directory tree exceeds {MAX_DIRECTORIES} directories", + dialect.name() + ); + } + let directory_metadata = fs::symlink_metadata(&directory).unwrap_or_else(|error| { + panic!( + "inspect migration directory `{}`: {error}", + relative_path_display(root, &directory) + ) + }); + if directory_metadata.file_type().is_symlink() { + panic!( + "migration directory `{}` must not be a symlink", + relative_path_display(root, &directory) + ); + } + if !directory_metadata.is_dir() { + panic!( + "migration directory `{}` is not a directory", + relative_path_display(root, &directory) + ); + } + let mut read_entries = fs::read_dir(&directory).unwrap_or_else(|error| { + panic!( + "read migration directory `{}`: {error}", + relative_path_display(root, &directory) + ) + }); + let remaining_entries = MAX_TOTAL_ENTRIES - entries_seen; + let mut entries = Vec::with_capacity(remaining_entries); + loop { + let Some(entry) = read_entries.next() else { + break; + }; + if entries_seen >= MAX_TOTAL_ENTRIES { + panic!( + "migration directory tree exceeds {MAX_TOTAL_ENTRIES} entries for {}", + dialect.name() + ); + } + entries_seen += 1; + entries.push(entry.unwrap_or_else(|error| { + panic!( + "read migration directory entry for {}: {error}", + dialect.name() + ) + })); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).unwrap_or_else(|error| { + panic!( + "inspect migration path `{}`: {error}", + relative_path_display(root, &path) + ) + }); + if metadata.file_type().is_symlink() { + panic!( + "migration path `{}` must not be a symlink", + relative_path_display(root, &path) + ); + } + if metadata.is_dir() { + pending.push(path); + continue; + } + if !metadata.is_file() { + panic!( + "migration path `{}` is not a regular file", + relative_path_display(root, &path) + ); + } + if path.extension().and_then(|extension| extension.to_str()) != Some("sql") { + continue; + } + let relative = path + .strip_prefix(root) + .unwrap_or_else(|_| panic!("migration path escaped repository root")) + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + files.insert(relative, ()); + if files.len() > MAX_SQL_FILES { + panic!( + "{} migration directory tree contains more than {MAX_SQL_FILES} SQL files", + dialect.name() + ); + } + } + } + files +} + +fn validate_dialect_directories(root: &Path) { + let migrations = root.join("migrations"); + let migrations_metadata = fs::symlink_metadata(&migrations) + .unwrap_or_else(|error| panic!("inspect migrations directory: {error}")); + if migrations_metadata.file_type().is_symlink() { + panic!("migrations directory must not be a symlink"); + } + if !migrations_metadata.is_dir() { + panic!("migrations path is not a directory"); + } + let mut read_entries = fs::read_dir(&migrations) + .unwrap_or_else(|error| panic!("read migrations directory: {error}")); + let mut entries_seen = 0usize; + let mut entries = Vec::with_capacity(MAX_TOP_LEVEL_ENTRIES); + loop { + let Some(entry) = read_entries.next() else { + break; + }; + if entries_seen >= MAX_TOP_LEVEL_ENTRIES { + panic!("migrations directory exceeds {MAX_TOP_LEVEL_ENTRIES} entries"); + } + entries_seen += 1; + entries.push(entry.unwrap_or_else(|error| panic!("read migrations entry: {error}"))); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .unwrap_or_else(|error| panic!("inspect migrations entry: {error}")); + if metadata.file_type().is_symlink() { + panic!( + "migration path `{}` must not be a symlink", + relative_path_display(root, &path) + ); + } + if !metadata.is_dir() { + continue; + } + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !matches!(name, "sqlite" | "postgres") { + panic!( + "unsupported migration dialect directory `{}`", + relative_path_display(root, &path) + ); + } + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/distributed.contracts.json b/distributed.contracts.json new file mode 100644 index 000000000..9024e3794 --- /dev/null +++ b/distributed.contracts.json @@ -0,0 +1,102 @@ +{ + "schema_version": 1, + "entries": { + "migration-inventory": { + "id": "migration-inventory", + "kind": "migration_inventory", + "scope": { "id": "repository/migrations" }, + "owner": "distributed/migrations", + "identity": { "kind": "migration_inventory", "value": "ref:migrations" }, + "provenance": { + "sources": ["migrations/inventory.json"], + "generator": "distributed.migrations" + }, + "outputs": { "catalog": "distributed.contracts.json" }, + "lifecycle": ["check"] + }, + "surface-e2e-ui": { + "id": "surface-e2e-ui", + "kind": "surface_client_manifest", + "scope": { "id": "client/e2e-ui/surface" }, + "owner": "query-layer/e2e-ui/surface", + "identity": { "kind": "surface_client_manifest", "value": "ref:client/e2e-ui" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-manifest" + }, + "outputs": { + "surface-manifest-user": "tests/e2e-ui/ui/src/lib/generated/user/manifest.json" + }, + "lifecycle": ["check"] + }, + "surface-e2e-ui-admin": { + "id": "surface-e2e-ui-admin", + "kind": "surface_client_manifest", + "scope": { "id": "client/e2e-ui-admin/surface" }, + "owner": "query-layer/e2e-ui-admin/surface", + "identity": { "kind": "surface_client_manifest", "value": "ref:client/e2e-ui-admin" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-manifest" + }, + "outputs": { + "surface-manifest-admin": "tests/e2e-ui/ui/src/lib/generated/admin/manifest.json" + }, + "lifecycle": ["check"] + }, + "surface-e2e-ui-public": { + "id": "surface-e2e-ui-public", + "kind": "surface_client_manifest", + "scope": { "id": "client/e2e-ui-public/surface" }, + "owner": "query-layer/e2e-ui-public/surface", + "identity": { "kind": "surface_client_manifest", "value": "ref:client/e2e-ui-public" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-manifest" + }, + "outputs": { + "surface-manifest-public": "tests/e2e-ui/ui/src/lib/generated/public/manifest.json" + }, + "lifecycle": ["check"] + }, + "generated-e2e-ui": { + "id": "generated-e2e-ui", + "kind": "generated_client_tree", + "scope": { "id": "client/e2e-ui/generated" }, + "owner": "query-layer/e2e-ui/generated", + "identity": { "kind": "generated_client_tree", "value": "ref:client/e2e-ui/generated" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-compiler" + }, + "outputs": { "generated-tree-user": "tests/e2e-ui/ui/src/lib/generated/user" }, + "lifecycle": ["check"] + }, + "generated-e2e-ui-admin": { + "id": "generated-e2e-ui-admin", + "kind": "generated_client_tree", + "scope": { "id": "client/e2e-ui-admin/generated" }, + "owner": "query-layer/e2e-ui-admin/generated", + "identity": { "kind": "generated_client_tree", "value": "ref:client/e2e-ui-admin/generated" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-compiler" + }, + "outputs": { "generated-tree-admin": "tests/e2e-ui/ui/src/lib/generated/admin" }, + "lifecycle": ["check"] + }, + "generated-e2e-ui-public": { + "id": "generated-e2e-ui-public", + "kind": "generated_client_tree", + "scope": { "id": "client/e2e-ui-public/generated" }, + "owner": "query-layer/e2e-ui-public/generated", + "identity": { "kind": "generated_client_tree", "value": "ref:client/e2e-ui-public/generated" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-compiler" + }, + "outputs": { "generated-tree-public": "tests/e2e-ui/ui/src/lib/generated/public" }, + "lifecycle": ["check"] + } + } +} diff --git a/distributed_cli/Cargo.toml b/distributed_cli/Cargo.toml index 5a6f59eae..72130a91c 100644 --- a/distributed_cli/Cargo.toml +++ b/distributed_cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "distributed_cli" -description = "The `dctl` CLI for Distributed services: scaffold projects, describe their manifest, and render schema artifacts (SQL or Atlas Operator resources). Also a library so other CLIs (e.g. hops) can mount its commands." +description = "The `distributed` CLI for Distributed applications: contracts check/accept, scaffold projects, describe manifests, compile clients, and render schema artifacts. Also a library so other CLIs (e.g. hops) can mount its commands." version.workspace = true edition.workspace = true license.workspace = true @@ -11,7 +11,7 @@ readme = "README.md" path = "src/lib.rs" [[bin]] -name = "dctl" +name = "distributed" path = "src/main.rs" [dependencies] diff --git a/distributed_cli/README.md b/distributed_cli/README.md index 4567fd7da..d1546711c 100644 --- a/distributed_cli/README.md +++ b/distributed_cli/README.md @@ -1,24 +1,24 @@ -# distributed_cli (`dctl`) +# distributed_cli (`distributed`) -Service tooling for [Distributed](https://crates.io/crates/distributed): a `dctl` +Service tooling for [Distributed](https://crates.io/crates/distributed): a `distributed` binary — and a library — that scaffolds service crates, inspects a service's -project manifest, and renders schema artifacts (SQL or an Atlas Operator -resource). +logical ApplicationManifest, and renders physical read-model schema artifacts +(SQL or an Atlas Operator resource). ```bash -cargo install distributed_cli # installs the `dctl` binary +cargo install distributed_cli # installs the `distributed` binary ``` It is also a library, so another CLI can mount its commands instead of reimplementing them. `hops`, for example, exposes the same surface under `hops service` by depending on this crate and dispatching with -`distributed_cli::run`. **Everything below documented as `dctl ` is also +`distributed_cli::run`. **Everything below documented as `distributed ` is also available as `hops service `.** -## `dctl scaffold ` — generate a service crate +## `distributed scaffold ` — generate a service crate ```bash -dctl scaffold orders --store postgres --transport http --gitops +distributed scaffold orders --store postgres --transport http --gitops ``` Writes a ready-to-build Distributed service under `./` (override with @@ -26,7 +26,7 @@ Writes a ready-to-build Distributed service under `./` (override with `, `--model ` (repeatable), `--read-models`, `--command` / `--event` (repeatable), `--bus `, `--gitops`, `--metrics prometheus`, `--tracing` / `--otel`, `--gitops-promote `, -`--github OWNER/REPO`, `--force`. See `dctl scaffold --help` for the full list. +`--github OWNER/REPO`, `--force`. See `distributed scaffold --help` for the full list. When used with `--gitops`, `--metrics prometheus` emits Prometheus Operator `ServiceMonitor` and `PrometheusRule` templates for HTTP services. The @@ -38,11 +38,11 @@ emit `monitoring.coreos.com` resources. OTLP tracing setup in the generated `main.rs`, and renders OTLP environment values in the Helm chart without hard-coding an endpoint. -## `dctl skills init` — extract agent skills into a project +## `distributed skills init` — extract agent skills into a project ```bash -dctl skills init # writes ./.distributed/skills/ and wires harnesses -dctl skills list # names + descriptions of the embedded skills +distributed skills init # writes ./.distributed/skills/ and wires harnesses +distributed skills list # names + descriptions of the embedded skills ``` Materializes the **agent skills** embedded in the binary — markdown guidance @@ -74,14 +74,15 @@ directories are never touched. After a CLI upgrade, re-run with `--force` to refresh existing skill files to the binary's embedded content; without `--force`, differing files are treated as local edits and skipped. -## The project manifest entrypoint +## The artifact entrypoints -`describe` and `schema` work by compiling your service crate and calling an -exported manifest function — by default `::distributed_manifest`. Add one -to your service that registers its read models / tables and services: +`describe` compiles your service crate and calls the explicit logical +application-manifest entrypoint — by default `::application_manifest`. +`schema` calls the separate physical read-model catalog entrypoint — by default +`::read_model_catalog`. Keep those owners separate: ```rust -use distributed::{DistributedProjectManifest, ReadModel}; +use distributed::{ReadModelCatalog, ReadModel}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] @@ -92,8 +93,8 @@ pub struct OrderView { pub status: String, } -pub fn distributed_manifest() -> DistributedProjectManifest { - DistributedProjectManifest::new("orders").read_model::() +pub fn read_model_catalog() -> ReadModelCatalog { + ReadModelCatalog::new("orders").read_model::() } ``` @@ -102,10 +103,10 @@ compile the target crate, they need the local `distributed` crate to be resolvable — found automatically from the workspace, or pass `--distributed-path` / set `DISTRIBUTED_PATH`. -## `dctl client-manifest` — authorized client surface +## `distributed client-manifest` — authorized client surface ```bash -dctl client-manifest > target/distributed-client.json +distributed client-manifest > target/distributed-client.json ``` Compiles the service's `distributed_client_surface` export into the versioned, @@ -113,17 +114,17 @@ role/application-selected manifest used by the operation compiler. The export already contains one concrete role or named application surface; it is not an admin catalog that downstream tools filter themselves. -## `dctl client` — typed query, live, and command artifacts +## `distributed client` — typed query, live, and command artifacts ```bash -dctl client \ +distributed client \ --manifest target/distributed-client.json \ --role user \ --documents 'src/**/*.graphql' \ --out src/generated/distributed # CI: parse, validate, and compare without writing -dctl client \ +distributed client \ --manifest target/distributed-client.json \ --role user \ --documents 'src/**/*.graphql' \ @@ -158,17 +159,17 @@ co-located document with a different filename can use the explicit fallback build time with their source location; the compiler does not emit a partial normalization plan. -## `dctl describe` — manifest as JSON +## `distributed describe` — manifest as JSON ```bash -dctl describe # current directory -dctl describe --manifest-path path/to/Cargo.toml --package orders-service +distributed describe # current directory +distributed describe --manifest-path path/to/Cargo.toml --package orders-service ``` Prints the versioned manifest envelope (schemas, services, transports) as JSON — a stable contract for other tooling. -## `dctl schema` — schema artifacts +## `distributed schema` — schema artifacts Renders the **desired-state** schema for the manifest's read models and operational tables. Output goes to stdout by default (or `--out `). @@ -176,7 +177,7 @@ operational tables. Output goes to stdout by default (or `--out `). ### SQL (default) ```bash -dctl schema --dialect postgres # or --dialect sqlite +distributed schema --dialect postgres # or --dialect sqlite ``` ### Atlas Operator resource (`--format atlas`) @@ -185,12 +186,12 @@ Wraps the desired-state SQL into an `AtlasSchema` (`db.atlasgo.io/v1alpha1`) for the [ariga atlas-operator](https://github.com/ariga/atlas-operator), so the operator diffs the live database against it and applies the migration in-cluster. -The resource is written to **stdout** — `dctl` deliberately does not pick a +The resource is written to **stdout** — `distributed` deliberately does not pick a location for it. Redirect it wherever you keep schema manifests: a file in the service repo, or a separate GitOps/schema repo. ```bash -dctl schema --format atlas \ +distributed schema --format atlas \ --name orders \ --namespace data \ --db-secret orders-db \ diff --git a/distributed_cli/skills/distributed-ci/SKILL.md b/distributed_cli/skills/distributed-ci/SKILL.md index e6e98a0ce..484c6b647 100644 --- a/distributed_cli/skills/distributed-ci/SKILL.md +++ b/distributed_cli/skills/distributed-ci/SKILL.md @@ -1,11 +1,11 @@ --- name: distributed-ci -description: Set up CI, release workflows, and GitOps promotion for a Distributed service with dctl scaffold flags (--github, --gitops, --gitops-promote). Use when configuring pipelines, previews, releases, or deploy automation. +description: Set up CI, release workflows, and GitOps promotion for a Distributed service with distributed scaffold flags (--github, --gitops, --gitops-promote). Use when configuring pipelines, previews, releases, or deploy automation. --- # CI and GitOps for Distributed services -`dctl scaffold` generates the whole delivery pipeline — Helm deploy chart, +`distributed scaffold` generates the whole delivery pipeline — Helm deploy chart, GitHub Actions workflows, and promotion charts — from flags. Prefer regenerating/extending these artifacts over hand-writing pipeline YAML. @@ -74,7 +74,7 @@ set, because the promotion charts target `.gitops/deploy`. ## Gotchas -- `dctl scaffold` **refuses a non-empty directory** without `--force`; adding +- `distributed scaffold` **refuses a non-empty directory** without `--force`; adding CI to an existing service means running scaffold with `--force` in a clean worktree and reviewing the diff, or copying the generated workflows in. - The three GitHub repos gate independent slices — you can adopt @@ -93,5 +93,5 @@ set, because the promotion charts target `.gitops/deploy`. ## Reference -Full flag list: `dctl scaffold --help`. The same commands ship as +Full flag list: `distributed scaffold --help`. The same commands ship as `hops service scaffold ...` when using the `hops` CLI. diff --git a/distributed_cli/skills/distributed-graphql/SKILL.md b/distributed_cli/skills/distributed-graphql/SKILL.md index 2e2ee0a4b..6489fc5c9 100644 --- a/distributed_cli/skills/distributed-graphql/SKILL.md +++ b/distributed_cli/skills/distributed-graphql/SKILL.md @@ -25,7 +25,7 @@ second GraphQL command registry. ### Add a model exposure -1. Ensure the read model is in `distributed_manifest()`. +1. Ensure the read model is in `read_model_catalog()`. 2. Add `src/query/.rs`: ```rust @@ -119,20 +119,21 @@ Declare each GraphQL mutation on the executable route: ```rust let routes = Routes::new() .with_repo(repository.aggregate::()) - .typed_command( - typed_command::>("order.create") - .roles(["user"]) - .emits(distributed::events![OrderCreatedDomainEvent]) - .applies(/* state_preview! for client optimism */), - ) + .command_transition::< + domain_commands::Create, + CreateOrderInput, + Eventual, + >("order.create") + .roles(["user"]) .handle(create_order); ``` Handlers accept `CausalCommandContext`, stage aggregate/outbox work on that context, and return `PreparedCommand>`, `PreparedCommand>`, or `PreparedCommand>`. Never commit outside the framework-owned -causal boundary. Projector obligations derive from `.emits` + portable/modeled -handlers (`mutation!`), not separately authored command confirmations/effects. +causal boundary. Projector obligations derive from the transition event set + +portable/modeled handlers (`mutation!`), not separately authored command +confirmations/effects. ### Command consistency modes (ship contract) @@ -141,8 +142,8 @@ handlers (`mutation!`), not separately authored command confirmations/effects. | Contract | Meaning | Mutation response | Client seal | |----------|---------|-------------------|-------------| | `Succeeded` | Tx succeeded; no projection promise | Payload only | Revalidate / live | -| `Eventual` | Events committed; Eventual projectors apply later | Payload + **projection-delta** + `expects` | `.applies` → wait obligations | -| `Atomic` | Exact row in **same** command tx | **Typed row `M`** + direct **`records[]`** (no eventual modeled metadata, empty `expects`) | `.applies` optional; **`confirmDirectProjection(row, records)`** before await settles | +| `Eventual` | Events committed; Eventual projectors apply later | Payload + **projection-delta** + `expects` | Automatic event→mutation preview, then wait obligations | +| `Atomic` | Exact row in **same** command tx | **Typed row `M`** + direct **`records[]`** (no eventual modeled metadata, empty `expects`) | Same automatic preview; **`confirmDirectProjection(row, records)`** before await settles | Handler for Atomic — this *is* returning atomic read-model updates: @@ -156,10 +157,12 @@ Rules: 1. Use `Atomic` only when the exact row is staged in-handler (`readmodel(row).…commit()?.atomic()`). Server will not attach causal projection-delta metadata to same-tx commands (by design). -2. Use `Eventual` with `.emits` + `.applies(state_preview! { … })` so eventual - projectors and client previews share one IR. -3. Direct may export portable programs for `.applies` (`is_preview_eligible`); - that is not `is_causally_eligible` (Eventual-only obligations). +2. Use `command_transition::` so the generated + transition supplies the exact event set and values the compiler can prove. + The role-visible projector arm is the only event→mutation mapping. +3. Direct and Eventual placements may both export that portable preview program + (`is_preview_eligible`); only Eventual creates causal wait obligations + (`is_causally_eligible`). 4. Do not board-sim Atomic UI — the returned row is authoritative. 5. Otherwise `Succeeded`; never invent a projected row. @@ -170,7 +173,7 @@ client manifests). ## SDL artifact (CI gate) ```bash -dctl schema --format graphql --out schema.graphql +distributed schema --format graphql --out schema.graphql git diff --exit-code schema.graphql ``` @@ -179,12 +182,19 @@ git diff --exit-code schema.graphql ## Scaffold ```bash -dctl scaffold my-service --query-api --read-models --store sqlite +distributed scaffold my-service --query-api --read-models --store sqlite ``` Emits typed causal handlers, a service-derived GraphQL engine, `src/query/` permissions, the `graphql` feature, and repository/token-key wiring. +## Client / replica drift (dogfood) + +If unit tests pass but the live UI 500s with schema/surface errors: the API +pod is still compiling or clients/`js/dist` are stale. Rebuild and wait — +don't redesign roles. Bare `__typename` (role fingerprint) ≠ application +surface clients. + ## Reference - Framework docs: `README § GraphQL query service` in the Distributed repo diff --git a/distributed_cli/skills/distributed-schema/SKILL.md b/distributed_cli/skills/distributed-schema/SKILL.md index e6f322650..0ea2d56b1 100644 --- a/distributed_cli/skills/distributed-schema/SKILL.md +++ b/distributed_cli/skills/distributed-schema/SKILL.md @@ -1,21 +1,22 @@ --- name: distributed-schema -description: Inspect a Distributed service manifest and render schema artifacts - dctl describe (manifest JSON), dctl schema (migration SQL or an Atlas Operator resource), and the distributed_manifest() envelope contract. Use when working on read-model schemas, migrations, or schema automation. +description: Inspect a Distributed read-model catalog and render schema artifacts - distributed describe (application JSON), distributed schema (migration SQL or an Atlas Operator resource), and the explicit application/read-model artifact contract. Use when working on read-model schemas, migrations, or schema automation. --- # Manifests and schema artifacts -`dctl describe` and `dctl schema` are the schema toolchain for a Distributed -service. Both **compile the target crate** and call its exported manifest -function — by default `::distributed_manifest` (override with -`--entrypoint `). +`distributed describe` and `distributed schema` are the artifact toolchain for a Distributed +service. Both **compile the target crate**, but they call different explicit +owners: `describe` defaults to `::application_manifest` for the logical +application artifact, while `schema` defaults to `::read_model_catalog` +for physical read-model SQL/SDL (override with `--entrypoint `). ## The manifest entrypoint The service must export a function that registers its read models / tables: ```rust -use distributed::{DistributedProjectManifest, ReadModel}; +use distributed::{ReadModelCatalog, ReadModel}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] #[table("orders")] @@ -25,33 +26,34 @@ pub struct OrderView { pub status: String, } -pub fn distributed_manifest() -> DistributedProjectManifest { - DistributedProjectManifest::new("orders").read_model::() +pub fn read_model_catalog() -> ReadModelCatalog { + ReadModelCatalog::new("orders").read_model::() } ``` -A read model not registered here is invisible to `describe`/`schema` — no SQL -is rendered for it. When you add a `#[derive(ReadModel)]` type, register it. +A read model not registered here is invisible to `schema` — no SQL is rendered +for it. When you add a `#[derive(ReadModel)]` type, register it; the logical +application entrypoint must select its own Surface contract explicitly. -## `dctl describe` — manifest JSON +## `distributed describe` — manifest JSON ```bash -dctl describe # current directory -dctl describe --manifest-path path/to/Cargo.toml --package orders-service +distributed describe # current directory +distributed describe --manifest-path path/to/Cargo.toml --package orders-service ``` Prints the versioned manifest envelope. The contract other tooling relies on: -a numeric `schema_version` (currently `1`) and a `project` object. `dctl` +a numeric `schema_version` (currently `1`) and a `project` object. `distributed` rejects envelopes with a missing/different `schema_version`, so treat the envelope as a stable machine interface, not free-form JSON. -## `dctl schema` — SQL or Atlas resource +## `distributed schema` — SQL or Atlas resource Renders the **desired-state** schema for the manifest's read models and operational tables. Output goes to stdout (or `--out `). ```bash -dctl schema --dialect postgres # migration SQL (or --dialect sqlite) +distributed schema --dialect postgres # migration SQL (or --dialect sqlite) ``` ### Atlas Operator resource @@ -61,7 +63,7 @@ for the ariga atlas-operator, which diffs the live database and applies the migration in-cluster — declarative schema via GitOps: ```bash -dctl schema --format atlas \ +distributed schema --format atlas \ --name orders \ --namespace data \ --db-secret orders-db \ @@ -78,7 +80,7 @@ dctl schema --format atlas \ - `--dev-url` sets `spec.devURL`, the scratch database Atlas uses to plan changes. - The resource goes to **stdout** deliberately — redirect it to wherever schema - manifests live (service repo or a GitOps repo). `dctl` does not pick a + manifests live (service repo or a GitOps repo). `distributed` does not pick a location. ## Running in CI @@ -95,14 +97,14 @@ dependencies resolvable: Typical schema-gate step: render and diff so schema drift fails the build. ```bash -dctl schema --dialect postgres --out rendered.sql +distributed schema --dialect postgres --out rendered.sql git diff --exit-code rendered.sql ``` Or regenerate the Atlas resource and let the GitOps PR carry the change: ```bash -dctl schema --format atlas --name orders --db-secret orders-db \ +distributed schema --format atlas --name orders --db-secret orders-db \ --out manifests/orders.schema.yaml ``` @@ -115,7 +117,7 @@ dctl schema --format atlas --name orders --db-secret orders-db \ `#[readmodel(table = "...", primary_key = ["a", "b"])]`, `#[index(...)]`. - Rendered SQL is desired-state, not a diff. Diffing against the live database is the Atlas operator's job (`--format atlas`) or your migration tool's. -- `dctl schema` and `dctl describe` also ship as `hops service schema` / +- `distributed schema` and `distributed describe` also ship as `hops service schema` / `hops service describe`. ## Reference diff --git a/distributed_cli/skills/distributed-usage/SKILL.md b/distributed_cli/skills/distributed-usage/SKILL.md index 90acc1696..91da624e5 100644 --- a/distributed_cli/skills/distributed-usage/SKILL.md +++ b/distributed_cli/skills/distributed-usage/SKILL.md @@ -1,6 +1,6 @@ --- name: distributed-usage -description: Build Distributed CQRS/event-sourced Rust services where you mostly write models and handlers while the framework and dctl generate persistence, transports, manifests, and deploy wiring. Use model-first TDD to specify plain aggregate behavior with fast unit tests before implementing models and thin handlers. Use when designing, testing, writing, or modifying a Distributed service or domain model. +description: Build Distributed CQRS/event-sourced Rust services where you mostly write models and handlers while the framework and distributed generate persistence, transports, manifests, and deploy wiring. Use model-first TDD to specify plain aggregate behavior with fast unit tests before implementing models and thin handlers. Use when designing, testing, writing, or modifying a Distributed service or domain model. --- # Using the Distributed framework @@ -11,7 +11,7 @@ description: Build Distributed CQRS/event-sourced Rust services where you mostly first, then write thin handlers around proven behavior.** Everything else — service wiring, transports, persistence, manifests, schema, -CI/GitOps — is deterministic structure the framework, macros, and `dctl` +CI/GitOps — is deterministic structure the framework, macros, and `distributed` generate. Your authored surface is deliberately small: aggregate models (`#[sourced]` event methods), command/event handler bodies, and read-model shapes. If you find yourself hand-writing service plumbing, routing, broker @@ -49,7 +49,7 @@ traits — production swaps are one constructor line, never a handler change. ## Workflow 1. Scaffold a service instead of hand-rolling layout: - `dctl scaffold --model --command --event --store postgres --transport http --bus nats --gitops` + `distributed scaffold --model --command --event --store postgres --transport http --bus nats --gitops` (from an event-storming board: aggregates → `--model`, commands → `--command`, events/policies → `--event`, query views → `--read-models`). 2. Write failing, colocated unit tests against the aggregate command API you @@ -171,7 +171,7 @@ of exposing a raw `Context`/`serde_json::Value` handler as the mutation contract - bind that exact `Service` through `GraphqlEngineBuilder::service`, configure public OIDC, and call `.without_http_command_routes()` so browser writes use only the GraphQL command proxy; -- generate the strictly typed client with `dctl client`. +- generate the strictly typed client with `distributed client`. Use the `distributed-graphql` skill for the complete route, consistency, authorization, and client-generation contract. The raw handler form below @@ -285,7 +285,7 @@ Copy the **e2e-ui** fixture under `tests/e2e-ui/` (README; see `tests/e2e-ui/REA crates/ todo-domain/ # personal todos (owner-scoped) chat-domain/ # lobby chat (shared room) - readmodels/ # projections + distributed_manifest + readmodels/ # projections + read_model_catalog service/ # thin command handlers + event projectors + GraphQL runner/ # store + bus + bind suite/ # HTTP/GraphQL behavioral cases @@ -302,21 +302,29 @@ Rules the fixture demonstrates: - GraphQL row filter: `owner_id = claim(x-user-id)` for role `user` - **Typed GraphQL commands** (`Eventual` / `Atomic`) via the OIDC command proxy; generic direct command POST routes are disabled -- **Generated client**: `dctl client` produces the typed replica/query/command +- **Generated client**: `distributed client` produces the typed replica/query/command artifacts consumed by the SvelteKit app - **Subscriptions**: wire `SqliteRepository::read_model_changes()` into `GraphqlEngineBuilder::change_stream`; clients use WebSocket `/graphql/ws` Run the full app: `cd tests/e2e-ui && make`. Suite: `make test`. +### e2e-ui cluster-dev dogfood (agents) + +Namespace = hops `--name`. Fix the live pods, not a host `make run`. After +source changes: `make gen-client`, restart, **wait until API logs `listening` +and Vite is ready**, then curl the FQDNs. Schema/pure errors usually mean +stale build, not a roles redesign. Full checklist: hops skill +`references/local-workbench.md` § Dogfood. + ## Manifest entrypoint -Every service should export `distributed_manifest()` registering its read -models — `dctl describe` and `dctl schema` compile the crate and call it: +Every service should export `read_model_catalog()` registering its read +models — `distributed describe` and `distributed schema` compile the crate and call it: ```rust -pub fn distributed_manifest() -> distributed::DistributedProjectManifest { - distributed::DistributedProjectManifest::new("todos").read_model::() +pub fn read_model_catalog() -> distributed::ReadModelCatalog { + distributed::ReadModelCatalog::new("todos").read_model::() } ``` diff --git a/distributed_cli/src/atlas.rs b/distributed_cli/src/atlas.rs index d90ba34d6..8314a74eb 100644 --- a/distributed_cli/src/atlas.rs +++ b/distributed_cli/src/atlas.rs @@ -4,7 +4,7 @@ //! it wants (e.g. stdout → any file, or a separate schema repo) — this crate //! intentionally does **not** decide a `.gitops/` location for it. //! -//! The desired-state SQL (e.g. `DistributedProjectManifest::sql_statements`) goes +//! The desired-state SQL (e.g. `ReadModelCatalog::sql_statements`) goes //! into `spec.schema.sql`; the operator diffs the live database against it and //! applies the change. //! diff --git a/distributed_cli/src/cli.rs b/distributed_cli/src/cli.rs index 47d88d6da..6735a65f4 100644 --- a/distributed_cli/src/cli.rs +++ b/distributed_cli/src/cli.rs @@ -1,11 +1,11 @@ -//! The `dctl` command surface: clap types plus the [`run`] dispatcher. Generation -//! lives in the crate's `generate`/`atlas` modules and the `describe`/`schema` -//! harness in `manifest_harness`; this module maps flags onto those types and -//! owns the scaffold's filesystem / process side effects (writing files, -//! running `gh`). +//! The `distributed` command surface: clap types plus the [`run_distributed`] +//! dispatcher. Generation lives in the crate's `generate`/`atlas` modules and +//! the `describe`/`schema` harness in `manifest_harness`; this module maps +//! flags onto those types and owns filesystem / process side effects. //! -//! `hops` mounts [`ServiceArgs`] under `hops service` and dispatches with [`run`], -//! re-exporting the commands rather than reimplementing them. +//! Host CLIs (for example `hops`) may mount [`ServiceArgs`] under a nested +//! service command and dispatch with [`run`], re-exporting rather than +//! reimplementing service-related commands. use clap::{ArgGroup, Args, Subcommand, ValueEnum}; use std::collections::{BTreeMap, BTreeSet}; @@ -19,6 +19,10 @@ use crate::client_compiler::{ compile_client, ClientCompileInput, ClientDocument, ClientRouteRegistration, ClientSurfaceSelector, GeneratedClientFile, GeneratedClientProject, }; +use crate::contracts::{ + contracts_accept, contracts_check, unknown_scope_diagnostic, ContractAcceptScope, + ContractCatalog, +}; use crate::manifest_harness::{run_manifest_harness, HarnessMode, HarnessOptions}; use crate::skills::{embedded_skills, generate_skills, SkillsInitSpec, AGENTS_MD_FILE}; use crate::{ @@ -30,6 +34,33 @@ use crate::{ const DISTRIBUTED_MANIFEST_SCHEMA_VERSION: u64 = 1; const DISTRIBUTED_CLIENT_MANIFEST_VERSION: u64 = 2; +/// Top-level standalone CLI arguments for the `distributed` binary. +#[derive(Args, Debug)] +pub struct DistributedArgs { + #[command(subcommand)] + pub command: DistributedCommands, +} + +#[derive(Subcommand, Debug)] +pub enum DistributedCommands { + /// Aggregate contract lifecycle check and accept + Contracts(ContractsArgs), + /// Scaffold a new Distributed microservice crate + #[command(alias = "create")] + Scaffold(ScaffoldArgs), + /// Print a service's explicit ApplicationManifest as JSON + Describe(DescribeArgs), + /// Compile role/application-scoped GraphQL operations into client artifacts + Client(ClientArgs), + /// Compile the service's authorized client Surface manifest as JSON + ClientManifest(ClientManifestArgs), + /// Render schema artifacts (SQL or an Atlas Operator resource) from a read-model catalog + Schema(SchemaArgs), + /// Extract the embedded Distributed agent skills into a project + Skills(SkillsArgs), +} + +/// Library adapter for embedding service-related commands under another CLI. #[derive(Args, Debug)] pub struct ServiceArgs { #[command(subcommand)] @@ -41,18 +72,67 @@ pub enum ServiceCommands { /// Scaffold a new Distributed microservice crate #[command(alias = "create")] Scaffold(ScaffoldArgs), - /// Print a service's Distributed project manifest as JSON + /// Print a service's explicit ApplicationManifest as JSON Describe(DescribeArgs), /// Compile role/application-scoped GraphQL operations into client artifacts Client(ClientArgs), /// Compile the service's authorized client Surface manifest as JSON ClientManifest(ClientManifestArgs), - /// Render schema artifacts (SQL or an Atlas Operator resource) from a manifest + /// Render schema artifacts (SQL or an Atlas Operator resource) from a read-model catalog Schema(SchemaArgs), /// Extract the embedded Distributed agent skills into a project Skills(SkillsArgs), } +#[derive(Args, Debug)] +pub struct ContractsArgs { + #[command(subcommand)] + pub command: ContractsCommands, +} + +#[derive(Subcommand, Debug)] +pub enum ContractsCommands { + /// Read-only aggregate contract check (never writes tracked files) + Check(ContractsCheckArgs), + /// Exact-scope accept with staging, atomic replace, and rollback + Accept(ContractsAcceptArgs), +} + +#[derive(Args, Debug)] +pub struct ContractsCheckArgs { + /// Catalog root directory + #[arg(long, default_value = ".")] + pub root: PathBuf, + /// Path to the contract catalog JSON (relative to root or absolute) + #[arg(long, default_value = "contracts/catalog.json")] + pub catalog: PathBuf, + /// Output format + #[arg(long, value_enum, default_value = "human")] + pub output: ContractsOutput, +} + +#[derive(Args, Debug)] +pub struct ContractsAcceptArgs { + /// Catalog root directory + #[arg(long, default_value = ".")] + pub root: PathBuf, + /// Exact accept scope (no broad wildcards) + #[arg(long)] + pub scope: String, + /// Staged payload file: JSON object mapping portable relative paths to UTF-8 contents + #[arg(long)] + pub staged: PathBuf, + /// Output format + #[arg(long, value_enum, default_value = "human")] + pub output: ContractsOutput, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum ContractsOutput { + Human, + Json, +} + #[derive(Args, Debug)] pub struct SkillsArgs { #[command(subcommand)] @@ -130,7 +210,7 @@ pub struct ScaffoldArgs { /// Model aggregate to scaffold. May be repeated. #[arg(long)] pub model: Vec, - /// Generate placeholder read-model modules and register them in distributed_manifest(). + /// Generate placeholder read-model modules and register them in read_model_catalog(). #[arg(long)] pub read_models: bool, /// Generate src/query/ GraphQL skeleton, enable the graphql feature, and wire with_graphql. @@ -201,7 +281,7 @@ pub struct DescribeArgs { /// Disable default features on the target service dependency. #[arg(long)] pub no_default_features: bool, - /// Manifest function to call. Defaults to ::distributed_manifest. + /// Application manifest function to call. Defaults to ::application_manifest. #[arg(long)] pub entrypoint: Option, /// Output format. @@ -255,6 +335,12 @@ pub struct ClientArgs { /// Verify that the manifest is selected for this named application surface. #[arg(long)] pub surface: Option, + /// Explicit eligible application roles (repeat for each role). + #[arg(long, value_name = "ROLE", requires = "surface")] + pub eligible_role: Vec, + /// Explicit schema application roles (repeat for each role). + #[arg(long, value_name = "ROLE", requires = "surface")] + pub schema_role: Vec, /// GraphQL document glob. Repeat for multiple source roots. #[arg(long, required = true, value_name = "GLOB")] pub documents: Vec, @@ -286,7 +372,7 @@ pub struct SchemaArgs { /// Disable default features on the target service dependency. #[arg(long)] pub no_default_features: bool, - /// Manifest function to call. Defaults to ::distributed_manifest. + /// Read-model catalog function to call. Defaults to ::read_model_catalog. #[arg(long)] pub entrypoint: Option, /// SQL dialect to render. @@ -430,8 +516,24 @@ impl From for GitopsPromoteTarget { } } -/// Dispatch a parsed service command. The `dctl` binary and any host CLI (e.g. -/// `hops service`) both call this. +/// Dispatch the standalone `distributed` binary command tree. +pub fn run_distributed(args: &DistributedArgs) -> Result<(), Box> { + match &args.command { + DistributedCommands::Contracts(contracts) => run_contracts(contracts), + DistributedCommands::Scaffold(scaffold) => run_scaffold(scaffold), + DistributedCommands::Describe(describe) => run_describe(describe), + DistributedCommands::Client(client) => run_client(client), + DistributedCommands::ClientManifest(client) => run_client_manifest(client), + DistributedCommands::Schema(schema) => run_schema(schema), + DistributedCommands::Skills(skills) => match &skills.command { + SkillsCommands::Init(init) => run_skills_init(init), + SkillsCommands::List => run_skills_list(), + }, + } +} + +/// Dispatch a parsed service command. Host CLIs (for example `hops service`) +/// call this without mounting the aggregate contracts surface. pub fn run(args: &ServiceArgs) -> Result<(), Box> { match &args.command { ServiceCommands::Scaffold(scaffold) => run_scaffold(scaffold), @@ -446,6 +548,86 @@ pub fn run(args: &ServiceArgs) -> Result<(), Box> { } } +fn run_contracts(args: &ContractsArgs) -> Result<(), Box> { + match &args.command { + ContractsCommands::Check(check) => run_contracts_check(check), + ContractsCommands::Accept(accept) => run_contracts_accept(accept), + } +} + +fn run_contracts_check(args: &ContractsCheckArgs) -> Result<(), Box> { + let root = absolute_path(&args.root)?; + let catalog_path = if args.catalog.is_absolute() { + args.catalog.clone() + } else { + root.join(&args.catalog) + }; + let catalog = ContractCatalog::from_path(&catalog_path)?; + let report = contracts_check(&catalog, &root, std::iter::empty()); + match args.output { + ContractsOutput::Human => { + if report.human.is_empty() { + println!("contracts check: ok"); + } else { + println!("{}", report.human); + } + } + ContractsOutput::Json => { + println!("{}", serde_json::to_string_pretty(&report.result)?); + } + } + if report.ok { + Ok(()) + } else { + Err("contracts check failed".into()) + } +} + +fn run_contracts_accept(args: &ContractsAcceptArgs) -> Result<(), Box> { + let Some(scope) = ContractAcceptScope::parse(&args.scope) else { + let diagnostic = unknown_scope_diagnostic(&args.scope); + return Err(diagnostic.human().into()); + }; + let root = absolute_path(&args.root)?; + let staged_source = fs::read_to_string(&args.staged)?; + let staged_json: serde_json::Value = serde_json::from_str(&staged_source)?; + let object = staged_json + .as_object() + .ok_or("staged payload must be a JSON object of path -> string contents")?; + let mut staged = BTreeMap::new(); + for (path, value) in object { + let contents = value + .as_str() + .ok_or_else(|| format!("staged path `{path}` must map to a UTF-8 string"))?; + staged.insert(path.clone(), contents.as_bytes().to_vec()); + } + let report = contracts_accept(&root, scope, &staged)?; + match args.output { + ContractsOutput::Human => { + if report.noop { + println!("contracts accept: no-op ({})", report.scope); + } else { + println!( + "contracts accept: updated {} path(s) for scope {}", + report.changed_paths.len(), + report.scope + ); + for path in &report.changed_paths { + println!(" {path}"); + } + } + } + ContractsOutput::Json => { + println!("{}", serde_json::to_string_pretty(&report)?); + } + } + if report.ok { + Ok(()) + } else { + Err("contracts accept failed".into()) + } +} + fn run_skills_list() -> Result<(), Box> { let width = embedded_skills() .iter() @@ -796,7 +978,23 @@ fn run_client(args: &ClientArgs) -> Result<(), Box> { })?; let selector = match (&args.role, &args.surface) { (Some(role), None) => ClientSurfaceSelector::role(role.clone()), - (None, Some(surface)) => ClientSurfaceSelector::application(surface.clone()), + (None, Some(surface)) => { + // Prefer explicit CLI roles when both lists are provided; otherwise + // take eligible/schema roles from the application surface in the + // manifest (one source of truth — no dual inventory config). + let (eligible_roles, schema_roles) = + if !args.eligible_role.is_empty() && !args.schema_role.is_empty() { + (args.eligible_role.clone(), args.schema_role.clone()) + } else if !args.eligible_role.is_empty() || !args.schema_role.is_empty() { + return Err( + "pass both --eligible-role and --schema-role, or neither (to use the manifest surface)" + .into(), + ); + } else { + application_roles_from_manifest(&manifest, surface)? + }; + ClientSurfaceSelector::application(surface.clone(), eligible_roles, schema_roles) + } _ => { return Err("pass exactly one of --role or --surface ".into()); } @@ -843,6 +1041,55 @@ fn read_utf8_bounded(path: &Path, limit: usize, label: &str) -> Result Result<(Vec, Vec), Box> { + let Some(surface) = manifest.get("surface") else { + return Ok((Vec::new(), Vec::new())); + }; + let kind = surface + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let name = surface + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if kind != "application" || name != surface_name { + return Ok((Vec::new(), Vec::new())); + } + let roles = |field: &str| -> Result, Box> { + let values = surface + .get(field) + .and_then(|value| value.as_array()) + .ok_or_else(|| format!("client manifest surface is missing non-empty `{field}`"))?; + let roles = values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("client manifest surface `{field}` must be strings")) + }) + .collect::, _>>()?; + if roles.is_empty() { + return Err(format!("client manifest surface `{field}` must not be empty").into()); + } + Ok(roles) + }; + Ok((roles("eligible_roles")?, roles("schema_roles")?)) +} + fn collect_client_documents(patterns: &[String]) -> Result, Box> { let project_root = fs::canonicalize(std::env::current_dir()?)?; let mut matched = BTreeMap::::new(); @@ -1187,7 +1434,7 @@ fn stale_generated_client_files( MAX_GENERATED_CLIENT_ARTIFACT_BYTES, "stale generated client artifact", )?; - if !contents.starts_with("/** GENERATED by dctl client. Do not edit. */") { + if !contents.starts_with("/** GENERATED by distributed client. Do not edit. */") { return Err(format!( "refusing to remove {} because its compiler ownership marker is missing", path.display() @@ -1251,7 +1498,7 @@ fn check_client_project( } drift.sort(); Err(format!( - "generated Distributed client artifacts are stale:\n {}\nrun `dctl client` without --check to regenerate", + "generated Distributed client artifacts are stale:\n {}\nrun `distributed client` without --check to regenerate", drift.join("\n ") ) .into()) @@ -1366,7 +1613,7 @@ fn run_schema(args: &SchemaArgs) -> Result<(), Box> { let msg = err.to_string(); if msg.contains("graphql_sdl") || msg.contains("no method named `graphql_sdl`") { format!( - "target service's distributed version predates graphql schema support — upgrade distributed to a version that provides DistributedProjectManifest::graphql_sdl(): {msg}" + "target service's distributed version predates read-model GraphQL schema support — upgrade distributed to a version that provides graphql_sdl_for_tables(): {msg}" ).into() } else { err @@ -1583,8 +1830,24 @@ fn validate_manifest_json(envelope: &serde_json::Value) -> Result<(), Box { validate_nonempty(name, "manifest.surface.name")?; } - ManifestSurface::Application { name, roles } => { + ManifestSurface::Application { + name, + eligible_roles, + schema_roles, + } => { validate_nonempty(name, "manifest.surface.name")?; - if roles.is_empty() { + if eligible_roles.is_empty() { return Err(ClientCompileError::manifest( - "client.manifest.surface_roles", + "client.manifest.surface_eligible_roles", format!("application surface `{name}` must declare at least one role"), )); } - canonicalize_string_set(roles, &format!("application surface `{name}` role"))?; + canonicalize_string_set( + eligible_roles, + &format!("application surface `{name}` eligible role"), + )?; + if schema_roles.is_empty() { + return Err(ClientCompileError::manifest( + "client.manifest.surface_schema_roles", + format!("application surface `{name}` must declare at least one schema role"), + )); + } + canonicalize_string_set( + schema_roles, + &format!("application surface `{name}` schema role"), + )?; + if schema_roles + .iter() + .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) + { + return Err(ClientCompileError::manifest( + "client.manifest.surface_schema_roles", + format!( + "application surface `{name}` schema roles must be a subset of eligible roles" + ), + )); + } } } Ok(()) @@ -120,14 +148,26 @@ pub(crate) fn validate_surface( ( ManifestSurface::Application { name: actual, - roles, + eligible_roles, + schema_roles, + }, + ClientSurfaceSelector::Application { + name: expected, + eligible_roles: expected_eligible_roles, + schema_roles: expected_schema_roles, }, - ClientSurfaceSelector::Application { name: expected }, ) => { !expected.trim().is_empty() && actual == expected - && !roles.is_empty() - && roles.iter().all(|role| !role.trim().is_empty()) + && eligible_roles == expected_eligible_roles + && schema_roles == expected_schema_roles + && !eligible_roles.is_empty() + && eligible_roles.iter().all(|role| !role.trim().is_empty()) + && !schema_roles.is_empty() + && schema_roles.iter().all(|role| !role.trim().is_empty()) + && schema_roles + .iter() + .all(|role| eligible_roles.iter().any(|eligible| eligible == role)) } _ => false, }; @@ -140,7 +180,15 @@ pub(crate) fn validate_surface( }; let expected_label = match expected { ClientSurfaceSelector::Role { name } => format!("role `{name}`"), - ClientSurfaceSelector::Application { name } => format!("application `{name}`"), + ClientSurfaceSelector::Application { + name, + eligible_roles, + schema_roles, + } => format!( + "application `{name}` (eligible roles [{}], schema roles [{}])", + eligible_roles.join(", "), + schema_roles.join(", ") + ), }; Err(ClientCompileError::manifest( "client.manifest.surface_mismatch", @@ -197,7 +245,7 @@ pub(crate) fn validate_execution_limits( return Err(ClientCompileError::manifest( "client.manifest.complexity_version", format!( - "unsupported query complexity contract version {}; dctl requires version 1", + "unsupported query complexity contract version {}; distributed requires version 1", execution.complexity.version ), )); diff --git a/distributed_cli/src/client_compiler/manifest/parse.rs b/distributed_cli/src/client_compiler/manifest/parse.rs index e332a568d..c9651b271 100644 --- a/distributed_cli/src/client_compiler/manifest/parse.rs +++ b/distributed_cli/src/client_compiler/manifest/parse.rs @@ -86,7 +86,7 @@ impl ClientManifest { return Err(ClientCompileError::manifest( "client.manifest.protocol_fingerprint", format!( - "client compiler protocol contract is `{PROTOCOL_FINGERPRINT}`, received `{}`; regenerate the manifest and use a matching dctl version", + "client compiler protocol contract is `{PROTOCOL_FINGERPRINT}`, received `{}`; regenerate the manifest and use a matching distributed version", wire.protocol_fingerprint ), )); diff --git a/distributed_cli/src/client_compiler/manifest/types.rs b/distributed_cli/src/client_compiler/manifest/types.rs index 85f632a8c..14e901a6c 100644 --- a/distributed_cli/src/client_compiler/manifest/types.rs +++ b/distributed_cli/src/client_compiler/manifest/types.rs @@ -55,7 +55,11 @@ pub(crate) struct ManifestComplexityWeights { #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub(crate) enum ManifestSurface { Role { name: String }, - Application { name: String, roles: Vec }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -1070,9 +1074,36 @@ pub(crate) struct ManifestCommandProjection { pub(crate) event_set: Vec, pub(crate) program_arms: Vec, pub(crate) preview_occurrences: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) pure_reduces: Vec, pub(crate) fallback: ManifestProjectionFallback, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManifestCommandPureReduce { + pub(crate) fn_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub(crate) client_module: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub(crate) client_export: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub(crate) wasm_package: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub(crate) wasm_export: String, + pub(crate) model: String, + pub(crate) key: Vec, + pub(crate) args: Vec, + pub(crate) assign: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManifestCommandPureArg { + pub(crate) name: String, + pub(crate) source: ManifestProjectionPreviewSource, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum ManifestProjectionFallback { diff --git a/distributed_cli/src/client_compiler/mod.rs b/distributed_cli/src/client_compiler/mod.rs index 9e1018061..741806183 100644 --- a/distributed_cli/src/client_compiler/mod.rs +++ b/distributed_cli/src/client_compiler/mod.rs @@ -66,7 +66,11 @@ impl ClientCompileInput { #[serde(tag = "kind", rename_all = "snake_case")] pub enum ClientSurfaceSelector { Role { name: String }, - Application { name: String }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } impl ClientSurfaceSelector { @@ -74,8 +78,22 @@ impl ClientSurfaceSelector { Self::Role { name: name.into() } } - pub fn application(name: impl Into) -> Self { - Self::Application { name: name.into() } + pub fn application( + name: impl Into, + eligible_roles: impl IntoIterator>, + schema_roles: impl IntoIterator>, + ) -> Self { + 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(); + schema_roles.sort(); + schema_roles.dedup(); + Self::Application { + name: name.into(), + eligible_roles, + schema_roles, + } } } diff --git a/distributed_cli/src/client_compiler/projection_delta/preview.rs b/distributed_cli/src/client_compiler/projection_delta/preview.rs index 642aa085c..e199389e7 100644 --- a/distributed_cli/src/client_compiler/projection_delta/preview.rs +++ b/distributed_cli/src/client_compiler/projection_delta/preview.rs @@ -79,17 +79,51 @@ pub(crate) struct CompiledCommandProjection { event_set: Vec, capabilities: ProjectionCapabilities, preview: PreviewPlan, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pure_reduces: Vec, fallback: ManifestProjectionFallback, #[serde(skip)] selected_models: BTreeSet, } +/// Client pure-reduce IR (`pureReduces` on the projection artifact). +/// +/// Field names match the rest of the projection preview wire (`occurrence_ordinal`, +/// `projection_refs` snake_case). `client_module` / `client_export` are gen-time +/// only (drive `pures.ts`); inventory is taken from the manifest, not this body. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct CompiledPureReduce { + /// Stable pure id used as pureFunctions key. + #[serde(rename = "fn")] + pure_fn: String, + /// App `$lib`-relative module without extension (for gen-client pures.ts). + #[serde(skip)] + client_module: String, + /// Named export in that module. + #[serde(skip)] + client_export: String, + scope: PreviewScope, + args: Vec, + assign: Vec, + occurrence_ordinal: u32, + projection_refs: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct CompiledPureArg { + name: String, + value: PreviewExpression, +} + impl CompiledCommandProjection { pub(crate) fn affected_models(&self) -> BTreeSet { let mut models = BTreeSet::new(); for operation in &self.preview.operations { operation.mutation.collect_models(&mut models); } + for reduce in &self.pure_reduces { + models.insert(reduce.scope.model.clone()); + } for recovery in &self.preview.recoveries { recovery.target.collect_models(&mut models); } @@ -625,6 +659,7 @@ pub(crate) fn compile_command_preview( validate_preview_inventory(&operations, &recoveries)?; let operations = canonicalize_operations(operations)?; let recoveries = canonicalize_recoveries(recoveries, &operations)?; + let pure_reduces = compile_pure_reduces(extension, &projection_refs, &occurrences)?; let compiled = CompiledCommandProjection { version: extension.version, delta_wire_version: PROJECTION_DELTA_WIRE_VERSION, @@ -642,6 +677,7 @@ pub(crate) fn compile_command_preview( operations, recoveries, }, + pure_reduces, fallback: extension.fallback, selected_models, }; @@ -1456,6 +1492,116 @@ fn source_knowledge(source: &ManifestProjectionPreviewSource) -> Knowledge { } } +fn compile_pure_reduces( + extension: &super::super::manifest::ManifestCommandProjection, + projection_refs: &BTreeMap<&str, u32>, + occurrences: &[PreviewOccurrence], +) -> Result, ClientCompileError> { + if extension.pure_reduces.is_empty() { + return Ok(Vec::new()); + } + // Pure reduce is bound to the first preview occurrence when present so the + // JS validator's occurrence_ordinal < occurrenceCount check passes. Pure + // still needs at least one selected program arm (projection_refs). + if projection_refs.is_empty() { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + "pure reduce requires at least one selected projection program", + )); + } + if occurrences.is_empty() { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + "pure reduce requires at least one preview occurrence so auto-optimism can order the overlay", + )); + } + let occurrence_ordinal = 0u32; + let mut refs: Vec = projection_refs.values().copied().collect(); + refs.sort_unstable(); + refs.dedup(); + let mut compiled = Vec::with_capacity(extension.pure_reduces.len()); + for reduce in &extension.pure_reduces { + if reduce.key.is_empty() { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure reduce `{}` requires at least one key field", + reduce.fn_name + ), + )); + } + if reduce.assign.is_empty() { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure reduce `{}` requires at least one assign field", + reduce.fn_name + ), + )); + } + compiled.push(compile_one_pure_reduce( + reduce, + occurrence_ordinal, + refs.clone(), + )?); + } + Ok(compiled) +} + +fn compile_one_pure_reduce( + reduce: &super::super::manifest::ManifestCommandPureReduce, + occurrence_ordinal: u32, + projection_refs: Vec, +) -> Result { + let mut key = Vec::with_capacity(reduce.key.len()); + for (ordinal, field) in reduce.key.iter().enumerate() { + let Knowledge::Known(value) = source_knowledge(&field.source) else { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure reduce `{}` key `{}` must resolve from input, default, or trusted preset", + reduce.fn_name, field.name + ), + )); + }; + key.push(PreviewKeyField { + ordinal: ordinal as u32, + field: field.name.clone(), + value, + }); + } + let mut args = Vec::with_capacity(reduce.args.len()); + for arg in &reduce.args { + let Knowledge::Known(value) = source_knowledge(&arg.source) else { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure reduce `{}` arg `{}` must resolve from input, default, or trusted preset", + reduce.fn_name, arg.name + ), + )); + }; + args.push(CompiledPureArg { + name: arg.name.clone(), + value, + }); + } + Ok(CompiledPureReduce { + pure_fn: reduce.fn_name.clone(), + client_module: reduce.client_module.clone(), + client_export: reduce.client_export.clone(), + scope: PreviewScope { + partition: PreviewPartition::Unit, + model: reduce.model.clone(), + key, + }, + args, + assign: reduce.assign.clone(), + occurrence_ordinal, + projection_refs, + }) +} + fn evaluate_expression( expression: &ManifestProjectionExpression, slots: &BTreeMap<&str, Knowledge>, diff --git a/distributed_cli/src/client_compiler/projection_delta/wire.rs b/distributed_cli/src/client_compiler/projection_delta/wire.rs index dc7f04a99..63830091e 100644 --- a/distributed_cli/src/client_compiler/projection_delta/wire.rs +++ b/distributed_cli/src/client_compiler/projection_delta/wire.rs @@ -211,21 +211,39 @@ impl ProjectionDeltaIdentity { #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub(crate) enum ProjectionSurfaceIdentity { Role { name: String }, - Application { name: String, roles: Vec }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } impl ProjectionSurfaceIdentity { fn validate(&self) -> Result<(), ClientCompileError> { match self { Self::Role { name } => nonempty(name, "role surface"), - Self::Application { name, roles } => { + Self::Application { + name, + eligible_roles, + schema_roles, + } => { nonempty(name, "application surface")?; - if roles.is_empty() { + if eligible_roles.is_empty() { + return Err(invalid( + "ProjectionDelta application eligible roles must be sorted, unique, and non-empty", + )); + } + validate_names(eligible_roles, "application eligible roles")?; + if schema_roles.is_empty() + || !schema_roles + .iter() + .all(|role| eligible_roles.iter().any(|eligible| eligible == role)) + { return Err(invalid( - "ProjectionDelta application roles must be sorted, unique, and non-empty", + "ProjectionDelta application schema roles must be a sorted, unique, non-empty subset of eligible roles", )); } - validate_names(roles, "application roles") + validate_names(schema_roles, "application schema roles") } } } @@ -864,7 +882,8 @@ mod tests { let mut delta = vector(); delta.identity.surface = ProjectionSurfaceIdentity::Application { name: "web".into(), - roles, + eligible_roles: roles, + schema_roles: Vec::new(), }; assert!( delta.canonical_bytes().is_err(), @@ -875,7 +894,8 @@ mod tests { let mut delta = vector(); delta.identity.surface = ProjectionSurfaceIdentity::Application { name: "web".into(), - roles: vec!["admin".into(), "user".into()], + eligible_roles: vec!["admin".into(), "user".into()], + schema_roles: vec!["admin".into(), "user".into()], }; assert!(delta.canonical_bytes().is_ok()); } diff --git a/distributed_cli/src/client_compiler/render/commands.rs b/distributed_cli/src/client_compiler/render/commands.rs index f2e8c2045..d927b911c 100644 --- a/distributed_cli/src/client_compiler/render/commands.rs +++ b/distributed_cli/src/client_compiler/render/commands.rs @@ -21,7 +21,8 @@ pub(super) fn render_commands(manifest: &ClientManifest) -> Result Result Result Result` named export. + ClientModule { module: String, export: String }, + /// wasm-pack package under `$lib`; host is generated in pures.ts. + WasmPackage { package: String, export: String }, +} + +/// Collect unique pure functions from command projection extensions. +fn pure_function_inventory( + manifest: &ClientManifest, +) -> Result, ClientCompileError> { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + for command in &manifest.commands { + let Some(projection) = &command.extensions.projection else { + continue; + }; + for reduce in &projection.pure_reduces { + if !seen.insert(reduce.fn_name.clone()) { + continue; + } + let hand = !reduce.client_module.is_empty() || !reduce.client_export.is_empty(); + let wasm = !reduce.wasm_package.is_empty() || !reduce.wasm_export.is_empty(); + let delivery = if hand && !wasm { + PureDelivery::ClientModule { + module: reduce.client_module.clone(), + export: reduce.client_export.clone(), + } + } else if wasm && !hand { + PureDelivery::WasmPackage { + package: reduce.wasm_package.clone(), + export: reduce.wasm_export.clone(), + } + } else { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure `{}` must declare either client_module+client_export or wasm_package+wasm_export", + reduce.fn_name + ), + )); + }; + out.push((reduce.fn_name.clone(), delivery)); + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(out) +} + +/// Generate `pures.ts` mapping pure fn ids to hosts / hand imports. +pub(super) fn render_pures(manifest: &ClientManifest) -> Result, ClientCompileError> { + let inventory = pure_function_inventory(manifest)?; + if inventory.is_empty() { + return Ok(None); + } + let mut sections = vec![ + "/** GENERATED by distributed client. Pure functions for projection.pureReduces. */" + .to_string(), + ]; + let needs_wasm = inventory + .iter() + .any(|(_, d)| matches!(d, PureDelivery::WasmPackage { .. })); + if needs_wasm { + sections.push( + "import { createWasmJsonPure } from '@hops-ops/distributed/replica';".into(), + ); + } + let mut entries = Vec::new(); + let mut ready_calls = Vec::new(); + for (index, (fn_name, delivery)) in inventory.iter().enumerate() { + match delivery { + PureDelivery::ClientModule { module, export } => { + let alias = format!("pure_{index}"); + // From generated// to $lib/ + let rel = format!("../../{module}.js"); + sections.push(format!("import {{ {export} as {alias} }} from '{rel}';")); + entries.push(format!(" {}: {alias}", quoted_property(fn_name))); + } + PureDelivery::WasmPackage { package, export } => { + let host = format!("pureHost_{index}"); + // From generated// to $lib/.js (wasm-pack entry) + let rel = format!("../../{package}.js"); + sections.push(format!( + "const {host} = createWasmJsonPure({{\n load: () => import('{rel}'),\n exportName: {export_lit}\n}});", + export_lit = serde_json::to_string(export).unwrap_or_else(|_| "\"\"".into()), + )); + entries.push(format!(" {}: {host}.pure", quoted_property(fn_name))); + ready_calls.push(format!(" await {host}.ensureReady();")); + } + } + } + sections.push(format!( + "export const PURE_FUNCTIONS = {{\n{}\n}} as const;", + entries.join(",\n") + )); + if !ready_calls.is_empty() { + sections.push(format!( + "/** Instantiate WASM pure hosts (no-op when none / already ready). */\nexport async function ensurePureFunctionsReady(): Promise {{\n{}\n}}", + ready_calls.join("\n") + )); + } + Ok(Some(format!("{}\n", sections.join("\n\n")))) +} + fn validate_command_namespaces(commands: &[ManifestCommand]) -> Result<(), ClientCompileError> { const RESERVED_SEGMENTS: [&str; 3] = ["__proto__", "constructor", "prototype"]; diff --git a/distributed_cli/src/client_compiler/render/operation.rs b/distributed_cli/src/client_compiler/render/operation.rs index 94facd907..8b4858a7f 100644 --- a/distributed_cli/src/client_compiler/render/operation.rs +++ b/distributed_cli/src/client_compiler/render/operation.rs @@ -20,7 +20,7 @@ pub(super) fn render_operation_module( let replica_value_import = variable_codec_uses_replica_value(&operation.variable_codec).then_some(", ReplicaValue"); Ok(format!( - "/** GENERATED by dctl client. Do not edit. */\n\ + "/** GENERATED by distributed client. Do not edit. */\n\ import type {{ ReplicaOperationArtifact{} }} from '@hops-ops/distributed/replica';\n\ \n\ {variables}\n\ diff --git a/distributed_cli/src/client_compiler/render/project.rs b/distributed_cli/src/client_compiler/render/project.rs index fdda77d31..c527152c0 100644 --- a/distributed_cli/src/client_compiler/render/project.rs +++ b/distributed_cli/src/client_compiler/render/project.rs @@ -46,6 +46,14 @@ pub(crate) fn render_project( path: "commands.ts".into(), contents: render_commands(manifest)?, }); + let pures = super::commands::render_pures(manifest)?; + let has_pures = pures.is_some(); + if let Some(pures) = pures { + files.push(GeneratedClientFile { + path: "pures.ts".into(), + contents: pures, + }); + } files.push(GeneratedClientFile { path: "protocol.ts".into(), contents: render_protocol(manifest)?, @@ -60,7 +68,7 @@ pub(crate) fn render_project( }); files.push(GeneratedClientFile { path: "index.ts".into(), - contents: render_index(&operations), + contents: render_index(&operations, has_pures), }); files.push(GeneratedClientFile { path: "manifest.json".into(), @@ -92,7 +100,7 @@ fn render_protocol(manifest: &ClientManifest) -> Result String { +fn render_index(operations: &[CompiledOperation], has_pures: bool) -> String { let mut lines = vec![ "/** GENERATED public entrypoint. */".to_string(), "export * from './commands.js';".into(), "export * from './protocol.js';".into(), "export * from './routes.js';".into(), ]; + if has_pures { + lines.push("export * from './pures.js';".into()); + } for operation in operations { let module = operation .module_path @@ -325,7 +336,7 @@ fn render_sveltekit( } let mut sections = vec![ - "/** GENERATED by dctl client. Do not edit. */".to_string(), + "/** GENERATED by distributed client. Do not edit. */".to_string(), [ "import {", " createDistributedSvelteKit,", diff --git a/distributed_cli/src/client_compiler/tests.rs b/distributed_cli/src/client_compiler/tests.rs index 375d57a8f..78ebc1305 100644 --- a/distributed_cli/src/client_compiler/tests.rs +++ b/distributed_cli/src/client_compiler/tests.rs @@ -2296,7 +2296,12 @@ fn application_surfaces_are_explicit_and_fingerprint_separate_chunks() { "query Todos { todos { id } }", ); let mut common = manifest(); - common["surface"] = json!({"kind": "application", "name": "web-common", "roles": ["user"]}); + common["surface"] = json!({ + "kind": "application", + "name": "web-common", + "eligible_roles": ["user"], + "schema_roles": ["user"], + }); refresh_schema_fingerprint(&mut common); let common_fingerprint = common["schema_fingerprint"] .as_str() @@ -2304,7 +2309,7 @@ fn application_surfaces_are_explicit_and_fingerprint_separate_chunks() { .to_string(); let common_project = compile_client(ClientCompileInput::new( common.clone(), - ClientSurfaceSelector::application("web-common"), + ClientSurfaceSelector::application("web-common", ["user"], ["user"]), vec![document.clone()], )) .expect("compile exact application surface"); @@ -2319,11 +2324,16 @@ fn application_surfaces_are_explicit_and_fingerprint_separate_chunks() { assert_eq!(mismatch.code, "client.manifest.surface_mismatch"); let mut elevated = manifest(); - elevated["surface"] = json!({"kind": "application", "name": "web-admin", "roles": ["admin"]}); + elevated["surface"] = json!({ + "kind": "application", + "name": "web-admin", + "eligible_roles": ["admin"], + "schema_roles": ["admin"], + }); refresh_schema_fingerprint(&mut elevated); let elevated_project = compile_client(ClientCompileInput::new( elevated, - ClientSurfaceSelector::application("web-admin"), + ClientSurfaceSelector::application("web-admin", ["admin"], ["admin"]), vec![document], )) .expect("compile separate elevated application surface"); @@ -2689,7 +2699,7 @@ fn rejects_well_formed_but_incompatible_protocol_fingerprint() { )) .expect_err("protocol drift"); assert_eq!(error.code, "client.manifest.protocol_fingerprint"); - assert!(error.message.contains("matching dctl version")); + assert!(error.message.contains("matching distributed version")); } #[test] @@ -4005,7 +4015,7 @@ fn source_paths_cannot_inject_generated_typescript() { .expect("compile adversarial source path"); let module = file(&project, &project.operations[0].module_path); - assert!(module.starts_with("/** GENERATED by dctl client. Do not edit. */\n")); + assert!(module.starts_with("/** GENERATED by distributed client. Do not edit. */\n")); assert!(!module.contains("compromised")); assert!( !module.contains("\"source\""), diff --git a/distributed_cli/src/contracts/artifact.rs b/distributed_cli/src/contracts/artifact.rs new file mode 100644 index 000000000..dd9d225d1 --- /dev/null +++ b/distributed_cli/src/contracts/artifact.rs @@ -0,0 +1,144 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; + +/// The reference-level artifact vocabulary shared by lifecycle checks. +/// +/// These variants identify semantic owners; they do not carry the payload of +/// the referenced artifact. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +#[serde(deny_unknown_fields)] +pub enum ContractArtifactKind { + /// Dialect-aware migration inventory and checksum registration. + MigrationInventory, + /// Role/application-visible surface or client manifest. + SurfaceClientManifest, + /// Compiler-owned generated client artifact tree. + GeneratedClientTree, + /// Framework-owned logical application manifest. + ApplicationManifest, + /// Framework-owned process-selection deployment plan. + DeploymentPlan, + /// Optional deployable UI program descriptor. + ClientProgramDescriptor, + /// Renderer-neutral resolved deployment. + ResolvedDeployment, +} + +impl ContractArtifactKind { + /// Every kind accepted by the versioned catalog. + pub const ALL: [Self; 7] = [ + Self::MigrationInventory, + Self::SurfaceClientManifest, + Self::GeneratedClientTree, + Self::ApplicationManifest, + Self::DeploymentPlan, + Self::ClientProgramDescriptor, + Self::ResolvedDeployment, + ]; + + /// The stable wire spelling for this kind. + pub const fn as_str(self) -> &'static str { + match self { + Self::MigrationInventory => "migration_inventory", + Self::SurfaceClientManifest => "surface_client_manifest", + Self::GeneratedClientTree => "generated_client_tree", + Self::ApplicationManifest => "application_manifest", + Self::DeploymentPlan => "deployment_plan", + Self::ClientProgramDescriptor => "client_program_descriptor", + Self::ResolvedDeployment => "resolved_deployment", + } + } + + /// Parse a stable wire spelling without accepting arbitrary enum values. + pub fn parse(value: &str) -> Option { + Self::ALL.into_iter().find(|kind| kind.as_str() == value) + } +} + +impl fmt::Display for ContractArtifactKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// A portable content or owner-defined identity for one artifact. +/// +/// `value` is intentionally opaque to this foundation. Producers may use a +/// content digest or another stable identity, but paths, timestamps, machine +/// locations, environment values, and secrets are not valid identity material. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactIdentity { + /// The semantic kind whose identity is being represented. + pub kind: ContractArtifactKind, + /// Stable identity value, commonly `sha256:`. + pub value: String, +} + +impl ArtifactIdentity { + /// Construct an identity from an already validated stable value. + pub fn new(kind: ContractArtifactKind, value: impl Into) -> Self { + Self { + kind, + value: value.into(), + } + } + + /// Create a SHA-256 identity from canonical bytes. + pub fn from_canonical_bytes(kind: ContractArtifactKind, bytes: &[u8]) -> Self { + Self::new(kind, canonical_digest(bytes)) + } +} + +/// Source references and generator identity for an artifact. +/// +/// `generator` is descriptive metadata only. The catalog never executes it +/// and never interprets it as a shell command. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactProvenance { + /// Catalog-relative authoritative source files or bounded glob patterns. + pub sources: std::collections::BTreeSet, + /// Stable generator identifier, not an executable command. + pub generator: String, + /// Optional source revision recorded by the producer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_revision: Option, + /// Maximum number of files a source glob may resolve to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glob_limit: Option, +} + +/// The immediate predecessor link in the lifecycle chain. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactPredecessor { + /// Catalog entry ID of the predecessor. + #[serde(alias = "entry")] + pub entry_id: String, + /// Identity observed when this artifact was produced. + pub identity: ArtifactIdentity, +} + +/// An environment-owned policy reference without policy values or secrets. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentPolicyReference { + /// Immutable environment policy identity. + pub identity: String, + /// Human-stable policy name. + pub name: String, + /// Portable owner/reference identifier. + pub reference: String, +} + +fn hex_digest(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub(crate) fn canonical_digest(bytes: &[u8]) -> String { + format!("sha256:{}", hex_digest(bytes)) +} diff --git a/distributed_cli/src/contracts/catalog.rs b/distributed_cli/src/contracts/catalog.rs new file mode 100644 index 000000000..68f0a7759 --- /dev/null +++ b/distributed_cli/src/contracts/catalog.rs @@ -0,0 +1,1522 @@ +use super::artifact::canonical_digest; +use super::diagnostic::is_secret_like; +use super::{ + ArtifactIdentity, ArtifactPredecessor, ArtifactProvenance, ContractArtifactKind, + ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, EnvironmentPolicyReference, +}; +use serde::de::Error as DeError; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +/// Version of the repository catalog wire format. +pub const CONTRACT_CATALOG_SCHEMA_VERSION: u32 = 1; +/// Version of the declarative client inventory wire format. +pub const CLIENT_DECLARATION_SCHEMA_VERSION: u32 = 1; +/// Maximum accepted catalog or client-inventory source size. +pub const MAX_CATALOG_BYTES: usize = 1024 * 1024; +/// Maximum number of entries in one catalog. +pub const MAX_CATALOG_ENTRIES: usize = 256; +/// Maximum number of physical files walked by one catalog validation. +pub const MAX_CATALOG_FILES: usize = 8_192; +/// Maximum number of unique physical directories walked by one validation. +pub const MAX_CATALOG_DIRECTORIES: usize = 2_048; +/// Maximum number of physical directory entries inspected by one validation. +pub const MAX_CATALOG_DIRECTORY_ENTRIES: usize = 4_096; +/// Maximum physical directory nesting depth accepted during discovery. +pub const MAX_CATALOG_DIRECTORY_DEPTH: usize = 64; +/// Maximum matches permitted for one catalog source glob. +pub const MAX_CATALOG_GLOB_MATCHES: usize = 2_048; + +const MAX_CATALOG_STRING_BYTES: usize = 4 * 1024; +/// Maximum JSON nesting depth accepted before typed catalog deserialization. +pub const MAX_CATALOG_JSON_DEPTH: usize = 24; +const MAX_CLIENT_DECLARATIONS: usize = 64; +const MAX_CLIENT_DOCUMENTS: usize = 64; + +/// A typed, deterministic catalog validation error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractError { + code: ContractDiagnosticCode, + message: String, +} + +impl ContractError { + pub(crate) fn new(code: ContractDiagnosticCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + /// Stable diagnostic classification for this error. + pub fn code(&self) -> ContractDiagnosticCode { + self.code + } + + /// Safe explanatory message. + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for ContractError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for ContractError {} + +/// A stable logical scope in the contract catalog. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ContractScope { + /// Stable scope ID; it is not a filesystem path. + pub id: String, +} + +/// One reference-only artifact declaration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ContractEntry { + /// Stable catalog entry ID. + #[serde(default)] + pub id: String, + /// Artifact kind owned by another semantic module. + pub kind: ContractArtifactKind, + /// Logical scope containing this artifact. + pub scope: ContractScope, + /// Authoritative semantic owner ID. + pub owner: String, + /// Canonical artifact identity or producer reference. + pub identity: ArtifactIdentity, + /// Source and generator provenance, without semantic payloads. + pub provenance: ArtifactProvenance, + /// Immediate predecessor identity, when this artifact has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub predecessor: Option, + /// Derived output ID to catalog-relative path. + #[serde(default)] + pub outputs: BTreeMap, + /// Lifecycle phases that may consume this reference. + #[serde(default)] + pub lifecycle: BTreeSet, + /// Environment policy identity/name/reference for deployment artifacts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_policy: Option, +} + +/// The repository-level catalog. Its map representation is canonical JSON. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ContractCatalog { + /// Catalog schema version. + pub schema_version: u32, + /// Entries keyed by stable entry ID. + #[serde(deserialize_with = "deserialize_entries")] + pub entries: BTreeMap, +} + +impl ContractCatalog { + /// Parse and structurally validate catalog JSON without touching the filesystem. + pub fn from_json_str(input: &str) -> Result { + let value = parse_json_document(input, "catalog")?; + reject_unknown_artifact_kinds(&value)?; + let catalog: Self = serde_json::from_value(value).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("parse catalog JSON: {error}"), + ) + })?; + catalog.validate_structure()?; + Ok(catalog) + } + + /// Alias for [`Self::from_json_str`]. + pub fn parse(input: &str) -> Result { + Self::from_json_str(input) + } + + /// Read, parse, and physically validate a catalog file. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let root = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let canonical_root = fs::canonicalize(root).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("resolve catalog repository root: {error}"), + ) + })?; + let canonical_catalog = fs::canonicalize(path).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("resolve catalog path: {error}"), + ) + })?; + if !canonical_catalog.starts_with(&canonical_root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + "catalog path resolves outside the repository root", + )); + } + let metadata = fs::metadata(&canonical_catalog).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect catalog path: {error}"), + ) + })?; + if !metadata.is_file() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + "catalog path is not a regular file", + )); + } + let bytes = read_bounded_file(&canonical_catalog, MAX_CATALOG_BYTES, "catalog")?; + let input = std::str::from_utf8(&bytes).map_err(|_| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + "catalog is not UTF-8", + ) + })?; + let catalog = Self::from_json_str(input)?; + catalog.validate_paths(canonical_root)?; + Ok(catalog) + } + + /// Alias for [`Self::from_path`]. + pub fn load(path: impl AsRef) -> Result { + Self::from_path(path) + } + + /// Serialize sorted catalog maps/sets as canonical bytes. + pub fn canonical_bytes(&self) -> Result, ContractError> { + self.validate_structure()?; + serde_json::to_vec(self).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("serialize canonical catalog: {error}"), + ) + }) + } + + /// Validate all declared paths against a repository root without writing. + pub fn validate_paths(&self, root: impl AsRef) -> Result<(), ContractError> { + self.validate_structure()?; + let root = fs::canonicalize(root.as_ref()).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("resolve repository root: {error}"), + ) + })?; + let metadata = fs::metadata(&root).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect repository root: {error}"), + ) + })?; + if !metadata.is_dir() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + "repository root is not a directory", + )); + } + + let mut walker = PhysicalPathWalker::new(root); + for entry in self.entries.values() { + for source in &entry.provenance.sources { + walker.resolve_declared_path( + source, + entry.provenance.glob_limit, + "catalog source", + )?; + } + for output in entry.outputs.values() { + if contains_glob(output) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("output path `{output}` may not contain a glob"), + )); + } + walker.resolve_declared_path(output, None, "catalog output")?; + } + } + Ok(()) + } + + /// Validate and return a pure aggregate result for a read-only check. + pub fn check(&self, root: impl AsRef) -> ContractCheckResult { + let mut result = ContractCheckResult::default(); + if let Err(error) = self.validate_structure() { + result.push(diagnostic_for_error(&error)); + return result; + } + let canonical_catalog = match self.canonical_bytes() { + Ok(bytes) => bytes, + Err(error) => { + result.push(diagnostic_for_error(&error)); + return result; + } + }; + result.catalog_identity = Some(canonical_digest(&canonical_catalog)); + result.artifacts = self + .entries + .iter() + .map(|(id, entry)| (id.clone(), entry.identity.clone())) + .collect(); + if let Err(error) = self.validate_paths(root) { + result.push(diagnostic_for_error(&error)); + } + result + } + + /// Alias emphasizing that collection is pure and read-only. + pub fn collect(&self, root: impl AsRef) -> ContractCheckResult { + self.check(root) + } + + fn validate_structure(&self) -> Result<(), ContractError> { + if self.schema_version != CONTRACT_CATALOG_SCHEMA_VERSION { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!( + "unsupported catalog schema version {}; expected {}", + self.schema_version, CONTRACT_CATALOG_SCHEMA_VERSION + ), + )); + } + if self.entries.is_empty() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + "catalog must declare at least one entry", + )); + } + if self.entries.len() > MAX_CATALOG_ENTRIES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "catalog declares {} entries; maximum is {MAX_CATALOG_ENTRIES}", + self.entries.len() + ), + )); + } + + let mut scopes = BTreeMap::<&str, &str>::new(); + let mut owners = BTreeMap::<&str, &str>::new(); + let mut outputs = BTreeMap::<&str, (&str, &str)>::new(); + let mut output_paths = BTreeMap::<&str, (&str, &str)>::new(); + + for (entry_id, entry) in &self.entries { + validate_identifier(entry_id, "catalog entry ID")?; + if entry.id != *entry_id { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!( + "entry key `{entry_id}` does not match entry ID `{}`", + entry.id + ), + )); + } + validate_identifier(&entry.scope.id, "contract scope ID")?; + validate_identifier(&entry.owner, "contract owner ID")?; + if let Some(previous) = scopes.insert(&entry.scope.id, entry_id) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateScope, + format!( + "scope `{}` is declared by both `{previous}` and `{entry_id}`", + entry.scope.id + ), + )); + } + if let Some(previous) = owners.insert(&entry.owner, entry_id) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateOwner, + format!( + "owner `{}` is declared by both `{previous}` and `{entry_id}`", + entry.owner + ), + )); + } + if entry.identity.kind != entry.kind { + return Err(ContractError::new( + ContractDiagnosticCode::ChainKindMismatch, + format!("entry `{entry_id}` identity kind does not match its artifact kind"), + )); + } + validate_stable_value(&entry.identity.value, "artifact identity")?; + if entry.provenance.sources.is_empty() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("entry `{entry_id}` must declare at least one source"), + )); + } + validate_stable_value(&entry.provenance.generator, "generator identity")?; + if let Some(revision) = &entry.provenance.source_revision { + validate_stable_value(revision, "source revision")?; + } + if let Some(limit) = entry.provenance.glob_limit { + if limit == 0 || limit > MAX_CATALOG_GLOB_MATCHES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "source glob limit {limit} is outside 1..={MAX_CATALOG_GLOB_MATCHES}" + ), + )); + } + } + for source in &entry.provenance.sources { + validate_catalog_path(source, true)?; + if contains_glob(source) { + validate_bounded_glob(source)?; + if entry.provenance.glob_limit.is_none() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("source glob `{source}` has no finite match limit"), + )); + } + } + } + if entry.outputs.is_empty() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("entry `{entry_id}` must declare at least one output"), + )); + } + if entry.outputs.len() > MAX_CATALOG_ENTRIES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("entry `{entry_id}` declares too many outputs"), + )); + } + for (output_id, output_path) in &entry.outputs { + validate_identifier(output_id, "catalog output ID")?; + validate_catalog_path(output_path, false)?; + if let Some((previous_entry, previous_path)) = + outputs.insert(output_id, (entry_id, output_path)) + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateOutput, + format!( + "output ID `{output_id}` is declared by `{previous_entry}` ({previous_path}) and `{entry_id}` ({output_path})" + ), + )); + } + if let Some((previous_entry, previous_id)) = + output_paths.insert(output_path, (entry_id, output_id)) + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateOutput, + format!( + "output path `{output_path}` is declared by `{previous_entry}` ({previous_id}) and `{entry_id}` ({output_id})" + ), + )); + } + } + for lifecycle in &entry.lifecycle { + validate_identifier(lifecycle, "lifecycle policy")?; + } + if let Some(policy) = &entry.environment_policy { + validate_stable_value(&policy.identity, "environment policy identity")?; + validate_identifier(&policy.name, "environment policy name")?; + validate_stable_value(&policy.reference, "environment policy reference")?; + } + if let Some(predecessor) = &entry.predecessor { + validate_identifier(&predecessor.entry_id, "predecessor entry ID")?; + validate_stable_value(&predecessor.identity.value, "predecessor identity")?; + } + } + + self.validate_predecessors() + } + + fn validate_predecessors(&self) -> Result<(), ContractError> { + for (entry_id, entry) in &self.entries { + let Some(predecessor) = &entry.predecessor else { + continue; + }; + let Some(predecessor_entry) = self.entries.get(&predecessor.entry_id) else { + return Err(ContractError::new( + ContractDiagnosticCode::ChainMissingPredecessor, + format!( + "entry `{entry_id}` references missing predecessor `{}`", + predecessor.entry_id + ), + )); + }; + if predecessor.identity.kind != predecessor_entry.kind { + return Err(ContractError::new( + ContractDiagnosticCode::ChainKindMismatch, + format!( + "entry `{entry_id}` predecessor `{}` declares kind {} but the entry is {}", + predecessor.entry_id, predecessor.identity.kind, predecessor_entry.kind + ), + )); + } + if predecessor.identity.value != predecessor_entry.identity.value { + return Err(ContractError::new( + ContractDiagnosticCode::ChainIdentityMismatch, + format!( + "entry `{entry_id}` predecessor `{}` has a stale identity", + predecessor.entry_id + ), + )); + } + } + + for start in self.entries.keys() { + let mut seen = BTreeSet::new(); + let mut current = start.as_str(); + while let Some(entry) = self.entries.get(current) { + if !seen.insert(current.to_string()) { + return Err(ContractError::new( + ContractDiagnosticCode::ChainCycle, + format!("predecessor chain cycles at `{current}`"), + )); + } + let Some(predecessor) = &entry.predecessor else { + break; + }; + current = &predecessor.entry_id; + } + } + Ok(()) + } +} + +/// One stable client declaration shared by Rust validation and the Vite config. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientDeclaration { + /// Virtual module exposed to the application. + pub module: String, + /// Rust-declared application surface. + pub surface: String, + /// Co-located GraphQL files or bounded globs. + #[serde(deserialize_with = "deserialize_documents")] + pub documents: BTreeSet, + /// Compiler-owned output directory, relative to the UI root. + pub output: String, + /// Optional Rust surface export for non-default clients. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_entrypoint: Option, +} + +/// Versioned application-owned client inventory. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientInventory { + /// Inventory schema version. + pub schema_version: u32, + /// Stable client declarations. + pub clients: Vec, +} + +impl ClientInventory { + /// Parse and validate the shared client inventory without executing anything. + pub fn from_json_str(input: &str) -> Result { + let value = parse_json_document(input, "client inventory")?; + let inventory: Self = serde_json::from_value(value).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("parse client inventory JSON: {error}"), + ) + })?; + inventory.validate()?; + Ok(inventory) + } + + /// Read and validate a client inventory file. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = read_bounded_file(path.as_ref(), MAX_CATALOG_BYTES, "client inventory")?; + let input = std::str::from_utf8(&bytes).map_err(|_| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + "client inventory is not UTF-8", + ) + })?; + Self::from_json_str(input) + } + + /// Alias for [`Self::from_json_str`]. + pub fn parse(input: &str) -> Result { + Self::from_json_str(input) + } + + /// Serialize a normalized client order and sorted document sets. + pub fn canonical_bytes(&self) -> Result, ContractError> { + self.validate()?; + let mut clients = self.clients.clone(); + clients.sort_by(|left, right| left.module.cmp(&right.module)); + serde_json::to_vec(&Self { + schema_version: self.schema_version, + clients, + }) + .map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("serialize canonical client inventory: {error}"), + ) + }) + } + + /// Validate the declaration schema and uniqueness constraints. + pub fn validate(&self) -> Result<(), ContractError> { + if self.schema_version != CLIENT_DECLARATION_SCHEMA_VERSION { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!( + "unsupported client inventory schema version {}; expected {}", + self.schema_version, CLIENT_DECLARATION_SCHEMA_VERSION + ), + )); + } + if self.clients.is_empty() || self.clients.len() > MAX_CLIENT_DECLARATIONS { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("client inventory must contain 1..={MAX_CLIENT_DECLARATIONS} declarations"), + )); + } + let mut modules = BTreeSet::new(); + let mut surfaces = BTreeSet::new(); + let mut outputs = BTreeSet::new(); + for client in &self.clients { + validate_client_module(&client.module)?; + validate_identifier(&client.surface, "client surface")?; + validate_catalog_path(&client.output, false)?; + if contains_glob(&client.output) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("client output `{}` may not contain a glob", client.output), + )); + } + if client.documents.is_empty() || client.documents.len() > MAX_CLIENT_DOCUMENTS { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "client `{}` must contain 1..={MAX_CLIENT_DOCUMENTS} documents", + client.module + ), + )); + } + for document in &client.documents { + validate_client_document(document)?; + } + if let Some(entrypoint) = &client.manifest_entrypoint { + validate_entrypoint(entrypoint)?; + } + if !modules.insert(&client.module) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateScope, + format!( + "client module `{}` is declared more than once", + client.module + ), + )); + } + if !surfaces.insert(&client.surface) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateScope, + format!( + "client surface `{}` is declared more than once", + client.surface + ), + )); + } + if !outputs.insert(&client.output) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateOutput, + format!( + "client output `{}` is declared more than once", + client.output + ), + )); + } + } + Ok(()) + } +} + +fn deserialize_entries<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum EntryContainer { + Map(BTreeMap), + List(Vec), + } + + let container = EntryContainer::deserialize(deserializer)?; + let mut entries = BTreeMap::new(); + match container { + EntryContainer::Map(map) => { + for (key, mut entry) in map { + if entry.id.is_empty() { + entry.id = key.clone(); + } + if entry.id != key { + return Err(D::Error::custom(format!( + "entry key `{key}` does not match entry ID `{}`", + entry.id + ))); + } + entries.insert(key, entry); + } + } + EntryContainer::List(list) => { + for entry in list { + if entry.id.is_empty() { + return Err(D::Error::custom("catalog list entries require an id")); + } + let id = entry.id.clone(); + if entries.insert(id.clone(), entry).is_some() { + return Err(D::Error::custom(format!("duplicate catalog entry `{id}`"))); + } + } + } + } + Ok(entries) +} + +fn deserialize_documents<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let documents = Vec::::deserialize(deserializer)?; + let mut unique_documents = BTreeSet::new(); + for document in documents { + if !unique_documents.insert(document.clone()) { + return Err(D::Error::custom(format!( + "duplicate client document `{document}`" + ))); + } + } + Ok(unique_documents) +} + +fn parse_json_document(input: &str, label: &str) -> Result { + if input.len() > MAX_CATALOG_BYTES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "{label} is {} bytes; maximum supported size is {MAX_CATALOG_BYTES}", + input.len() + ), + )); + } + let value: Value = serde_json::from_str(input).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("parse {label} JSON: {error}"), + ) + })?; + validate_json_value(&value, 0, "$", label)?; + Ok(value) +} + +fn validate_json_value( + value: &Value, + depth: usize, + path: &str, + label: &str, +) -> Result<(), ContractError> { + if depth > MAX_CATALOG_JSON_DEPTH { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} exceeds maximum JSON nesting depth"), + )); + } + match value { + Value::Object(object) => { + for (key, child) in object { + if is_forbidden_field(key) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + format!("{label} field `{key}` is not permitted in portable metadata"), + )); + } + validate_json_value(child, depth + 1, &format!("{path}.{key}"), label)?; + } + } + Value::Array(array) => { + if array.len() > MAX_CATALOG_ENTRIES * 32 { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} array at {path} is too large"), + )); + } + for (index, child) in array.iter().enumerate() { + validate_json_value(child, depth + 1, &format!("{path}[{index}]"), label)?; + } + } + Value::String(string) => { + if string.len() > MAX_CATALOG_STRING_BYTES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} string at {path} is too large"), + )); + } + if is_secret_like(string) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + format!("{label} contains a credential-like value at {path}"), + )); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + Ok(()) +} + +fn reject_unknown_artifact_kinds(value: &Value) -> Result<(), ContractError> { + let Some(entries) = value.get("entries") else { + return Ok(()); + }; + match entries { + Value::Object(entries) => { + for (entry_id, entry) in entries { + check_kind(entry, entry_id)?; + } + } + Value::Array(entries) => { + for (index, entry) in entries.iter().enumerate() { + check_kind(entry, &format!("entries[{index}]"))?; + } + } + _ => {} + } + Ok(()) +} + +fn check_kind(value: &Value, entry_id: &str) -> Result<(), ContractError> { + let Some(kind) = value.get("kind") else { + return Ok(()); + }; + let Some(kind) = kind.as_str() else { + return Ok(()); + }; + if ContractArtifactKind::parse(kind).is_none() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnknownKind, + format!("entry `{entry_id}` uses unknown artifact kind `{kind}`"), + )); + } + Ok(()) +} + +fn is_forbidden_field(field: &str) -> bool { + matches!( + field.to_ascii_lowercase().as_str(), + "credential" + | "credentials" + | "connection_string" + | "connectionstring" + | "password" + | "token" + | "secret" + | "secrets" + | "private_key" + | "privatekey" + | "header" + | "headers" + | "environment" + | "environment_value" + | "environment_values" + | "raw_environment" + | "raw_environment_values" + | "env" + | "key" + ) +} + +fn validate_identifier(value: &str, label: &str) -> Result<(), ContractError> { + if value.is_empty() || value.trim() != value { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("{label} must be a non-empty trimmed value"), + )); + } + if is_secret_like(value) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + format!("{label} contains credential-like material"), + )); + } + if value.len() > MAX_CATALOG_STRING_BYTES + || value.contains('\0') + || value.contains('\\') + || value.contains("..") + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} is too long or not portable"), + )); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte)) + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("{label} contains unsupported characters"), + )); + } + Ok(()) +} + +fn validate_stable_value(value: &str, label: &str) -> Result<(), ContractError> { + if value.is_empty() || value.trim() != value || value.len() > MAX_CATALOG_STRING_BYTES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("{label} must be a non-empty trimmed value"), + )); + } + if is_secret_like(value) + || value.contains('\0') + || value.contains('\\') + || value.starts_with('/') + || value.starts_with('~') + || value.contains("/Users/") + || value.contains("/home/") + || value.contains("\\Users\\") + || value.contains("\\home\\") + || looks_like_timestamp(value) + { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + format!("{label} contains non-portable or sensitive material"), + )); + } + Ok(()) +} + +fn looks_like_timestamp(value: &str) -> bool { + value.len() >= 20 + && value.as_bytes().get(4) == Some(&b'-') + && value.as_bytes().get(7) == Some(&b'-') + && value.as_bytes().get(10) == Some(&b'T') +} + +fn validate_catalog_path(value: &str, allow_glob: bool) -> Result<(), ContractError> { + if is_secret_like(value) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + "catalog metadata contains credential-like path material", + )); + } + if value.is_empty() + || value.trim() != value + || value.len() > MAX_CATALOG_STRING_BYTES + || value.contains('\0') + || value.contains('\\') + || value.starts_with('/') + || value.starts_with('~') + || (value.len() >= 2 && value.as_bytes()[1] == b':') + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("catalog path `{value}` is absolute or not portable"), + )); + } + for component in Path::new(value).components() { + if !matches!(component, Component::Normal(_)) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("catalog path `{value}` contains parent or root traversal"), + )); + } + } + if !allow_glob && contains_glob(value) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("catalog path `{value}` contains an unbounded glob"), + )); + } + Ok(()) +} + +fn validate_client_module(value: &str) -> Result<(), ContractError> { + if is_secret_like(value) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + "client module contains credential-like material", + )); + } + if value.len() > MAX_CATALOG_STRING_BYTES + || value.trim() != value + || value.contains('\0') + || value.contains('\\') + || value.contains("..") + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client module `{value}` is not portable"), + )); + } + let mut segments = value.split('/'); + if segments.next() != Some("$distributed") { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client module `{value}` must start with $distributed"), + )); + } + if segments.any(|segment| { + segment.is_empty() + || !segment + .as_bytes() + .first() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + }) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client module `{value}` contains an unsupported segment"), + )); + } + Ok(()) +} + +fn validate_client_document(value: &str) -> Result<(), ContractError> { + validate_catalog_path(value, true)?; + if !value.ends_with(".graphql") && !value.ends_with(".gql") { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client document `{value}` must end in .graphql or .gql"), + )); + } + if value.contains("**") + || value.starts_with('*') + || value.starts_with('?') + || value.starts_with('[') + || value.starts_with(']') + || value.starts_with('{') + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("client document glob `{value}` is unbounded"), + )); + } + Ok(()) +} + +fn validate_entrypoint(value: &str) -> Result<(), ContractError> { + if is_secret_like(value) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + "client manifest entrypoint contains credential-like material", + )); + } + if value.is_empty() + || value.len() > MAX_CATALOG_STRING_BYTES + || value.split("::").any(|segment| { + segment.is_empty() + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client manifest entrypoint `{value}` is not a Rust path"), + )); + } + Ok(()) +} + +fn contains_glob(value: &str) -> bool { + value + .bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{')) +} + +fn validate_bounded_glob(value: &str) -> Result<(), ContractError> { + if value.contains("**") { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("recursive catalog glob `{value}` is not bounded by a match limit"), + )); + } + let components = value.split('/').collect::>(); + let glob_components = components + .iter() + .enumerate() + .filter(|(_, component)| contains_glob(component)) + .map(|(index, _)| index) + .collect::>(); + if glob_components.len() != 1 || glob_components[0] != components.len() - 1 { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("catalog glob `{value}` must match entries in one bounded directory"), + )); + } + Ok(()) +} + +fn glob_parent(value: &str) -> PathBuf { + value + .rsplit_once('/') + .map_or_else(PathBuf::new, |(parent, _)| PathBuf::from(parent)) +} + +fn read_bounded_file(path: &Path, limit: usize, label: &str) -> Result, ContractError> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label}: {error}"), + ) + })?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} must not be a symlink"), + )); + } + if !metadata.is_file() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + format!("{label} must be a regular file"), + )); + } + let file_size = metadata.len(); + if file_size > limit as u64 { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} is {file_size} bytes; maximum supported size is {limit}"), + )); + } + let read_limit = limit.saturating_add(1); + let file = fs::File::open(path).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label}: {error}"), + ) + })?; + let opened_metadata = file.metadata().map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect opened {label}: {error}"), + ) + })?; + if !opened_metadata.is_file() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + format!("opened {label} is not a regular file"), + )); + } + let opened_size = opened_metadata.len(); + if opened_size > limit as u64 { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("opened {label} is {opened_size} bytes; maximum supported size is {limit}"), + )); + } + let mut bytes = Vec::with_capacity(opened_size as usize); + file.take(read_limit as u64) + .read_to_end(&mut bytes) + .map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label}: {error}"), + ) + })?; + if bytes.len() > limit { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "{label} is {} bytes; maximum supported size is {limit}", + bytes.len() + ), + )); + } + Ok(bytes) +} + +struct PhysicalPathWalker { + root: PathBuf, + files: BTreeSet, + directories: BTreeSet, + directory_entries: usize, +} + +impl PhysicalPathWalker { + fn new(root: PathBuf) -> Self { + Self { + root, + files: BTreeSet::new(), + directories: BTreeSet::new(), + directory_entries: 0, + } + } + + fn resolve_declared_path( + &mut self, + declared: &str, + glob_limit: Option, + label: &str, + ) -> Result<(), ContractError> { + if contains_glob(declared) { + let limit = glob_limit.ok_or_else(|| { + ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("{label} glob `{declared}` has no finite match limit"), + ) + })?; + validate_bounded_glob(declared)?; + let final_component = declared.rsplit('/').next().ok_or_else(|| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("{label} glob `{declared}` has no final component"), + ) + })?; + let pattern = glob::Pattern::new(final_component).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("parse {label} glob `{declared}`: {error}"), + ) + })?; + let matches = self.guard_glob_candidates( + &glob_parent(declared), + declared, + label, + &pattern, + limit, + )?; + for matched in &matches { + self.walk_canonical_target(matched, declared, label)?; + } + if matches.is_empty() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("{label} glob `{declared}` matched no entries"), + )); + } + return Ok(()); + } + self.walk_target(&self.root.join(declared), declared, label) + } + + fn guard_glob_candidates( + &mut self, + parent: &Path, + declared: &str, + label: &str, + pattern: &glob::Pattern, + match_limit: usize, + ) -> Result, ContractError> { + let candidate_directory = fs::canonicalize(self.root.join(parent)).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("resolve {label} glob directory `{declared}`: {error}"), + ) + })?; + if !candidate_directory.starts_with(&self.root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} glob directory `{declared}` escapes the repository root"), + )); + } + let metadata = fs::metadata(&candidate_directory).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect {label} glob directory `{declared}`: {error}"), + ) + })?; + if !metadata.is_dir() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("{label} glob directory `{declared}` is not a directory"), + )); + } + let depth = candidate_directory + .strip_prefix(&self.root) + .map_err(|_| { + ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} glob directory `{declared}` escapes the repository root"), + ) + })? + .components() + .count(); + if depth > MAX_CATALOG_DIRECTORY_DEPTH { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "catalog directory depth exceeds {MAX_CATALOG_DIRECTORY_DEPTH} at `{declared}`" + ), + )); + } + if self.directories.insert(candidate_directory.clone()) + && self.directories.len() > MAX_CATALOG_DIRECTORIES + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("catalog directories exceed {MAX_CATALOG_DIRECTORIES} at `{declared}`"), + )); + } + let mut entries = Vec::new(); + for entry in fs::read_dir(&candidate_directory).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label} glob directory `{declared}`: {error}"), + ) + })? { + let entry = entry.map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label} glob directory `{declared}`: {error}"), + ) + })?; + self.record_directory_entry(declared)?; + entries.push(entry); + } + entries.sort_by_key(|entry| entry.file_name()); + + let mut matches = Vec::new(); + for entry in entries { + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + if !pattern.matches(file_name) { + continue; + } + let path = entry.path(); + let symlink = fs::symlink_metadata(&path) + .map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect {label} glob candidate `{declared}`: {error}"), + ) + })? + .file_type() + .is_symlink(); + let canonical = fs::canonicalize(&path).map_err(|error| { + ContractError::new( + if symlink { + ContractDiagnosticCode::CatalogSymlinkEscape + } else { + ContractDiagnosticCode::CatalogPath + }, + format!("resolve {label} glob candidate `{declared}`: {error}"), + ) + })?; + if !canonical.starts_with(&self.root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} glob `{declared}` resolves outside the repository root"), + )); + } + matches.push(canonical); + if matches.len() > match_limit { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} glob `{declared}` exceeds limit {match_limit}"), + )); + } + } + Ok(matches) + } + + fn walk_target( + &mut self, + path: &Path, + declared: &str, + label: &str, + ) -> Result<(), ContractError> { + let canonical = fs::canonicalize(path).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("resolve {label} `{declared}`: {error}"), + ) + })?; + if !canonical.starts_with(&self.root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} `{declared}` resolves outside the repository root"), + )); + } + self.walk_canonical_target(&canonical, declared, label) + } + + fn walk_canonical_target( + &mut self, + canonical: &Path, + declared: &str, + label: &str, + ) -> Result<(), ContractError> { + let metadata = fs::metadata(canonical).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect {label} `{declared}`: {error}"), + ) + })?; + if metadata.is_file() { + self.record_file(canonical.to_path_buf(), declared) + } else if metadata.is_dir() { + let depth = self.relative_depth(canonical, declared)?; + self.walk_directory(canonical, declared, depth) + } else { + Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + format!("{label} `{declared}` is not a regular file or directory"), + )) + } + } + + fn relative_depth(&self, path: &Path, declared: &str) -> Result { + path.strip_prefix(&self.root) + .map(|relative| relative.components().count()) + .map_err(|_| { + ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("catalog path `{declared}` escapes the repository root"), + ) + }) + } + + fn walk_directory( + &mut self, + directory: &Path, + declared: &str, + initial_depth: usize, + ) -> Result<(), ContractError> { + let mut pending = vec![(directory.to_path_buf(), initial_depth)]; + while let Some((directory, depth)) = pending.pop() { + if depth > MAX_CATALOG_DIRECTORY_DEPTH { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "catalog directory depth exceeds {MAX_CATALOG_DIRECTORY_DEPTH} at `{declared}`" + ), + )); + } + if !self.directories.insert(directory.clone()) { + continue; + } + if self.directories.len() > MAX_CATALOG_DIRECTORIES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("catalog directories exceed {MAX_CATALOG_DIRECTORIES} at `{declared}`"), + )); + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(&directory).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read catalog directory `{declared}`: {error}"), + ) + })? { + let entry = entry.map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read catalog directory `{declared}`: {error}"), + ) + })?; + self.record_directory_entry(declared)?; + entries.push(entry); + } + entries.sort_by_key(|entry| entry.file_name()); + + let mut child_directories = Vec::new(); + for entry in entries { + let path = entry.path(); + let symlink = fs::symlink_metadata(&path) + .map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect catalog entry `{declared}`: {error}"), + ) + })? + .file_type() + .is_symlink(); + let canonical = fs::canonicalize(&path).map_err(|error| { + ContractError::new( + if symlink { + ContractDiagnosticCode::CatalogSymlinkEscape + } else { + ContractDiagnosticCode::CatalogPath + }, + format!("resolve catalog entry `{declared}`: {error}"), + ) + })?; + if !canonical.starts_with(&self.root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("catalog entry under `{declared}` escapes the repository root"), + )); + } + let metadata = fs::metadata(&canonical).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect catalog entry `{declared}`: {error}"), + ) + })?; + if metadata.is_dir() { + child_directories.push(canonical); + } else if metadata.is_file() { + self.record_file(canonical, declared)?; + } else { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + format!("catalog entry under `{declared}` is special or unsupported"), + )); + } + } + for child in child_directories.into_iter().rev() { + pending.push((child, depth + 1)); + } + } + Ok(()) + } + + fn record_directory_entry(&mut self, declared: &str) -> Result<(), ContractError> { + self.directory_entries += 1; + if self.directory_entries > MAX_CATALOG_DIRECTORY_ENTRIES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "catalog directory entries exceed {MAX_CATALOG_DIRECTORY_ENTRIES} at `{declared}`" + ), + )); + } + Ok(()) + } + + fn record_file(&mut self, path: PathBuf, declared: &str) -> Result<(), ContractError> { + if self.files.insert(path) && self.files.len() > MAX_CATALOG_FILES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("catalog paths exceed {MAX_CATALOG_FILES} physical files at `{declared}`"), + )); + } + Ok(()) + } +} + +fn diagnostic_for_error(error: &ContractError) -> ContractDiagnostic { + ContractDiagnostic::new( + error.code(), + None, + None::<&str>, + "contract-catalog", + std::iter::empty::<&str>(), + std::iter::empty::<&str>(), + None::<&str>, + None, + None, + None::<&str>, + None, + "inspect distributed.contracts.json", + ) + .with_detail(error.message()) +} diff --git a/distributed_cli/src/contracts/chain.rs b/distributed_cli/src/contracts/chain.rs new file mode 100644 index 000000000..642cb0cdb --- /dev/null +++ b/distributed_cli/src/contracts/chain.rs @@ -0,0 +1,174 @@ +//! Generic predecessor-staleness diagnostics for the approved artifact chain. +//! +//! This module does not reinterpret CMP/DPL payload semantics. It only compares +//! expected versus observed predecessor identities and reports owner/path. + +use super::artifact::{ArtifactIdentity, ArtifactPredecessor, ContractArtifactKind}; +use super::diagnostic::{ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// One observed predecessor link to validate. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ObservedPredecessor { + /// Catalog entry or source path that owns this check. + pub owner: String, + /// Spec/source path for diagnostics. + pub source_path: String, + /// The predecessor the artifact claims. + pub claimed: ArtifactPredecessor, + /// The currently recorded identity for that predecessor entry, if any. + pub observed: Option, +} + +/// Validate claimed predecessors against observed catalog identities (no writes). +pub fn check_predecessor_chain( + observations: impl IntoIterator, +) -> ContractCheckResult { + let mut diagnostics = BTreeSet::new(); + for observation in observations { + match &observation.observed { + None => { + diagnostics.insert( + ContractDiagnostic::new( + ContractDiagnosticCode::ChainMissingPredecessor, + Some(observation.claimed.identity.kind), + None::<&str>, + &observation.owner, + [observation.source_path.as_str()], + std::iter::empty::<&str>(), + Some("predecessor.entry_id"), + Some(observation.claimed.entry_id.as_str()), + None, + Some("repair_catalog_predecessor"), + None, + "distributed contracts check --scope catalog", + ) + .with_detail(format!( + "owner `{}` at `{}` references missing predecessor `{}`", + observation.owner, observation.source_path, observation.claimed.entry_id + )), + ); + } + Some(observed) if observed.kind != observation.claimed.identity.kind => { + diagnostics.insert( + ContractDiagnostic::new( + ContractDiagnosticCode::ChainKindMismatch, + Some(observation.claimed.identity.kind), + None::<&str>, + &observation.owner, + [observation.source_path.as_str()], + std::iter::empty::<&str>(), + Some("predecessor.identity.kind"), + Some(observation.claimed.identity.kind.as_str()), + Some(observed.kind.as_str()), + Some("repair_predecessor_kind"), + None, + "distributed contracts check --scope catalog", + ) + .with_detail(format!( + "owner `{}` at `{}` expected predecessor kind {} but observed {}", + observation.owner, + observation.source_path, + observation.claimed.identity.kind, + observed.kind + )), + ); + } + Some(observed) if observed.value != observation.claimed.identity.value => { + diagnostics.insert( + ContractDiagnostic::new( + ContractDiagnosticCode::ChainIdentityMismatch, + Some(ContractArtifactKind::ApplicationManifest), + None::<&str>, + &observation.owner, + [observation.source_path.as_str()], + std::iter::empty::<&str>(), + Some("predecessor.identity.value"), + Some(observation.claimed.identity.value.as_str()), + Some(observed.value.as_str()), + Some("accept_application_manifest"), + None, + "distributed contracts accept --scope application_manifest", + ) + .with_detail(format!( + "owner `{}` at `{}` has stale predecessor `{}`", + observation.owner, observation.source_path, observation.claimed.entry_id + )), + ); + } + Some(_) => {} + } + } + ContractCheckResult { + catalog_identity: None, + artifacts: Default::default(), + diagnostics, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contracts::artifact::ArtifactPredecessor; + + #[test] + fn stale_application_predecessor_reports_owner_path_and_identities() { + let result = check_predecessor_chain([ObservedPredecessor { + owner: "deployment-plan/todo".into(), + source_path: "specs/application-composition/index#DeploymentPlan".into(), + claimed: ArtifactPredecessor { + entry_id: "app-manifest".into(), + identity: ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:expected", + ), + }, + observed: Some(ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:observed-stale", + )), + }]); + assert_eq!(result.diagnostics.len(), 1); + let diagnostic = result.diagnostics.iter().next().unwrap(); + assert_eq!( + diagnostic.code, + ContractDiagnosticCode::ChainIdentityMismatch + ); + assert_eq!(diagnostic.owner, "deployment-plan/todo"); + assert!(diagnostic + .source_paths + .iter() + .any(|path| path.contains("application-composition"))); + assert_eq!( + diagnostic.expected.as_ref().map(|v| v.as_str()), + Some("sha256:expected") + ); + assert_eq!( + diagnostic.observed.as_ref().map(|v| v.as_str()), + Some("sha256:observed-stale") + ); + } + + #[test] + fn missing_predecessor_is_deterministic_and_no_write() { + let result = check_predecessor_chain([ObservedPredecessor { + owner: "plan".into(), + source_path: "plans/todo.json".into(), + claimed: ArtifactPredecessor { + entry_id: "missing".into(), + identity: ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:x", + ), + }, + observed: None, + }]); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!( + result.diagnostics.iter().next().unwrap().code, + ContractDiagnosticCode::ChainMissingPredecessor + ); + } +} diff --git a/distributed_cli/src/contracts/classification.rs b/distributed_cli/src/contracts/classification.rs new file mode 100644 index 000000000..a07661381 --- /dev/null +++ b/distributed_cli/src/contracts/classification.rs @@ -0,0 +1,160 @@ +//! Lifecycle decision classification for semantic and wire identity changes. +//! +//! Classification never guesses that every schema diff requires a protocol +//! bump. Each identity owner maps to a distinct required decision. + +use super::diagnostic::ContractDiagnosticCode; +use super::snapshots::{SnapshotChange, SnapshotDiff}; +use serde::{Deserialize, Serialize}; + +/// The decision an operator/tool must make for one detected change. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LifecycleDecision { + /// Accept surface/client manifest wire change. + AcceptManifestWire, + /// Accept protocol/semantic fingerprint change. + AcceptProtocolSemantic, + /// Accept application-manifest logical identity change. + AcceptApplicationManifest, + /// Accept deployment-plan identity change. + AcceptDeploymentPlan, + /// No acceptance required (informational). + None, +} + +impl LifecycleDecision { + pub const fn as_str(self) -> &'static str { + match self { + Self::AcceptManifestWire => "accept_manifest_wire", + Self::AcceptProtocolSemantic => "accept_protocol_semantic", + Self::AcceptApplicationManifest => "accept_application_manifest", + Self::AcceptDeploymentPlan => "accept_deployment_plan", + Self::None => "none", + } + } +} + +/// One classified change with a stable diagnostic code. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClassifiedChange { + pub path: String, + pub decision: LifecycleDecision, + pub code: ContractDiagnosticCode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +/// Classify a snapshot diff into explicit lifecycle decisions. +pub fn classify_snapshot_diff(diff: &SnapshotDiff) -> Vec { + diff.changes + .iter() + .map(|change| classify_change(&diff.kind, change)) + .collect() +} + +fn classify_change(kind: &str, change: &SnapshotChange) -> ClassifiedChange { + let (decision, code) = match kind { + "surface_client_manifest" | "generated_client_tree" => { + if change.path.contains("protocol") { + ( + LifecycleDecision::AcceptProtocolSemantic, + ContractDiagnosticCode::ProtocolDrift, + ) + } else { + ( + LifecycleDecision::AcceptManifestWire, + ContractDiagnosticCode::ManifestVersion, + ) + } + } + "application_manifest" => ( + LifecycleDecision::AcceptApplicationManifest, + ContractDiagnosticCode::SchemaDrift, + ), + "deployment_plan" => ( + LifecycleDecision::AcceptDeploymentPlan, + ContractDiagnosticCode::ChainStale, + ), + _ => { + if change.path.contains("protocol") { + ( + LifecycleDecision::AcceptProtocolSemantic, + ContractDiagnosticCode::ProtocolDrift, + ) + } else { + (LifecycleDecision::None, ContractDiagnosticCode::SchemaDrift) + } + } + }; + ClassifiedChange { + path: change.path.clone(), + decision, + code, + before: change.before.clone(), + after: change.after.clone(), + } +} + +/// Prove that manifest-wire and protocol-semantic changes require distinct decisions. +pub fn decisions_are_distinct(changes: &[ClassifiedChange]) -> bool { + let mut seen = std::collections::BTreeSet::new(); + for change in changes { + if change.decision == LifecycleDecision::None { + continue; + } + seen.insert(change.decision); + } + // 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") + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contracts::snapshots::{diff_snapshots, snapshot_from_json}; + use serde_json::json; + + #[test] + fn manifest_and_protocol_changes_require_distinct_decisions() { + let before = snapshot_from_json( + "client", + "surface_client_manifest", + &json!({ + "schema_fingerprint": "sha256:aaa", + "protocol_fingerprint": "sha256:proto-1", + "commands": [{ "id": "todo.create" }] + }), + ) + .unwrap(); + let after = snapshot_from_json( + "client", + "surface_client_manifest", + &json!({ + "schema_fingerprint": "sha256:bbb", + "protocol_fingerprint": "sha256:proto-2", + "commands": [{ "id": "todo.create" }] + }), + ) + .unwrap(); + let classified = classify_snapshot_diff(&diff_snapshots(&before, &after)); + assert!(classified.iter().any(|c| { + c.path.contains("schema_fingerprint") + && c.decision == LifecycleDecision::AcceptManifestWire + })); + assert!(classified.iter().any(|c| { + c.path.contains("protocol_fingerprint") + && c.decision == LifecycleDecision::AcceptProtocolSemantic + && c.code == ContractDiagnosticCode::ProtocolDrift + })); + assert!(decisions_are_distinct(&classified)); + } +} diff --git a/distributed_cli/src/contracts/closeout.rs b/distributed_cli/src/contracts/closeout.rs new file mode 100644 index 000000000..22cf7ed1d --- /dev/null +++ b/distributed_cli/src/contracts/closeout.rs @@ -0,0 +1,121 @@ +//! CTL closeout helpers for non-deployment chain verification (task 8 slice). +//! +//! Deployment/render/XR closeout remains tasks 14–19 and is intentionally +//! out of scope here. + +use super::artifact::{ArtifactIdentity, ArtifactPredecessor, ContractArtifactKind}; +use super::chain::{check_predecessor_chain, ObservedPredecessor}; +use super::diagnostic::ContractCheckResult; +use super::program::{ClientProgramDescriptor, ProgramCompatibility}; + +/// Verify the local contract chain: application → plan → optional program. +pub fn close_local_contract_chain( + application: &ArtifactIdentity, + plan: &ArtifactIdentity, + plan_claims_application: bool, + program: Option<&ClientProgramDescriptor>, +) -> ContractCheckResult { + let mut observations = Vec::new(); + if plan_claims_application { + observations.push(ObservedPredecessor { + owner: "deployment-plan".into(), + source_path: "contracts/deployment-plan".into(), + claimed: ArtifactPredecessor { + entry_id: "application-manifest".into(), + identity: application.clone(), + }, + observed: Some(application.clone()), + }); + } + if let Some(program) = program { + observations.push(ObservedPredecessor { + owner: format!("program:{}", program.program_name), + source_path: "contracts/client-program".into(), + claimed: ArtifactPredecessor { + entry_id: "application-manifest".into(), + identity: program.application_manifest.clone(), + }, + observed: Some(application.clone()), + }); + observations.push(ObservedPredecessor { + owner: format!("program:{}", program.program_name), + source_path: "contracts/client-program".into(), + claimed: ArtifactPredecessor { + entry_id: "deployment-plan".into(), + identity: program.deployment_plan.clone(), + }, + observed: Some(plan.clone()), + }); + } + let _ = ContractArtifactKind::ApplicationManifest; + check_predecessor_chain(observations) +} + +/// Classify program compatibility when both descriptors validate. +pub fn classify_release_programs( + advertised: &ClientProgramDescriptor, + loaded: &ClientProgramDescriptor, +) -> Result { + advertised.classify_against(loaded) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contracts::program::{ + ClientProgramArtifact, ClientProgramDescriptor, ClientProgramSurface, + }; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn local_chain_closeout_accepts_matching_predecessors() { + 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()); + } + + #[test] + fn local_chain_closeout_reports_stale_program_plan() { + let root = std::env::temp_dir().join(format!( + "closeout-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("a.js"), b"1").unwrap(); + let app = ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:app", + ); + let plan = ArtifactIdentity::new(ContractArtifactKind::DeploymentPlan, "sha256:plan"); + let program = ClientProgramDescriptor::builder("e2e-ui") + .surface(ClientProgramSurface { + name: "e2e-ui".into(), + schema_fingerprint: "sha256:s".into(), + protocol_fingerprint: "sha256:p".into(), + }) + .artifact(ClientProgramArtifact { + path: "op.ts".into(), + digest: "sha256:o".into(), + }) + .application_manifest(app.clone()) + .deployment_plan(ArtifactIdentity::new( + ContractArtifactKind::DeploymentPlan, + "sha256:stale-plan", + )) + .assets_from_dir(&root) + .unwrap() + .build() + .unwrap(); + let result = close_local_contract_chain(&app, &plan, true, Some(&program)); + assert!(!result.diagnostics.is_empty()); + let _ = fs::remove_dir_all(root); + } +} diff --git a/distributed_cli/src/contracts/diagnostic.rs b/distributed_cli/src/contracts/diagnostic.rs new file mode 100644 index 000000000..69a78d53b --- /dev/null +++ b/distributed_cli/src/contracts/diagnostic.rs @@ -0,0 +1,545 @@ +use super::{ArtifactIdentity, ContractArtifactKind}; +use serde::de::Deserializer; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +/// Stable diagnostic classifications shared by human and machine output. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub enum ContractDiagnosticCode { + #[serde(rename = "CTL-CATALOG-INVALID")] + CatalogInvalid, + #[serde(rename = "CTL-CATALOG-LIMIT")] + CatalogInputLimit, + #[serde(rename = "CTL-CATALOG-PATH")] + CatalogPath, + #[serde(rename = "CTL-CATALOG-SYMLINK")] + CatalogSymlinkEscape, + #[serde(rename = "CTL-CATALOG-FILE")] + CatalogSpecialFile, + #[serde(rename = "CTL-CATALOG-KIND")] + CatalogUnknownKind, + #[serde(rename = "CTL-CATALOG-DUPLICATE-SCOPE")] + CatalogDuplicateScope, + #[serde(rename = "CTL-CATALOG-DUPLICATE-OWNER")] + CatalogDuplicateOwner, + #[serde(rename = "CTL-CATALOG-DUPLICATE-OUTPUT")] + CatalogDuplicateOutput, + #[serde(rename = "CTL-CATALOG-GLOB")] + CatalogUnboundedGlob, + #[serde(rename = "CTL-ENV-VALUE")] + EnvironmentValue, + #[serde(rename = "CTL-CHAIN-MISSING")] + ChainMissingPredecessor, + #[serde(rename = "CTL-CHAIN-KIND")] + ChainKindMismatch, + #[serde(rename = "CTL-CHAIN-IDENTITY")] + ChainIdentityMismatch, + #[serde(rename = "CTL-CHAIN-CYCLE")] + ChainCycle, + #[serde(rename = "CTL-MIG-HISTORY")] + MigrationHistory, + #[serde(rename = "CTL-MIG-INVENTORY")] + MigrationInventory, + #[serde(rename = "CTL-SCHEMA-DRIFT")] + SchemaDrift, + #[serde(rename = "CTL-MANIFEST-VERSION")] + ManifestVersion, + #[serde(rename = "CTL-PROTOCOL-DRIFT")] + ProtocolDrift, + #[serde(rename = "CTL-GEN-STALE")] + GeneratedStale, + #[serde(rename = "CTL-PROGRAM-INCOMPLETE")] + ProgramIncomplete, + #[serde(rename = "CTL-CHAIN-STALE")] + ChainStale, +} + +impl ContractDiagnosticCode { + /// The stable code string used in logs and JSON output. + pub const fn as_str(self) -> &'static str { + match self { + Self::CatalogInvalid => "CTL-CATALOG-INVALID", + Self::CatalogInputLimit => "CTL-CATALOG-LIMIT", + Self::CatalogPath => "CTL-CATALOG-PATH", + Self::CatalogSymlinkEscape => "CTL-CATALOG-SYMLINK", + Self::CatalogSpecialFile => "CTL-CATALOG-FILE", + Self::CatalogUnknownKind => "CTL-CATALOG-KIND", + Self::CatalogDuplicateScope => "CTL-CATALOG-DUPLICATE-SCOPE", + Self::CatalogDuplicateOwner => "CTL-CATALOG-DUPLICATE-OWNER", + Self::CatalogDuplicateOutput => "CTL-CATALOG-DUPLICATE-OUTPUT", + Self::CatalogUnboundedGlob => "CTL-CATALOG-GLOB", + Self::EnvironmentValue => "CTL-ENV-VALUE", + Self::ChainMissingPredecessor => "CTL-CHAIN-MISSING", + Self::ChainKindMismatch => "CTL-CHAIN-KIND", + Self::ChainIdentityMismatch => "CTL-CHAIN-IDENTITY", + Self::ChainCycle => "CTL-CHAIN-CYCLE", + Self::MigrationHistory => "CTL-MIG-HISTORY", + Self::MigrationInventory => "CTL-MIG-INVENTORY", + Self::SchemaDrift => "CTL-SCHEMA-DRIFT", + Self::ManifestVersion => "CTL-MANIFEST-VERSION", + Self::ProtocolDrift => "CTL-PROTOCOL-DRIFT", + Self::GeneratedStale => "CTL-GEN-STALE", + Self::ProgramIncomplete => "CTL-PROGRAM-INCOMPLETE", + Self::ChainStale => "CTL-CHAIN-STALE", + } + } +} + +impl fmt::Display for ContractDiagnosticCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// A diagnostic value that is redacted at construction and deserialization. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SafeDiagnosticValue(String); + +impl SafeDiagnosticValue { + /// Construct a value while removing credential-like material. + pub fn new(value: impl AsRef) -> Self { + Self(redact_value(value.as_ref())) + } + + /// Read the safely rendered value. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SafeDiagnosticValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for SafeDiagnosticValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(Self::new(value)) + } +} + +/// One stable, safely redacted contract diagnostic. +#[derive(Clone, Deserialize, Eq, Ord, PartialEq, PartialOrd)] +#[serde(deny_unknown_fields)] +pub struct ContractDiagnostic { + /// Stable classification code. + pub code: ContractDiagnosticCode, + /// Referenced artifact kind, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_kind: Option, + /// Declared scope, when known. + #[serde( + default, + deserialize_with = "deserialize_redacted_option", + skip_serializing_if = "Option::is_none" + )] + pub scope: Option, + /// Authoritative owner of the affected contract. + #[serde(deserialize_with = "deserialize_redacted_string")] + pub owner: String, + /// Exact safe source paths involved in the diagnostic. + #[serde(default, deserialize_with = "deserialize_redacted_set")] + pub source_paths: BTreeSet, + /// Exact safe derived/output paths involved in the diagnostic. + #[serde(default, deserialize_with = "deserialize_redacted_set")] + pub derived_paths: BTreeSet, + /// Semantic path within an owner, if applicable. + #[serde( + default, + deserialize_with = "deserialize_redacted_option", + skip_serializing_if = "Option::is_none" + )] + pub semantic_path: Option, + /// Safe expected value, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected: Option, + /// Safe observed value, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed: Option, + /// Required lifecycle classification, if applicable. + #[serde( + default, + deserialize_with = "deserialize_redacted_option", + skip_serializing_if = "Option::is_none" + )] + pub required_classification: Option, + /// Whether merge-base evidence was available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_base_available: Option, + /// One safe repair or acceptance command. + #[serde(deserialize_with = "deserialize_redacted_string")] + pub repair_command: String, + /// Stable, non-sensitive explanatory detail. + #[serde( + default, + deserialize_with = "deserialize_redacted_string", + skip_serializing_if = "String::is_empty" + )] + pub detail: String, +} + +#[derive(Debug, Serialize)] +struct RedactedContractDiagnostic { + code: ContractDiagnosticCode, + #[serde(skip_serializing_if = "Option::is_none")] + artifact_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + owner: String, + source_paths: BTreeSet, + derived_paths: BTreeSet, + #[serde(skip_serializing_if = "Option::is_none")] + semantic_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + observed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + required_classification: Option, + #[serde(skip_serializing_if = "Option::is_none")] + merge_base_available: Option, + repair_command: String, + #[serde(skip_serializing_if = "String::is_empty")] + detail: String, +} + +impl ContractDiagnostic { + /// Build a diagnostic from safe facts. Values are redacted before storage. + #[expect(clippy::too_many_arguments)] + pub fn new( + code: ContractDiagnosticCode, + artifact_kind: Option, + scope: Option, + owner: impl AsRef, + source_paths: I, + derived_paths: J, + semantic_path: Option

, + expected: Option<&str>, + observed: Option<&str>, + required_classification: Option, + merge_base_available: Option, + repair_command: impl AsRef, + ) -> Self + where + S: AsRef, + I: IntoIterator, + I::Item: AsRef, + J: IntoIterator, + J::Item: AsRef, + P: AsRef, + Q: AsRef, + { + Self { + code, + artifact_kind, + scope: scope.map(|value| redact_value(value.as_ref())), + owner: redact_value(owner.as_ref()), + source_paths: source_paths + .into_iter() + .map(|value| redact_value(value.as_ref())) + .collect(), + derived_paths: derived_paths + .into_iter() + .map(|value| redact_value(value.as_ref())) + .collect(), + semantic_path: semantic_path.map(|value| redact_value(value.as_ref())), + expected: expected.map(SafeDiagnosticValue::new), + observed: observed.map(SafeDiagnosticValue::new), + required_classification: required_classification + .map(|value| redact_value(value.as_ref())), + merge_base_available, + repair_command: redact_value(repair_command.as_ref()), + detail: String::new(), + } + } + + /// Add a safe explanatory detail to an existing diagnostic. + pub fn with_detail(mut self, detail: impl AsRef) -> Self { + self.detail = redact_value(detail.as_ref()); + self + } + + /// Render the exact facts in the human-readable format. + pub fn human(&self) -> String { + self.redacted().human_unchecked() + } + + fn redacted(&self) -> Self { + Self { + code: self.code, + artifact_kind: self.artifact_kind, + scope: self.scope.as_deref().map(redact_value), + owner: redact_value(&self.owner), + source_paths: self + .source_paths + .iter() + .map(|value| redact_value(value)) + .collect(), + derived_paths: self + .derived_paths + .iter() + .map(|value| redact_value(value)) + .collect(), + semantic_path: self.semantic_path.as_deref().map(redact_value), + expected: self + .expected + .as_ref() + .map(|value| SafeDiagnosticValue::new(value.as_str())), + observed: self + .observed + .as_ref() + .map(|value| SafeDiagnosticValue::new(value.as_str())), + required_classification: self.required_classification.as_deref().map(redact_value), + merge_base_available: self.merge_base_available, + repair_command: redact_value(&self.repair_command), + detail: redact_value(&self.detail), + } + } + + fn human_unchecked(&self) -> String { + let mut output = self.code.to_string(); + if !self.detail.is_empty() { + output.push_str(": "); + output.push_str(&self.detail); + } + if let Some(kind) = self.artifact_kind { + output.push_str(&format!(" [kind={kind}]")); + } + if let Some(scope) = &self.scope { + output.push_str(&format!(" [scope={scope}]")); + } + output.push_str(&format!(" [owner={}]", self.owner)); + if !self.source_paths.is_empty() { + output.push_str(&format!(" [source={}]", join_set(&self.source_paths))); + } + if !self.derived_paths.is_empty() { + output.push_str(&format!(" [derived={}]", join_set(&self.derived_paths))); + } + if let Some(path) = &self.semantic_path { + output.push_str(&format!(" [semantic_path={path}]")); + } + if let Some(expected) = &self.expected { + output.push_str(&format!(" [expected={expected}]")); + } + if let Some(observed) = &self.observed { + output.push_str(&format!(" [observed={observed}]")); + } + if let Some(classification) = &self.required_classification { + output.push_str(&format!(" [classification={classification}]")); + } + if let Some(available) = self.merge_base_available { + output.push_str(&format!(" [merge_base_available={available}]")); + } + output.push_str(&format!(" [repair={}]", self.repair_command)); + output + } + + fn redacted_wire(&self) -> RedactedContractDiagnostic { + let redacted = self.redacted(); + RedactedContractDiagnostic { + code: redacted.code, + artifact_kind: redacted.artifact_kind, + scope: redacted.scope, + owner: redacted.owner, + source_paths: redacted.source_paths, + derived_paths: redacted.derived_paths, + semantic_path: redacted.semantic_path, + expected: redacted.expected, + observed: redacted.observed, + required_classification: redacted.required_classification, + merge_base_available: redacted.merge_base_available, + repair_command: redacted.repair_command, + detail: redacted.detail, + } + } + + /// Serialize the same safely redacted facts used by [`Self::human`]. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } +} + +impl Serialize for ContractDiagnostic { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.redacted_wire().serialize(serializer) + } +} + +impl fmt::Display for ContractDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.human()) + } +} + +impl fmt::Debug for ContractDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ContractDiagnostic") + .field("facts", &self.redacted_wire()) + .finish() + } +} + +/// Aggregated read-only contract-check output. +#[derive(Clone, Default, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ContractCheckResult { + /// Identity of the canonical catalog, when serialization succeeded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_identity: Option, + /// Artifact identities collected by catalog entry ID. + #[serde(default)] + pub artifacts: BTreeMap, + /// Sorted independent diagnostics. + #[serde(default)] + pub diagnostics: BTreeSet, +} + +#[derive(Serialize)] +struct RedactedContractCheckResult { + #[serde(skip_serializing_if = "Option::is_none")] + catalog_identity: Option, + artifacts: BTreeMap, + diagnostics: BTreeSet, +} + +impl ContractCheckResult { + fn redacted_wire(&self) -> RedactedContractCheckResult { + RedactedContractCheckResult { + catalog_identity: self.catalog_identity.as_deref().map(redact_value), + artifacts: self + .artifacts + .iter() + .map(|(entry_id, identity)| { + ( + redact_value(entry_id), + ArtifactIdentity { + kind: identity.kind, + value: redact_value(&identity.value), + }, + ) + }) + .collect(), + diagnostics: self.diagnostics.clone(), + } + } +} + +impl Serialize for ContractCheckResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.redacted_wire().serialize(serializer) + } +} + +impl fmt::Debug for ContractCheckResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let redacted = self.redacted_wire(); + formatter + .debug_struct("ContractCheckResult") + .field("catalog_identity", &redacted.catalog_identity) + .field("artifacts", &redacted.artifacts) + .field("diagnostics", &redacted.diagnostics) + .finish() + } +} + +impl ContractCheckResult { + /// Whether no diagnostics were collected. + pub fn is_ok(&self) -> bool { + self.diagnostics.is_empty() + } + + /// Add one diagnostic while preserving deterministic order and uniqueness. + pub fn push(&mut self, diagnostic: ContractDiagnostic) { + self.diagnostics.insert(diagnostic); + } + + /// Canonical JSON bytes for stable comparisons and evidence. + pub fn canonical_bytes(&self) -> Result, serde_json::Error> { + serde_json::to_vec(self) + } + + /// Human output with diagnostics in stable order. + pub fn human(&self) -> String { + self.diagnostics + .iter() + .map(ContractDiagnostic::human) + .collect::>() + .join("\n") + } + + /// JSON output for machine consumers. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } +} + +impl fmt::Display for ContractCheckResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.human()) + } +} + +fn join_set(values: &BTreeSet) -> String { + values.iter().cloned().collect::>().join(",") +} + +fn deserialize_redacted_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + String::deserialize(deserializer).map(|value| redact_value(&value)) +} + +fn deserialize_redacted_option<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.map(|value| redact_value(&value))) +} + +fn deserialize_redacted_set<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + BTreeSet::::deserialize(deserializer).map(|values| { + values + .into_iter() + .map(|value| redact_value(&value)) + .collect() + }) +} + +fn redact_value(value: &str) -> String { + if is_secret_like(value) { + "[REDACTED]".to_string() + } else { + value.to_string() + } +} + +pub(crate) fn is_secret_like(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("postgres://") + || lower.contains("postgresql://") + || lower.contains("mysql://") + || lower.contains("mongodb://") + || lower.contains("bearer ") + || lower.contains("password=") + || lower.contains("token=") + || lower.contains("secret=") + || lower.contains("-----begin ") +} diff --git a/distributed_cli/src/contracts/migrations.rs b/distributed_cli/src/contracts/migrations.rs new file mode 100644 index 000000000..f542823a5 --- /dev/null +++ b/distributed_cli/src/contracts/migrations.rs @@ -0,0 +1,1513 @@ +//! Dialect-aware migration inventory and immutable-history checks. +//! +//! The inventory is deliberately a small, explicit data model. It owns the +//! logical migration order and the source paths/checksums for each supported +//! dialect; SQLx remains the runtime owner of applying those bytes. The +//! history checker reads the comparison inventory and its SQL bytes from one +//! explicit Git revision, so changing a local checksum cannot hide a baseline +//! edit. + +use super::diagnostic::is_secret_like; +use super::{ + ArtifactIdentity, ContractArtifactKind, ContractCheckResult, ContractDiagnostic, + ContractDiagnosticCode, ContractError, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// The current migration inventory wire format. +pub const MIGRATION_INVENTORY_SCHEMA_VERSION: u32 = 1; +/// Repository-relative location of the inventory. +pub const MIGRATION_INVENTORY_PATH: &str = "migrations/inventory.json"; +/// Maximum accepted inventory size. +pub const MAX_MIGRATION_INVENTORY_BYTES: usize = 1024 * 1024; +/// Maximum accepted SQL source size for one migration. +pub const MAX_MIGRATION_SQL_BYTES: usize = 4 * 1024 * 1024; +/// Maximum number of migrations in one inventory. +pub const MAX_MIGRATIONS: usize = 256; +/// Maximum nesting depth of JSON objects and arrays in an inventory. +pub const MAX_MIGRATION_JSON_DEPTH: usize = 24; +/// Maximum number of entries traversed beneath one dialect directory tree. +pub const MAX_MIGRATION_TOTAL_ENTRIES: usize = MAX_MIGRATIONS * 4; +/// Maximum number of direct entries beneath `migrations`. +pub const MAX_MIGRATION_TOP_LEVEL_ENTRIES: usize = 64; +/// The stable owner and scope used by migration diagnostics. +pub const MIGRATION_OWNER: &str = "distributed/migrations"; +pub const MIGRATION_SCOPE: &str = "repository/migrations"; + +const DIALECT_DIRECTORY_LIMIT: usize = 4_096; +const REDACTED_MIGRATION_PATH: &str = ""; + +/// A SQL dialect with an explicit migration directory. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MigrationDialect { + /// SQLite migrations. + Sqlite, + /// PostgreSQL migrations. + Postgres, +} + +impl MigrationDialect { + /// All dialects required by the repository contract. + pub const ALL: [Self; 2] = [Self::Sqlite, Self::Postgres]; + + /// Stable inventory spelling. + pub const fn as_str(self) -> &'static str { + match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + } + } + + fn directory(self) -> &'static str { + match self { + Self::Sqlite => "migrations/sqlite", + Self::Postgres => "migrations/postgres", + } + } +} + +impl std::fmt::Display for MigrationDialect { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// One dialect-specific SQL file declaration. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MigrationFile { + /// Repository-relative path to the SQL file. + pub path: String, + /// Lowercase SHA-256 digest of the exact file bytes. + pub sha256: String, +} + +/// One logical migration and its required dialect implementations. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MigrationEntry { + /// Consecutive logical migration version, starting at one. + pub version: u64, + /// Human-readable SQLx migration description. + pub description: String, + /// SQLite source declaration. + pub sqlite: MigrationFile, + /// PostgreSQL source declaration. + pub postgres: MigrationFile, +} + +impl MigrationEntry { + /// Return the declaration for one supported dialect. + pub fn file(&self, dialect: MigrationDialect) -> &MigrationFile { + match dialect { + MigrationDialect::Sqlite => &self.sqlite, + MigrationDialect::Postgres => &self.postgres, + } + } +} + +/// The single source of migration registration and history identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MigrationInventory { + /// Inventory wire-format version. + pub schema_version: u32, + /// Migrations in their runtime application order. + pub migrations: Vec, +} + +impl MigrationInventory { + /// Parse and structurally validate inventory JSON without filesystem I/O. + pub fn from_json_str(input: &str) -> Result { + if input.len() > MAX_MIGRATION_INVENTORY_BYTES { + return Err(inventory_error(format!( + "migration inventory is {} bytes; maximum supported size is {MAX_MIGRATION_INVENTORY_BYTES}", + input.len() + ))); + } + validate_json_nesting(input)?; + let value: Value = serde_json::from_str(input) + .map_err(|error| inventory_error(format!("parse migration inventory JSON: {error}")))?; + validate_json_value(&value, 1)?; + let inventory: Self = serde_json::from_value(value) + .map_err(|error| inventory_error(format!("parse migration inventory JSON: {error}")))?; + inventory.validate_structure()?; + Ok(inventory) + } + + /// Alias for [`Self::from_json_str`]. + pub fn parse(input: &str) -> Result { + Self::from_json_str(input) + } + + /// Read and fully validate an inventory at its conventional repository path. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let repository_root = inferred_repository_root(path); + let canonical_root = canonical_repository_root(&repository_root)?; + let relative = relative_path(&repository_root, path)?; + reject_symlink_components(&canonical_root, &relative, "migration inventory")?; + let bytes = read_bounded_file(path, MAX_MIGRATION_INVENTORY_BYTES, "migration inventory")?; + let input = std::str::from_utf8(&bytes) + .map_err(|_| inventory_error("migration inventory is not UTF-8".to_string()))?; + let inventory = Self::from_json_str(input)?; + inventory.validate_paths(&canonical_root)?; + Ok(inventory) + } + + /// Load and validate `migrations/inventory.json` beneath a repository root. + pub fn from_repository_root(root: impl AsRef) -> Result { + Self::from_path(root.as_ref().join(MIGRATION_INVENTORY_PATH)) + } + + /// Serialize the validated inventory without changing its runtime order. + pub fn canonical_bytes(&self) -> Result, ContractError> { + self.validate_structure()?; + serde_json::to_vec(self) + .map_err(|error| inventory_error(format!("serialize migration inventory: {error}"))) + } + + /// Validate all declared files, checksums, dialect directories, and extras. + pub fn validate_paths(&self, root: impl AsRef) -> Result<(), ContractError> { + self.validate_structure()?; + let root = canonical_repository_root(root.as_ref())?; + let declared = self + .migrations + .iter() + .flat_map(|migration| { + MigrationDialect::ALL + .into_iter() + .map(move |dialect| (dialect, migration.file(dialect).path.clone())) + }) + .collect::>(); + + for migration in &self.migrations { + for dialect in MigrationDialect::ALL { + let declaration = migration.file(dialect); + let display_path = declared_path_display(&declaration.path); + let bytes = read_migration_file(&root, declaration, dialect)?; + let observed = sha256_hex(&bytes); + if observed != declaration.sha256 { + return Err(inventory_error(format!( + "{} migration `{}` checksum mismatch: expected {}, observed {}", + dialect, display_path, declaration.sha256, observed + ))); + } + if std::str::from_utf8(&bytes).is_err() { + return Err(inventory_error(format!( + "{} migration `{display_path}` is not UTF-8 SQL", + dialect + ))); + } + } + } + + for dialect in MigrationDialect::ALL { + let actual = collect_sql_files(&root, dialect)?; + let declared_for_dialect = declared + .iter() + .filter(|(declared_dialect, _)| *declared_dialect == dialect) + .cloned() + .collect::>(); + if let Some(path) = actual.difference(&declared_for_dialect).next() { + let display_path = declared_path_display(&path.1); + return Err(inventory_error(format!( + "extra {} migration file `{}` is not registered", + dialect, display_path + ))); + } + } + validate_no_extra_dialect_directories(&root)?; + Ok(()) + } + + /// Collect a read-only current-tree result with stable diagnostics. + pub fn check(&self, root: impl AsRef) -> ContractCheckResult { + let mut result = ContractCheckResult::default(); + let identity = match self.canonical_bytes() { + Ok(identity) => identity, + Err(error) => { + result.push(diagnostic_for_error(&error)); + return result; + } + }; + result.catalog_identity = Some(super::artifact::canonical_digest(&identity)); + result.artifacts.insert( + "migration-inventory".to_string(), + ArtifactIdentity::from_canonical_bytes( + ContractArtifactKind::MigrationInventory, + &identity, + ), + ); + if let Err(error) = self.validate_paths(root) { + result.push(diagnostic_for_error(&error)); + } + result + } + + /// Compare this current inventory against inventory and SQL bytes at a Git revision. + pub fn check_history( + &self, + root: impl AsRef, + base_revision: &str, + ) -> MigrationHistoryCheck { + let root = root.as_ref(); + let mut result = MigrationHistoryCheck { + baseline: BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "comparison has not started".to_string(), + }, + diagnostics: BTreeSet::new(), + }; + + if let Err(error) = self.validate_paths(root) { + result.push(diagnostic_for_error(&error)); + } + + let Ok(root) = canonical_repository_root(root) else { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "repository root is unavailable".to_string(), + }; + result.push(unavailable_diagnostic( + base_revision, + "repository root is unavailable", + false, + )); + return result; + }; + if !valid_revision(base_revision) || !git_revision_exists(&root, base_revision) { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "explicit Git revision is unavailable".to_string(), + }; + result.push(unavailable_diagnostic( + base_revision, + "immutable migration history evidence is unavailable for the explicit base revision", + false, + )); + return result; + } + + let baseline_bytes = match git_file(&root, base_revision, MIGRATION_INVENTORY_PATH) { + Ok(bytes) => bytes, + Err(reason) => { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason, + }; + result.push(unavailable_diagnostic( + base_revision, + "the explicit Git revision has no readable migration inventory", + true, + )); + return result; + } + }; + let baseline_input = match std::str::from_utf8(&baseline_bytes) { + Ok(input) => input, + Err(_) => { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "baseline migration inventory is not UTF-8".to_string(), + }; + result.push(unavailable_diagnostic( + base_revision, + "the explicit Git revision contains a non-UTF-8 migration inventory", + true, + )); + return result; + } + }; + let baseline = match Self::from_json_str(baseline_input) { + Ok(inventory) => inventory, + Err(error) => { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "baseline migration inventory is structurally invalid".to_string(), + }; + result.push( + ContractDiagnostic::new( + ContractDiagnosticCode::MigrationHistory, + Some(ContractArtifactKind::MigrationInventory), + Some(MIGRATION_SCOPE), + MIGRATION_OWNER, + [MIGRATION_INVENTORY_PATH], + std::iter::empty::<&str>(), + None::<&str>, + Some(base_revision), + Some(error.message()), + Some("restore the baseline inventory and add a new migration"), + Some(true), + "restore the baseline inventory and add a new migration", + ) + .with_detail("baseline migration inventory is structurally invalid"), + ); + return result; + } + }; + + result.baseline = BaselineAvailability::Available { + revision: base_revision.to_string(), + }; + let baseline_files = match load_baseline_files(&root, base_revision, &baseline) { + Ok(files) => files, + Err(reason) => { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason, + }; + result.push(unavailable_diagnostic( + base_revision, + "the explicit Git revision has incomplete migration SQL evidence", + true, + )); + return result; + } + }; + compare_history( + self, + &baseline, + &baseline_files, + &root, + base_revision, + &mut result, + ); + result + } + + /// Alias emphasizing that the baseline comparison is read-only. + pub fn compare_history( + &self, + root: impl AsRef, + base_revision: &str, + ) -> MigrationHistoryCheck { + self.check_history(root, base_revision) + } + + fn validate_structure(&self) -> Result<(), ContractError> { + if self.schema_version != MIGRATION_INVENTORY_SCHEMA_VERSION { + return Err(inventory_error(format!( + "unsupported migration inventory schema version {}; expected {}", + self.schema_version, MIGRATION_INVENTORY_SCHEMA_VERSION + ))); + } + if self.migrations.is_empty() || self.migrations.len() > MAX_MIGRATIONS { + return Err(inventory_error(format!( + "migration inventory must contain 1..={MAX_MIGRATIONS} migrations" + ))); + } + + let mut paths = BTreeMap::::new(); + for (index, migration) in self.migrations.iter().enumerate() { + let expected_version = (index + 1) as u64; + if migration.version != expected_version { + return Err(inventory_error(format!( + "migration versions must be ordered and consecutive: expected {expected_version}, observed {}", + migration.version + ))); + } + if migration.version > i64::MAX as u64 { + return Err(inventory_error(format!( + "migration version {} exceeds SQLx's signed version range", + migration.version + ))); + } + validate_description(&migration.description, migration.version)?; + for dialect in MigrationDialect::ALL { + let file = migration.file(dialect); + validate_migration_path(&file.path, dialect)?; + validate_checksum(&file.sha256, &file.path)?; + if let Some((previous_version, previous_dialect)) = paths.get(&file.path) { + return Err(inventory_error(format!( + "{} migration path `{}` is declared more than once ({} version {}, {} version {})", + dialect, + declared_path_display(&file.path), + previous_dialect, + previous_version, + dialect, + migration.version + ))); + } + paths.insert(file.path.clone(), (migration.version, dialect)); + } + } + Ok(()) + } +} + +/// The result of comparing an inventory with an explicit Git baseline. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MigrationHistoryCheck { + /// Whether the explicit revision was available and readable. + pub baseline: BaselineAvailability, + /// Deterministically ordered history and current-tree diagnostics. + pub diagnostics: BTreeSet, +} + +impl MigrationHistoryCheck { + /// True only when baseline evidence exists and no diagnostic was emitted. + pub fn is_verified(&self) -> bool { + self.baseline.is_available() && self.diagnostics.is_empty() + } + + /// Alias for [`Self::is_verified`]. Unavailable evidence is never success. + pub fn is_ok(&self) -> bool { + self.is_verified() + } + + /// Whether the explicit comparison baseline could not be read. + pub fn is_unavailable(&self) -> bool { + !self.baseline.is_available() + } + + /// Add one deterministic diagnostic. + pub fn push(&mut self, diagnostic: ContractDiagnostic) { + self.diagnostics.insert(diagnostic); + } + + /// Human output shared with aggregate contract checks. + pub fn human(&self) -> String { + self.diagnostics + .iter() + .map(ContractDiagnostic::human) + .collect::>() + .join("\n") + } + + /// JSON output shared with aggregate contract checks. + pub fn to_json(&self) -> Result { + let mut result = ContractCheckResult::default(); + for diagnostic in &self.diagnostics { + result.push(diagnostic.clone()); + } + result.to_json() + } +} + +/// Typed fact describing whether the comparison revision was available. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BaselineAvailability { + /// Inventory and all declared SQL bytes were read from this revision. + Available { revision: String }, + /// Comparison evidence was unavailable; this is not a successful check. + Unavailable { revision: String, reason: String }, +} + +impl BaselineAvailability { + /// Whether baseline inventory and SQL evidence was available. + pub fn is_available(&self) -> bool { + matches!(self, Self::Available { .. }) + } + + /// The requested revision, regardless of availability. + pub fn revision(&self) -> &str { + match self { + Self::Available { revision } | Self::Unavailable { revision, .. } => revision, + } + } + + /// Unavailability explanation, if any. + pub fn reason(&self) -> Option<&str> { + match self { + Self::Available { .. } => None, + Self::Unavailable { reason, .. } => Some(reason), + } + } +} + +/// Compare a repository's conventional inventory against an explicit Git revision. +pub fn check_migration_history( + root: impl AsRef, + base_revision: &str, +) -> MigrationHistoryCheck { + let root = root.as_ref(); + match MigrationInventory::from_repository_root(root) { + Ok(inventory) => inventory.check_history(root, base_revision), + Err(error) => { + let mut result = MigrationHistoryCheck { + baseline: BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "current migration inventory is invalid".to_string(), + }, + diagnostics: BTreeSet::new(), + }; + result.push(diagnostic_for_error(&error)); + result + } + } +} + +/// Validate a repository's conventional inventory and SQL files. +pub fn check_migration_inventory(root: impl AsRef) -> ContractCheckResult { + match MigrationInventory::from_repository_root(root.as_ref()) { + Ok(inventory) => inventory.check(root), + Err(error) => { + let mut result = ContractCheckResult::default(); + result.push(diagnostic_for_error(&error)); + result + } + } +} + +fn compare_history( + current: &MigrationInventory, + baseline: &MigrationInventory, + baseline_files: &BTreeMap<(u64, MigrationDialect), Vec>, + root: &Path, + base_revision: &str, + result: &mut MigrationHistoryCheck, +) { + let current_by_version = current + .migrations + .iter() + .map(|migration| (migration.version, migration)) + .collect::>(); + let current_last = current + .migrations + .last() + .map_or(0, |migration| migration.version); + let baseline_last = baseline + .migrations + .last() + .map_or(0, |migration| migration.version); + let next_version = current_last.max(baseline_last).saturating_add(1); + + let baseline_versions = baseline + .migrations + .iter() + .map(|migration| migration.version) + .collect::>(); + let current_baseline_versions = current + .migrations + .iter() + .filter(|migration| baseline_versions.contains(&migration.version)) + .map(|migration| migration.version) + .collect::>(); + if current_baseline_versions != baseline_versions { + result.push(history_diagnostic( + base_revision, + next_version, + baseline + .migrations + .iter() + .flat_map(|migration| { + MigrationDialect::ALL.map(|dialect| migration.file(dialect).path.clone()) + }) + .collect::>(), + Some(&format!("versions={baseline_versions:?}")), + Some(&format!("versions={current_baseline_versions:?}")), + "baseline migration order or numbering changed", + )); + } + + for baseline_migration in &baseline.migrations { + let Some(current_migration) = current_by_version.get(&baseline_migration.version) else { + result.push(history_diagnostic( + base_revision, + next_version, + MigrationDialect::ALL.map(|dialect| baseline_migration.file(dialect).path.clone()), + Some(&format!( + "version {} is present", + baseline_migration.version + )), + Some("missing"), + &format!( + "baseline migration {} was deleted; restore it and add migration {}", + baseline_migration.version, next_version + ), + )); + continue; + }; + + if current_migration.description != baseline_migration.description { + result.push(history_diagnostic( + base_revision, + next_version, + MigrationDialect::ALL.map(|dialect| current_migration.file(dialect).path.clone()), + Some(&baseline_migration.description), + Some(¤t_migration.description), + &format!( + "baseline migration {} description changed; restore it and add migration {}", + baseline_migration.version, next_version + ), + )); + } + + for dialect in MigrationDialect::ALL { + let baseline_file = baseline_migration.file(dialect); + let current_file = current_migration.file(dialect); + if baseline_file.path != current_file.path { + let expected_path = declared_path_display(&baseline_file.path); + let observed_path = declared_path_display(¤t_file.path); + result.push(history_diagnostic( + base_revision, + next_version, + [baseline_file.path.clone(), current_file.path.clone()], + Some(&expected_path), + Some(&observed_path), + &format!( + "baseline migration {} {} path changed; restore it and add migration {}", + baseline_migration.version, dialect, next_version + ), + )); + } + if baseline_file.sha256 != current_file.sha256 { + result.push(history_diagnostic( + base_revision, + next_version, + [baseline_file.path.clone(), current_file.path.clone()], + Some(&baseline_file.sha256), + Some(¤t_file.sha256), + &format!( + "baseline migration {} {} checksum changed; restore it and add migration {}", + baseline_migration.version, dialect, next_version + ), + )); + } + let Some(baseline_bytes) = baseline_files.get(&(baseline_migration.version, dialect)) + else { + continue; + }; + let Ok(current_bytes) = read_migration_file(root, current_file, dialect) else { + continue; + }; + if baseline_bytes != ¤t_bytes { + let baseline_hash = sha256_hex(baseline_bytes); + let current_hash = sha256_hex(¤t_bytes); + result.push(history_diagnostic( + base_revision, + next_version, + [baseline_file.path.clone(), current_file.path.clone()], + Some(&baseline_hash), + Some(¤t_hash), + &format!( + "baseline migration {} {} SQL bytes changed; restore it and add migration {}", + baseline_migration.version, dialect, next_version + ), + )); + } + } + } +} + +fn load_baseline_files( + root: &Path, + revision: &str, + inventory: &MigrationInventory, +) -> Result>, String> { + let mut files = BTreeMap::new(); + for migration in &inventory.migrations { + for dialect in MigrationDialect::ALL { + let declaration = migration.file(dialect); + let display_path = declared_path_display(&declaration.path); + let bytes = git_file(root, revision, &declaration.path).map_err(|reason| { + format!( + "unable to read baseline {} migration `{}`: {reason}", + dialect, display_path + ) + })?; + let observed = sha256_hex(&bytes); + if observed != declaration.sha256 { + return Err(format!( + "baseline {} migration `{}` checksum does not match its inventory", + dialect, display_path + )); + } + if bytes.len() > MAX_MIGRATION_SQL_BYTES || std::str::from_utf8(&bytes).is_err() { + return Err(format!( + "baseline {} migration `{}` is not bounded UTF-8 SQL", + dialect, display_path + )); + } + files.insert((migration.version, dialect), bytes); + } + } + Ok(files) +} + +fn history_diagnostic( + base_revision: &str, + next_version: u64, + paths: I, + expected: Option<&str>, + observed: Option<&str>, + detail: &str, +) -> ContractDiagnostic +where + I: IntoIterator, + P: AsRef, +{ + let repair = format!( + "restore the baseline migration from {base_revision} and add migration {next_version}" + ); + let paths = paths + .into_iter() + .map(|path| declared_path_display(path.as_ref())) + .collect::>(); + ContractDiagnostic::new( + ContractDiagnosticCode::MigrationHistory, + Some(ContractArtifactKind::MigrationInventory), + Some(MIGRATION_SCOPE), + MIGRATION_OWNER, + paths, + [MIGRATION_INVENTORY_PATH], + None::<&str>, + expected, + observed, + Some(format!("add migration {next_version}")), + Some(true), + repair, + ) + .with_detail(detail) +} + +fn unavailable_diagnostic( + base_revision: &str, + detail: &str, + merge_base_available: bool, +) -> ContractDiagnostic { + ContractDiagnostic::new( + ContractDiagnosticCode::MigrationHistory, + Some(ContractArtifactKind::MigrationInventory), + Some(MIGRATION_SCOPE), + MIGRATION_OWNER, + [MIGRATION_INVENTORY_PATH], + std::iter::empty::<&str>(), + None::<&str>, + Some(base_revision), + Some("unavailable"), + Some("history evidence unavailable"), + Some(merge_base_available), + "rerun with `distributed contracts check --base `", + ) + .with_detail(detail) +} + +fn diagnostic_for_error(error: &ContractError) -> ContractDiagnostic { + ContractDiagnostic::new( + error.code(), + Some(ContractArtifactKind::MigrationInventory), + Some(MIGRATION_SCOPE), + MIGRATION_OWNER, + [MIGRATION_INVENTORY_PATH], + std::iter::empty::<&str>(), + None::<&str>, + None, + None, + None::<&str>, + None, + "inspect migrations/inventory.json and declared SQL files", + ) + .with_detail(error.message()) +} + +fn inventory_error(message: String) -> ContractError { + ContractError::new(ContractDiagnosticCode::MigrationInventory, message) +} + +fn validate_json_nesting(input: &str) -> Result<(), ContractError> { + let mut depth = 0usize; + let mut escaped = false; + let mut in_string = false; + + for byte in input.bytes() { + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + continue; + } + + match byte { + b'"' => in_string = true, + b'{' | b'[' => { + depth = depth.saturating_add(1); + if depth > MAX_MIGRATION_JSON_DEPTH { + return Err(inventory_error( + "migration inventory exceeds maximum JSON nesting depth".to_string(), + )); + } + } + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + } + + Ok(()) +} + +fn validate_json_value(value: &Value, depth: usize) -> Result<(), ContractError> { + if depth > MAX_MIGRATION_JSON_DEPTH { + return Err(inventory_error( + "migration inventory exceeds maximum JSON nesting depth".to_string(), + )); + } + match value { + Value::Object(object) => { + for (key, child) in object { + if is_secret_like(key) { + return Err(inventory_error(format!( + "migration inventory contains a credential-like field `{key}`" + ))); + } + let child_depth = if child.is_object() || child.is_array() { + depth + 1 + } else { + depth + }; + validate_json_value(child, child_depth)?; + } + } + Value::Array(array) => { + if array.len() > MAX_MIGRATIONS * 4 { + return Err(inventory_error( + "migration inventory contains too many JSON array values".to_string(), + )); + } + for child in array { + let child_depth = if child.is_object() || child.is_array() { + depth + 1 + } else { + depth + }; + validate_json_value(child, child_depth)?; + } + } + Value::String(string) => { + if string.len() > 4 * 1024 { + return Err(inventory_error( + "migration inventory contains an oversized string".to_string(), + )); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + Ok(()) +} + +fn validate_description(description: &str, version: u64) -> Result<(), ContractError> { + if description.is_empty() + || description.trim() != description + || description.len() > 4 * 1024 + || description.contains('\0') + || is_secret_like(description) + { + return Err(inventory_error(format!( + "migration {version} description is empty, sensitive, or not portable" + ))); + } + Ok(()) +} + +fn validate_checksum(checksum: &str, path: &str) -> Result<(), ContractError> { + if checksum.len() != 64 + || !checksum.bytes().all(|byte| byte.is_ascii_hexdigit()) + || checksum + .chars() + .any(|character| character.is_ascii_uppercase()) + { + return Err(inventory_error(format!( + "migration `{}` must declare one lowercase 64-character SHA-256 checksum", + declared_path_display(path) + ))); + } + Ok(()) +} + +fn validate_migration_path(path: &str, dialect: MigrationDialect) -> Result<(), ContractError> { + let display_path = declared_path_display(path); + if path.is_empty() + || path.trim() != path + || path.len() > 4 * 1024 + || path.contains('\0') + || path.contains('\\') + || !path.ends_with(".sql") + { + return Err(inventory_error(format!( + "{} migration path `{display_path}` is not a portable SQL path", + dialect, + ))); + } + let path_value = Path::new(path); + if path_value.is_absolute() + || path_value + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || !path_value.starts_with(dialect.directory()) + { + return Err(inventory_error(format!( + "{} migration path `{display_path}` must remain beneath `{}`", + dialect, + dialect.directory() + ))); + } + if is_secret_like(path) { + return Err(inventory_error(format!( + "{} migration path `{display_path}` contains sensitive material", + dialect, + ))); + } + Ok(()) +} + +fn declared_path_display(path: &str) -> String { + let path_value = Path::new(path); + if is_secret_like(path) + || path_value.is_absolute() + || path.contains('\\') + || path_value + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + REDACTED_MIGRATION_PATH.to_string() + } else { + path.to_string() + } +} + +fn canonical_repository_root(root: &Path) -> Result { + let metadata = fs::symlink_metadata(root) + .map_err(|error| inventory_error(format!("inspect migration repository root: {error}")))?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + "migration repository root must not be a symlink".to_string(), + )); + } + let root = fs::canonicalize(root) + .map_err(|error| inventory_error(format!("resolve migration repository root: {error}")))?; + let metadata = fs::metadata(&root) + .map_err(|error| inventory_error(format!("inspect migration repository root: {error}")))?; + if !metadata.is_dir() { + return Err(inventory_error( + "migration repository root is not a directory".to_string(), + )); + } + Ok(root) +} + +fn relative_path(root: &Path, path: &Path) -> Result { + let current_directory = std::env::current_dir() + .map_err(|error| inventory_error(format!("resolve current directory: {error}")))?; + let absolute_root = if root.is_absolute() { + root.to_path_buf() + } else { + current_directory.join(root) + }; + let absolute_path = if path.is_absolute() { + path.to_path_buf() + } else { + current_directory.join(path) + }; + absolute_path + .strip_prefix(&absolute_root) + .map(Path::to_path_buf) + .map_err(|_| { + inventory_error("migration inventory path escaped repository root".to_string()) + }) +} + +fn inferred_repository_root(path: &Path) -> PathBuf { + if path.file_name().and_then(|name| name.to_str()) == Some("inventory.json") + && path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + == Some("migrations") + { + return path + .parent() + .and_then(Path::parent) + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + } + path.parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf) +} + +fn read_migration_file( + root: &Path, + declaration: &MigrationFile, + dialect: MigrationDialect, +) -> Result, ContractError> { + let path = root.join(&declaration.path); + let display_path = declared_path_display(&declaration.path); + let mut current = root.to_path_buf(); + let components = Path::new(&declaration.path) + .components() + .collect::>(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(component) = component else { + return Err(inventory_error(format!( + "{} migration path `{}` contains traversal", + dialect, display_path + ))); + }; + current.push(component); + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + inventory_error(format!( + "missing {} migration file `{}`", + dialect, display_path + )) + } else { + inventory_error(format!( + "inspect {} migration file `{}`: {error}", + dialect, display_path + )) + } + })?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "{} migration file `{}` must not be a symlink", + dialect, display_path + ), + )); + } + if index + 1 != components.len() && !metadata.is_dir() { + return Err(inventory_error(format!( + "{} migration path `{}` has a non-directory parent", + dialect, display_path + ))); + } + } + let metadata = fs::metadata(&path).map_err(|error| { + inventory_error(format!( + "inspect {} migration file `{}`: {error}", + dialect, display_path + )) + })?; + if !metadata.is_file() { + return Err( + ContractDiagnosticCode::CatalogSpecialFile.into_error(format!( + "{} migration file `{}` is not regular", + dialect, display_path + )), + ); + } + let file = File::open(&path).map_err(|error| { + inventory_error(format!( + "open {} migration file `{}`: {error}", + dialect, display_path + )) + })?; + let opened_metadata = file.metadata().map_err(|error| { + inventory_error(format!( + "inspect opened {} migration file `{}`: {error}", + dialect, display_path + )) + })?; + if !opened_metadata.is_file() { + return Err( + ContractDiagnosticCode::CatalogSpecialFile.into_error(format!( + "opened {} migration file `{}` is not regular", + dialect, display_path + )), + ); + } + if opened_metadata.len() > MAX_MIGRATION_SQL_BYTES as u64 { + return Err(inventory_error(format!( + "{} migration file `{}` exceeds {MAX_MIGRATION_SQL_BYTES} bytes", + dialect, display_path + ))); + } + let mut bytes = Vec::with_capacity(opened_metadata.len() as usize); + file.take(MAX_MIGRATION_SQL_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + inventory_error(format!( + "read {} migration file `{}`: {error}", + dialect, display_path + )) + })?; + if bytes.len() > MAX_MIGRATION_SQL_BYTES { + return Err(inventory_error(format!( + "{} migration file `{}` exceeds {MAX_MIGRATION_SQL_BYTES} bytes", + dialect, display_path + ))); + } + Ok(bytes) +} + +fn relative_path_display(root: &Path, path: &Path) -> String { + let relative = path + .strip_prefix(root) + .map(|relative| { + relative + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") + }) + .unwrap_or_else(|_| "".to_string()); + declared_path_display(&relative) +} + +fn collect_sql_files( + root: &Path, + dialect: MigrationDialect, +) -> Result, ContractError> { + let directory = root.join(dialect.directory()); + let mut result = BTreeSet::new(); + let mut pending = vec![directory]; + let mut directories = 0; + let mut entries_seen = 0usize; + while let Some(directory) = pending.pop() { + directories += 1; + if directories > DIALECT_DIRECTORY_LIMIT { + return Err(inventory_error(format!( + "{} migration directory tree exceeds {DIALECT_DIRECTORY_LIMIT} directories", + dialect + ))); + } + let directory_metadata = fs::symlink_metadata(&directory).map_err(|error| { + inventory_error(format!( + "inspect {} migration directory `{}`: {error}", + dialect, + relative_path_display(root, &directory) + )) + })?; + if directory_metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "{} migration directory `{}` must not be a symlink", + dialect, + relative_path_display(root, &directory) + ), + )); + } + if !directory_metadata.is_dir() { + return Err(inventory_error(format!( + "{} migration directory `{}` is not a directory", + dialect, + relative_path_display(root, &directory) + ))); + } + let mut read_entries = fs::read_dir(&directory).map_err(|error| { + inventory_error(format!( + "read {} migration directory `{}`: {error}", + dialect, + relative_path_display(root, &directory) + )) + })?; + let remaining_entries = MAX_MIGRATION_TOTAL_ENTRIES - entries_seen; + let mut entries = Vec::with_capacity(remaining_entries); + loop { + let Some(entry) = read_entries.next() else { + break; + }; + if entries_seen >= MAX_MIGRATION_TOTAL_ENTRIES { + return Err(inventory_error(format!( + "{} migration directory tree exceeds {MAX_MIGRATION_TOTAL_ENTRIES} entries", + dialect + ))); + } + entries_seen += 1; + entries.push(entry.map_err(|error| { + inventory_error(format!("read {dialect} migration directory entry: {error}")) + })?); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + inventory_error(format!( + "inspect {dialect} migration directory entry `{}`: {error}", + relative_path_display(root, &path) + )) + })?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "{} migration path `{}` must not be a symlink", + dialect, + relative_path_display(root, &path) + ), + )); + } + if metadata.is_dir() { + pending.push(path); + continue; + } + if !metadata.is_file() { + return Err( + ContractDiagnosticCode::CatalogSpecialFile.into_error(format!( + "{} migration path `{}` is not regular", + dialect, + relative_path_display(root, &path) + )), + ); + } + if path.extension().and_then(|extension| extension.to_str()) != Some("sql") { + continue; + } + let relative = path + .strip_prefix(root) + .map_err(|_| inventory_error("migration path escaped repository root".to_string()))? + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + result.insert((dialect, relative)); + if result.len() > MAX_MIGRATIONS * 2 { + return Err(inventory_error( + "migration directory contains too many SQL files".to_string(), + )); + } + } + } + Ok(result) +} + +fn validate_no_extra_dialect_directories(root: &Path) -> Result<(), ContractError> { + let migrations = root.join("migrations"); + let migrations_metadata = fs::symlink_metadata(&migrations) + .map_err(|error| inventory_error(format!("inspect migrations directory: {error}")))?; + if migrations_metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + "migrations directory must not be a symlink".to_string(), + )); + } + if !migrations_metadata.is_dir() { + return Err(inventory_error( + "migrations path is not a directory".to_string(), + )); + } + let mut read_entries = fs::read_dir(&migrations) + .map_err(|error| inventory_error(format!("read migrations directory: {error}")))?; + let mut entries_seen = 0usize; + let mut entries = Vec::with_capacity(MAX_MIGRATION_TOP_LEVEL_ENTRIES); + loop { + let Some(entry) = read_entries.next() else { + break; + }; + if entries_seen >= MAX_MIGRATION_TOP_LEVEL_ENTRIES { + return Err(inventory_error(format!( + "migrations directory exceeds {MAX_MIGRATION_TOP_LEVEL_ENTRIES} entries" + ))); + } + entries_seen += 1; + entries.push( + entry.map_err(|error| inventory_error(format!("read migrations entry: {error}")))?, + ); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| inventory_error(format!("inspect migrations entry: {error}")))?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "migration path `{}` must not be a symlink", + relative_path_display(root, &path) + ), + )); + } + if !metadata.is_dir() { + continue; + } + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !matches!(name, "sqlite" | "postgres") { + return Err(inventory_error(format!( + "unsupported migration dialect directory `{}`", + relative_path_display(root, &path) + ))); + } + } + Ok(()) +} + +fn reject_symlink_components( + root: &Path, + relative: &Path, + label: &str, +) -> Result<(), ContractError> { + let display_path = declared_path_display(&relative.to_string_lossy()); + let mut current = root.to_path_buf(); + for component in relative.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + return Err(inventory_error(format!( + "{label} path `{}` contains parent traversal", + display_path + ))); + } + Component::Normal(part) => current.push(part), + Component::Prefix(_) | Component::RootDir => { + return Err(inventory_error(format!( + "{label} path `{}` is not relative to the repository root", + display_path + ))); + } + } + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + inventory_error(format!("inspect {label} path `{}`: {error}", display_path)) + })?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "{label} path `{}` must not traverse a symlink", + display_path + ), + )); + } + } + Ok(()) +} + +fn read_bounded_file(path: &Path, limit: usize, label: &str) -> Result, ContractError> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| inventory_error(format!("read {label}: {error}")))?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} must not be a symlink"), + )); + } + if !metadata.is_file() { + return Err(ContractDiagnosticCode::CatalogSpecialFile + .into_error(format!("{label} is not a regular file"))); + } + if metadata.len() > limit as u64 { + return Err(inventory_error(format!( + "{label} is {} bytes; maximum supported size is {limit}", + metadata.len() + ))); + } + let file = + File::open(path).map_err(|error| inventory_error(format!("read {label}: {error}")))?; + let opened_size = file + .metadata() + .map_err(|error| inventory_error(format!("inspect opened {label}: {error}")))?; + if !opened_size.is_file() { + return Err(ContractDiagnosticCode::CatalogSpecialFile + .into_error(format!("opened {label} is not a regular file"))); + } + let opened_size = opened_size.len(); + if opened_size > limit as u64 { + return Err(inventory_error(format!( + "opened {label} is {opened_size} bytes; maximum supported size is {limit}" + ))); + } + let mut bytes = Vec::with_capacity(opened_size as usize); + file.take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| inventory_error(format!("read {label}: {error}")))?; + if bytes.len() > limit { + return Err(inventory_error(format!("{label} exceeds {limit} bytes"))); + } + Ok(bytes) +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn valid_revision(revision: &str) -> bool { + !revision.is_empty() + && revision.trim() == revision + && !revision.starts_with('-') + && !revision.contains(':') + && !revision.chars().any(char::is_control) + && !revision.chars().any(char::is_whitespace) +} + +fn git_revision_exists(root: &Path, revision: &str) -> bool { + Command::new("git") + .arg("-C") + .arg(root) + .args(["rev-parse", "--verify", "--quiet", "--end-of-options"]) + .arg(format!("{revision}^{{commit}}")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn git_file(root: &Path, revision: &str, path: &str) -> Result, String> { + let object = format!("{revision}:{path}"); + let limit = if path == MIGRATION_INVENTORY_PATH { + MAX_MIGRATION_INVENTORY_BYTES + } else { + MAX_MIGRATION_SQL_BYTES + }; + let mut child = Command::new("git") + .arg("-C") + .arg(root) + .args(["show", "--no-ext-diff", "--format=", "--end-of-options"]) + .arg(object) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| "could not invoke Git for the explicit revision".to_string())?; + let mut stdout = child.stdout.take().ok_or_else(|| { + terminate_child(&mut child); + "Git did not provide a readable baseline stream".to_string() + })?; + let mut bytes = Vec::new(); + let mut buffer = [0u8; 8 * 1024]; + loop { + match stdout.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + if read > limit.saturating_sub(bytes.len()) { + terminate_child(&mut child); + return Err(format!("baseline file exceeds {limit} bytes")); + } + bytes.extend_from_slice(&buffer[..read]); + } + Err(_) => { + terminate_child(&mut child); + return Err("could not read the explicit Git baseline file".to_string()); + } + } + } + drop(stdout); + let status = child + .wait() + .map_err(|_| "could not wait for Git baseline file".to_string())?; + if !status.success() { + return Err("Git did not provide the requested baseline file".to_string()); + } + Ok(bytes) +} + +fn terminate_child(child: &mut std::process::Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +trait DiagnosticCodeError { + fn into_error(self, message: String) -> ContractError; +} + +impl DiagnosticCodeError for ContractDiagnosticCode { + fn into_error(self, message: String) -> ContractError { + ContractError::new(self, message) + } +} diff --git a/distributed_cli/src/contracts/mod.rs b/distributed_cli/src/contracts/mod.rs new file mode 100644 index 000000000..68f3fe2c3 --- /dev/null +++ b/distributed_cli/src/contracts/mod.rs @@ -0,0 +1,59 @@ +//! Generic contract lifecycle records used by the CLI and its future checks. +//! +//! This module records ownership, references, identities, and diagnostics. It +//! deliberately does not model the semantic payload of an application, +//! deployment, migration, or client artifact. Those payloads remain owned by +//! their respective compiler or framework modules. + +mod artifact; +mod catalog; +mod chain; +mod closeout; +mod classification; +mod diagnostic; +mod migrations; +mod program; +mod snapshots; +mod transaction; + +#[cfg(test)] +mod tests; + +pub use artifact::{ + ArtifactIdentity, ArtifactPredecessor, ArtifactProvenance, ContractArtifactKind, + EnvironmentPolicyReference, +}; +pub use catalog::{ + ClientDeclaration, ClientInventory, ContractCatalog, ContractEntry, ContractError, + ContractScope, CLIENT_DECLARATION_SCHEMA_VERSION, CONTRACT_CATALOG_SCHEMA_VERSION, + 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, +}; +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 diagnostic::{ + ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, SafeDiagnosticValue, +}; +pub use migrations::{ + check_migration_history, check_migration_inventory, BaselineAvailability, MigrationDialect, + MigrationEntry, MigrationFile, MigrationHistoryCheck, MigrationInventory, MAX_MIGRATIONS, + MAX_MIGRATION_INVENTORY_BYTES, MAX_MIGRATION_JSON_DEPTH, MAX_MIGRATION_SQL_BYTES, + MAX_MIGRATION_TOP_LEVEL_ENTRIES, MAX_MIGRATION_TOTAL_ENTRIES, MIGRATION_INVENTORY_PATH, + MIGRATION_INVENTORY_SCHEMA_VERSION, MIGRATION_OWNER, MIGRATION_SCOPE, +}; +pub use program::{ + ClientProgramArtifact, ClientProgramAsset, ClientProgramDescriptor, ClientProgramSurface, + ProgramCompatibility, CLIENT_PROGRAM_DESCRIPTOR_VERSION, CLIENT_PROGRAM_POLICY_VERSION, +}; +pub use snapshots::{ + diff_snapshots, snapshot_from_json, SemanticSnapshot, SnapshotChange, SnapshotDiff, + SnapshotEntry, MAX_SNAPSHOT_DEPTH, MAX_SNAPSHOT_PATHS, MAX_SNAPSHOT_VALUE_BYTES, +}; +pub use transaction::{ + contracts_accept, contracts_check, unknown_scope_diagnostic, ContractAcceptScope, + ContractsAcceptReport, ContractsCheckReport, +}; diff --git a/distributed_cli/src/contracts/program.rs b/distributed_cli/src/contracts/program.rs new file mode 100644 index 000000000..c2eb8c780 --- /dev/null +++ b/distributed_cli/src/contracts/program.rs @@ -0,0 +1,460 @@ +//! Client program descriptors and three-way compatibility classification. +//! +//! Hot-code-push handoff for deployable UI programs. No browser staging, +//! service workers, or activation behavior lives here. + +use super::artifact::{ArtifactIdentity, ContractArtifactKind}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +/// Descriptor wire version. +pub const CLIENT_PROGRAM_DESCRIPTOR_VERSION: u32 = 1; +/// Compatibility policy version for V1 exact mutation/offline identities. +pub const CLIENT_PROGRAM_POLICY_VERSION: u32 = 1; +pub const MAX_PROGRAM_ASSETS: usize = 8_192; +pub const MAX_PROGRAM_ASSET_BYTES: usize = 32 * 1024 * 1024; + +/// One portable asset in a deployable program tree. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientProgramAsset { + /// Portable relative path under the deployable root. + pub path: String, + /// SHA-256 digest of file bytes (`sha256:`). + pub digest: String, + /// Optional SRI string when produced by the packaging pipeline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrity: Option, + pub size_bytes: u64, +} + +/// One client surface bound into the program. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientProgramSurface { + pub name: String, + /// Schema fingerprint from the accepted surface manifest. + pub schema_fingerprint: String, + /// Protocol fingerprint from the accepted surface manifest. + pub protocol_fingerprint: String, +} + +/// One generated operation/artifact identity referenced by the program. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientProgramArtifact { + pub path: String, + pub digest: String, +} + +/// Complete deterministic client program descriptor. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientProgramDescriptor { + pub version: u32, + pub policy_version: u32, + pub program_name: String, + /// Complete identity of the program (version + policy + contract set + assets). + pub program_id: String, + /// Sorted surface/artifact contract identities. + pub contract_set_id: String, + pub surfaces: Vec, + pub artifacts: Vec, + pub assets: Vec, + /// Immediate application-manifest predecessor identity. + pub application_manifest: ArtifactIdentity, + /// Immediate deployment-plan predecessor identity. + pub deployment_plan: ArtifactIdentity, +} + +/// Three-way compatibility classification for loaded vs advertised programs. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProgramCompatibility { + /// Exact program identity match. + Current, + /// Contract set identical; only assets differ (hot asset push). + CompatibleAssetOnlyUpdate, + /// Mutation/offline contract set differs or material is incomplete. + IncompatibleRequiredUpdate, +} + +impl ClientProgramDescriptor { + pub fn builder(program_name: impl Into) -> ClientProgramDescriptorBuilder { + ClientProgramDescriptorBuilder { + program_name: program_name.into(), + surfaces: Vec::new(), + artifacts: Vec::new(), + assets: Vec::new(), + application_manifest: None, + deployment_plan: None, + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.version != CLIENT_PROGRAM_DESCRIPTOR_VERSION { + return Err(format!( + "unsupported client program descriptor version {}", + self.version + )); + } + if self.policy_version != CLIENT_PROGRAM_POLICY_VERSION { + return Err(format!( + "unsupported client program policy version {}", + self.policy_version + )); + } + if self.program_name.trim().is_empty() { + return Err("program_name must not be empty".into()); + } + if self.surfaces.is_empty() { + 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}")); + } + let mut paths = BTreeSet::new(); + for asset in &self.assets { + validate_portable_path(&asset.path)?; + if !paths.insert(asset.path.clone()) { + return Err(format!("duplicate portable asset path `{}`", asset.path)); + } + if !asset.digest.starts_with("sha256:") { + return Err(format!("asset `{}` digest must be sha256", asset.path)); + } + } + for surface in &self.surfaces { + if surface.name.trim().is_empty() { + return Err("surface name must not be empty".into()); + } + } + if self.application_manifest.kind != ContractArtifactKind::ApplicationManifest { + return Err("application_manifest predecessor kind mismatch".into()); + } + if self.deployment_plan.kind != ContractArtifactKind::DeploymentPlan { + return Err("deployment_plan predecessor kind mismatch".into()); + } + let expected_contract = contract_set_id(&self.surfaces, &self.artifacts); + if self.contract_set_id != expected_contract { + return Err("contract_set_id is stale relative to surfaces/artifacts".into()); + } + let expected_program = program_id( + self.version, + self.policy_version, + &self.contract_set_id, + &self.assets, + ); + if self.program_id != expected_program { + return Err("program_id is stale relative to contract set and assets".into()); + } + Ok(()) + } + + pub fn canonical_bytes(&self) -> Result, String> { + self.validate()?; + serde_json::to_vec(self).map_err(|error| error.to_string()) + } + + pub fn classify_against(&self, loaded: &Self) -> Result { + self.validate()?; + loaded.validate()?; + if self.program_id == loaded.program_id { + return Ok(ProgramCompatibility::Current); + } + if self.contract_set_id == loaded.contract_set_id { + return Ok(ProgramCompatibility::CompatibleAssetOnlyUpdate); + } + Ok(ProgramCompatibility::IncompatibleRequiredUpdate) + } +} + +pub struct ClientProgramDescriptorBuilder { + program_name: String, + surfaces: Vec, + artifacts: Vec, + assets: Vec, + application_manifest: Option, + deployment_plan: Option, +} + +impl ClientProgramDescriptorBuilder { + pub fn surface(mut self, surface: ClientProgramSurface) -> Self { + self.surfaces.push(surface); + self + } + + pub fn artifact(mut self, artifact: ClientProgramArtifact) -> Self { + self.artifacts.push(artifact); + self + } + + pub fn asset(mut self, asset: ClientProgramAsset) -> Self { + self.assets.push(asset); + self + } + + pub fn application_manifest(mut self, identity: ArtifactIdentity) -> Self { + self.application_manifest = Some(identity); + self + } + + pub fn deployment_plan(mut self, identity: ArtifactIdentity) -> Self { + self.deployment_plan = Some(identity); + self + } + + /// Hash every regular file under `root` into portable assets. + pub fn assets_from_dir(mut self, root: &Path) -> Result { + let mut assets = collect_assets(root)?; + assets.sort(); + self.assets.extend(assets); + Ok(self) + } + + pub fn build(mut self) -> Result { + self.surfaces.sort_by(|a, b| a.name.cmp(&b.name)); + self.artifacts.sort_by(|a, b| a.path.cmp(&b.path)); + self.assets.sort(); + let application_manifest = self.application_manifest.ok_or_else(|| { + "client program requires application_manifest predecessor identity".to_string() + })?; + let deployment_plan = self.deployment_plan.ok_or_else(|| { + "client program requires deployment_plan predecessor identity".to_string() + })?; + let contract_set_id = contract_set_id(&self.surfaces, &self.artifacts); + let program_id = program_id( + CLIENT_PROGRAM_DESCRIPTOR_VERSION, + CLIENT_PROGRAM_POLICY_VERSION, + &contract_set_id, + &self.assets, + ); + let descriptor = ClientProgramDescriptor { + version: CLIENT_PROGRAM_DESCRIPTOR_VERSION, + policy_version: CLIENT_PROGRAM_POLICY_VERSION, + program_name: self.program_name, + program_id, + contract_set_id, + surfaces: self.surfaces, + artifacts: self.artifacts, + assets: self.assets, + application_manifest, + deployment_plan, + }; + descriptor.validate()?; + Ok(descriptor) + } +} + +fn contract_set_id( + surfaces: &[ClientProgramSurface], + artifacts: &[ClientProgramArtifact], +) -> String { + let mut material = BTreeMap::new(); + for surface in surfaces { + material.insert( + format!("surface:{}", surface.name), + format!( + "{}|{}", + surface.schema_fingerprint, surface.protocol_fingerprint + ), + ); + } + for artifact in artifacts { + 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) +} + +fn program_id( + version: u32, + policy_version: u32, + contract_set_id: &str, + assets: &[ClientProgramAsset], +) -> String { + let asset_ids = assets + .iter() + .map(|asset| format!("{}:{}", asset.path, asset.digest)) + .collect::>(); + let material = serde_json::json!({ + "version": version, + "policy_version": policy_version, + "contract_set_id": contract_set_id, + "assets": asset_ids, + }); + let bytes = serde_json::to_vec(&material).unwrap_or_default(); + identity_digest("distributed.client-program.program-id.v1", &bytes) +} + +fn collect_assets(root: &Path) -> Result, String> { + if !root.is_dir() { + 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(); + let mut stack = vec![root.clone()]; + while let Some(dir) = stack.pop() { + let entries = fs::read_dir(&dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + let meta = entry.metadata().map_err(|e| e.to_string())?; + if meta.file_type().is_symlink() { + return Err(format!("symlink assets are rejected: {}", path.display())); + } + if meta.is_dir() { + stack.push(path); + continue; + } + if !meta.is_file() { + return Err(format!("special files are rejected: {}", path.display())); + } + if meta.len() as usize > MAX_PROGRAM_ASSET_BYTES { + return Err(format!( + "asset `{}` exceeds max bytes {MAX_PROGRAM_ASSET_BYTES}", + path.display() + )); + } + let relative = path + .strip_prefix(&root) + .map_err(|_| "asset escaped root".to_string())? + .to_string_lossy() + .replace('\\', "/"); + validate_portable_path(&relative)?; + let bytes = fs::read(&path).map_err(|e| e.to_string())?; + let digest = { + let mut hasher = Sha256::new(); + hasher.update(&bytes); + format!("sha256:{:x}", hasher.finalize()) + }; + assets.push(ClientProgramAsset { + path: relative, + digest, + integrity: None, + size_bytes: meta.len(), + }); + if assets.len() > MAX_PROGRAM_ASSETS { + return Err(format!("asset tree exceeds max count {MAX_PROGRAM_ASSETS}")); + } + } + } + Ok(assets) +} + +fn validate_portable_path(path: &str) -> Result<(), String> { + if path.is_empty() || path.starts_with('/') || path.contains('\0') { + return Err(format!("invalid portable path `{path}`")); + } + if Path::new(path) + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(format!("path traversal rejected: `{path}`")); + } + Ok(()) +} + +// Local helper so program module does not depend on application identity. +fn identity_digest(domain: &str, bytes: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(domain.as_bytes()); + digest.update(b"\0"); + digest.update(bytes); + format!("sha256:{:x}", digest.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("client-program-{nanos}")); + fs::create_dir_all(&path).unwrap(); + path + } + + fn base_builder(assets_dir: &Path) -> ClientProgramDescriptorBuilder { + ClientProgramDescriptor::builder("e2e-ui") + .surface(ClientProgramSurface { + name: "e2e-ui".into(), + schema_fingerprint: "sha256:schema".into(), + protocol_fingerprint: "sha256:protocol".into(), + }) + .artifact(ClientProgramArtifact { + path: "operations/todos.ts".into(), + digest: "sha256:op".into(), + }) + .application_manifest(ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:app", + )) + .deployment_plan(ArtifactIdentity::new( + ContractArtifactKind::DeploymentPlan, + "sha256:plan", + )) + .assets_from_dir(assets_dir) + .unwrap() + } + + #[test] + fn descriptor_is_byte_deterministic_and_classifies_asset_only_updates() { + let root = temp_dir(); + 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.classify_against(&second).unwrap(), + ProgramCompatibility::Current + ); + + fs::write(root.join("app.js"), b"console.log(2)").unwrap(); + let asset_changed = base_builder(&root).build().unwrap(); + assert_eq!( + first.classify_against(&asset_changed).unwrap(), + ProgramCompatibility::CompatibleAssetOnlyUpdate + ); + assert_eq!(first.contract_set_id, asset_changed.contract_set_id); + assert_ne!(first.program_id, asset_changed.program_id); + + let mut surface_changed = base_builder(&root).build().unwrap(); + surface_changed.surfaces[0].schema_fingerprint = "sha256:other".into(); + // Rebuild ids after mutation via builder path + let surface_changed = ClientProgramDescriptor::builder("e2e-ui") + .surface(ClientProgramSurface { + name: "e2e-ui".into(), + schema_fingerprint: "sha256:other".into(), + protocol_fingerprint: "sha256:protocol".into(), + }) + .artifact(ClientProgramArtifact { + path: "operations/todos.ts".into(), + digest: "sha256:op".into(), + }) + .application_manifest(ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:app", + )) + .deployment_plan(ArtifactIdentity::new( + ContractArtifactKind::DeploymentPlan, + "sha256:plan", + )) + .assets_from_dir(&root) + .unwrap() + .build() + .unwrap(); + assert_eq!( + first.classify_against(&surface_changed).unwrap(), + ProgramCompatibility::IncompatibleRequiredUpdate + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/distributed_cli/src/contracts/snapshots.rs b/distributed_cli/src/contracts/snapshots.rs new file mode 100644 index 000000000..6c387e620 --- /dev/null +++ b/distributed_cli/src/contracts/snapshots.rs @@ -0,0 +1,253 @@ +//! Canonical semantic snapshots for lifecycle review. +//! +//! Snapshots capture behavior-affecting material as reviewable paths and values +//! rather than opaque hashes alone. Absolute paths, timestamps, environment +//! values, and secrets are excluded. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// Maximum number of snapshot paths retained for one artifact. +pub const MAX_SNAPSHOT_PATHS: usize = 16_384; +/// Maximum depth when walking nested JSON. +pub const MAX_SNAPSHOT_DEPTH: usize = 32; +/// Maximum string value bytes retained in a snapshot entry. +pub const MAX_SNAPSHOT_VALUE_BYTES: usize = 4_096; + +/// One canonical semantic path and its reviewable value. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotEntry { + /// Dot-path into the semantic material (stable, sorted). + pub path: String, + /// JSON value at the path (objects already flattened into child paths). + pub value: Value, +} + +/// Deterministic semantic snapshot for one artifact owner. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticSnapshot { + /// Semantic owner identity (spec/source path or catalog entry id). + pub owner: String, + /// Artifact kind label (surface, application_manifest, deployment_plan, …). + pub kind: String, + /// Sorted path inventory. + pub entries: Vec, + /// Content digest of the canonical snapshot bytes. + pub digest: String, +} + +/// Diff between two snapshots. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotDiff { + pub owner: String, + pub kind: String, + pub changes: Vec, +} + +/// One path-level change requiring a lifecycle decision. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotChange { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +/// Build a semantic snapshot from arbitrary JSON by flattening into sorted paths. +pub fn snapshot_from_json( + owner: impl Into, + kind: impl Into, + value: &Value, +) -> Result { + let owner = owner.into(); + let kind = kind.into(); + let mut paths = BTreeMap::new(); + flatten_value("", value, 0, &mut paths)?; + if paths.len() > MAX_SNAPSHOT_PATHS { + return Err(format!( + "snapshot for `{owner}` exceeds max path count {MAX_SNAPSHOT_PATHS}" + )); + } + let entries = paths + .into_iter() + .map(|(path, value)| SnapshotEntry { path, value }) + .collect::>(); + let digest = digest_entries(&entries); + Ok(SemanticSnapshot { + owner, + kind, + entries, + digest, + }) +} + +/// Diff two snapshots of the same owner/kind. +pub fn diff_snapshots(before: &SemanticSnapshot, after: &SemanticSnapshot) -> SnapshotDiff { + let mut changes = Vec::new(); + let mut before_map = before + .entries + .iter() + .map(|entry| (entry.path.as_str(), &entry.value)) + .collect::>(); + for entry in &after.entries { + match before_map.remove(entry.path.as_str()) { + Some(previous) if previous == &entry.value => {} + Some(previous) => changes.push(SnapshotChange { + path: entry.path.clone(), + before: Some(previous.clone()), + after: Some(entry.value.clone()), + }), + None => changes.push(SnapshotChange { + path: entry.path.clone(), + before: None, + after: Some(entry.value.clone()), + }), + } + } + for (path, previous) in before_map { + changes.push(SnapshotChange { + path: path.to_string(), + before: Some(previous.clone()), + after: None, + }); + } + changes.sort_by(|left, right| left.path.cmp(&right.path)); + SnapshotDiff { + owner: after.owner.clone(), + kind: after.kind.clone(), + changes, + } +} + +fn flatten_value( + prefix: &str, + value: &Value, + depth: usize, + out: &mut BTreeMap, +) -> Result<(), String> { + if depth > MAX_SNAPSHOT_DEPTH { + return Err(format!( + "snapshot depth exceeds maximum {MAX_SNAPSHOT_DEPTH} at `{prefix}`" + )); + } + match value { + Value::Object(fields) => { + // Skip volatile / non-semantic keys. + for (key, child) in fields { + if is_volatile_key(key) { + continue; + } + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + flatten_value(&path, child, depth + 1, out)?; + } + } + Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + let path = format!("{prefix}[{index}]"); + flatten_value(&path, child, depth + 1, out)?; + } + } + Value::String(text) if text.len() > MAX_SNAPSHOT_VALUE_BYTES => { + out.insert( + prefix.to_string(), + Value::String(format!( + "", + text.len() + )), + ); + } + other => { + out.insert(prefix.to_string(), other.clone()); + } + } + Ok(()) +} + +fn is_volatile_key(key: &str) -> bool { + matches!( + key, + "generated_at" + | "timestamp" + | "wall_time" + | "absolute_path" + | "cwd" + | "hostname" + | "env" + | "environment" + | "secret" + | "password" + | "token" + | "connection_string" + ) +} + +fn digest_entries(entries: &[SnapshotEntry]) -> String { + let bytes = serde_json::to_vec(entries).unwrap_or_default(); + let mut digest = Sha256::new(); + digest.update(b"distributed.contract.snapshot.v1\0"); + digest.update(&bytes); + format!("sha256:{:x}", digest.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn canonical_snapshot_ignores_unordered_input_but_detects_nullability() { + let left = snapshot_from_json( + "surface/web", + "surface_client_manifest", + &json!({ + "models": { + "TodoView": { "fields": { "title": { "nullable": false }, "id": { "nullable": false } } } + }, + "generated_at": "2026-01-01T00:00:00Z" + }), + ) + .unwrap(); + let right = snapshot_from_json( + "surface/web", + "surface_client_manifest", + &json!({ + "models": { + "TodoView": { "fields": { "id": { "nullable": false }, "title": { "nullable": false } } } + } + }), + ) + .unwrap(); + assert_eq!(left.digest, right.digest); + assert!(diff_snapshots(&left, &right).changes.is_empty()); + + let drifted = snapshot_from_json( + "surface/web", + "surface_client_manifest", + &json!({ + "models": { + "TodoView": { "fields": { "id": { "nullable": false }, "title": { "nullable": true } } } + } + }), + ) + .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].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 new file mode 100644 index 000000000..127f38378 --- /dev/null +++ b/distributed_cli/src/contracts/tests.rs @@ -0,0 +1,1618 @@ +use super::*; +use serde_json::{json, Value}; +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sha2::{Digest, Sha256}; + +#[cfg(unix)] +use std::os::unix::fs::symlink; +#[cfg(unix)] +use std::os::unix::net::UnixListener; + +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("distributed_cli has a repository parent") + .to_path_buf() +} + +fn fixture(name: &str) -> &'static str { + match name { + "valid" => include_str!("../../tests/fixtures/contracts/catalog/catalog-valid.json"), + "duplicate" => { + include_str!("../../tests/fixtures/contracts/catalog/catalog-duplicate-scope.json") + } + "escaping" => { + include_str!("../../tests/fixtures/contracts/catalog/catalog-escaping-path.json") + } + "cycle" => include_str!("../../tests/fixtures/contracts/catalog/catalog-chain-cycle.json"), + "environment" => { + include_str!("../../tests/fixtures/contracts/catalog/catalog-environment-value.json") + } + _ => panic!("unknown catalog fixture {name}"), + } +} + +fn path_catalog(source: &str) -> ContractCatalog { + let input = serde_json::json!({ + "schema_version": 1, + "entries": { + "path-test": { + "id": "path-test", + "kind": "migration_inventory", + "scope": { "id": "path/test" }, + "owner": "test/path", + "identity": { + "kind": "migration_inventory", + "value": "sha256:path-test" + }, + "provenance": { + "sources": [source], + "generator": "test.path" + }, + "outputs": { "output": "inside.txt" } + } + } + }); + ContractCatalog::from_json_str(&input.to_string()).expect("path catalog JSON") +} + +fn migration_checksum(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn write_repository_file(root: &Path, relative: &str, bytes: &[u8]) { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("migration file parent")) + .expect("migration file parent directory"); + fs::write(path, bytes).expect("migration fixture file"); +} + +fn migration_entry( + version: u64, + sqlite_path: &str, + sqlite_sql: &[u8], + postgres_path: &str, + postgres_sql: &[u8], +) -> Value { + json!({ + "version": version, + "description": if version == 1 { "initial" } else { "next" }, + "sqlite": { + "path": sqlite_path, + "sha256": migration_checksum(sqlite_sql) + }, + "postgres": { + "path": postgres_path, + "sha256": migration_checksum(postgres_sql) + } + }) +} + +fn write_migration_inventory(root: &Path, entries: &[Value]) { + let path = root.join(MIGRATION_INVENTORY_PATH); + fs::create_dir_all(path.parent().expect("inventory parent directory")) + .expect("inventory parent directory"); + fs::write( + path, + serde_json::to_vec_pretty(&json!({ + "schema_version": MIGRATION_INVENTORY_SCHEMA_VERSION, + "migrations": entries + })) + .expect("migration inventory JSON"), + ) + .expect("migration inventory file"); +} + +fn create_migration_fixture(label: &str) -> (TemporaryDirectory, Vec, Vec) { + let root = TemporaryDirectory::new_short(label); + let sqlite_sql = b"CREATE TABLE one (id INTEGER PRIMARY KEY);\n".to_vec(); + let postgres_sql = b"CREATE TABLE one (id BIGINT PRIMARY KEY);\n".to_vec(); + let sqlite_path = "migrations/sqlite/0001_initial.sql"; + let postgres_path = "migrations/postgres/0001_initial.sql"; + write_repository_file(root.path(), sqlite_path, &sqlite_sql); + write_repository_file(root.path(), postgres_path, &postgres_sql); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + sqlite_path, + &sqlite_sql, + postgres_path, + &postgres_sql, + )], + ); + (root, sqlite_sql, postgres_sql) +} + +fn git_fixture_command(root: &Path, args: &[&str]) -> std::process::Output { + Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .expect("invoke git fixture command") +} + +fn commit_migration_fixture(root: &Path) -> String { + for args in [ + ["init", "-q"].as_slice(), + ["config", "user.email", "migration-tests@example.invalid"].as_slice(), + ["config", "user.name", "Migration Tests"].as_slice(), + ["add", "--all"].as_slice(), + ["commit", "-qm", "baseline"].as_slice(), + ] { + let output = git_fixture_command(root, args); + assert!( + output.status.success(), + "git fixture command {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + } + let output = git_fixture_command(root, &["rev-parse", "HEAD"]); + assert!(output.status.success(), "read fixture revision"); + String::from_utf8(output.stdout) + .expect("fixture revision UTF-8") + .trim() + .to_string() +} + +fn glob_catalog(source: &str, glob_limit: usize) -> ContractCatalog { + let input = serde_json::json!({ + "schema_version": 1, + "entries": { + "glob-test": { + "id": "glob-test", + "kind": "migration_inventory", + "scope": { "id": "path/glob" }, + "owner": "test/glob", + "identity": { + "kind": "migration_inventory", + "value": "sha256:glob-test" + }, + "provenance": { + "sources": [source], + "generator": "test.glob", + "glob_limit": glob_limit + }, + "outputs": { "output": "inside.txt" } + } + } + }); + ContractCatalog::from_json_str(&input.to_string()).expect("glob catalog JSON") +} + +fn create_sparse_file(path: &Path, length: usize) { + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .expect("sparse file"); + file.set_len(length as u64).expect("sparse file length"); +} + +struct TemporaryDirectory(PathBuf); + +impl TemporaryDirectory { + fn new(label: &str) -> Self { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos(); + let base = std::env::temp_dir(); + for attempt in 0..100 { + let path = base.join(format!( + "distributed-contracts-{label}-{}-{timestamp}-{attempt}", + std::process::id() + )); + if fs::create_dir(&path).is_ok() { + return Self(path); + } + } + panic!("could not create temporary directory for {label}"); + } + + fn path(&self) -> &Path { + &self.0 + } + + #[cfg(unix)] + fn new_short(label: &str) -> Self { + let base = std::env::temp_dir(); + for attempt in 0..100 { + let path = base.join(format!("dct-{label}-{}-{attempt}", std::process::id())); + if fs::create_dir(&path).is_ok() { + return Self(path); + } + } + panic!("could not create short temporary directory for {label}"); + } +} + +impl Drop for TemporaryDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn catalog_references_one_declarative_client_inventory() { + let root = repository_root(); + let catalog = ContractCatalog::from_path(root.join("distributed.contracts.json")) + .expect("repository catalog should resolve without writing"); + let inventory = + ClientInventory::from_path(root.join("tests/e2e-ui/ui/distributed.clients.json")) + .expect("client inventory should use the shared schema"); + + assert_eq!(inventory.clients.len(), 3); + assert!(inventory + .clients + .iter() + .any(|client| client.surface == "e2e-ui-admin")); + assert_eq!( + catalog.canonical_bytes().expect("canonical catalog"), + ContractCatalog::from_json_str( + std::str::from_utf8(&catalog.canonical_bytes().expect("canonical catalog")) + .expect("canonical catalog is UTF-8") + ) + .expect("canonical catalog parses") + .canonical_bytes() + .expect("second canonical catalog") + ); +} + +#[test] +fn catalog_from_path_accepts_repository_relative_catalog_name() { + if std::env::var_os("DISTRIBUTED_CATALOG_RELATIVE_PATH_CHILD").is_some() { + ContractCatalog::from_path("distributed.contracts.json") + .expect("repository-relative catalog path should resolve from the repository root"); + return; + } + + let status = Command::new(std::env::current_exe().expect("contract test executable")) + .current_dir(repository_root()) + .env("DISTRIBUTED_CATALOG_RELATIVE_PATH_CHILD", "1") + .args([ + "--exact", + "contracts::tests::catalog_from_path_accepts_repository_relative_catalog_name", + "--quiet", + ]) + .status() + .expect("run repository-relative catalog test from the repository root"); + assert!(status.success()); +} + +#[test] +fn catalog_and_inventory_reject_sparse_oversized_files_before_reading() { + let root = TemporaryDirectory::new("sparse-input"); + let catalog_path = root.path().join("distributed.contracts.json"); + create_sparse_file(&catalog_path, MAX_CATALOG_BYTES + 1); + let catalog_error = ContractCatalog::from_path(&catalog_path) + .expect_err("sparse oversized catalog must fail before allocation"); + assert_eq!( + catalog_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); + + let inventory_path = root.path().join("distributed.clients.json"); + create_sparse_file(&inventory_path, MAX_CATALOG_BYTES + 1); + let inventory_error = ClientInventory::from_path(&inventory_path) + .expect_err("sparse oversized inventory must fail before allocation"); + assert_eq!( + inventory_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); +} + +#[test] +fn catalog_rejects_duplicate_scope_and_escaping_paths() { + let duplicate = ContractCatalog::from_json_str(fixture("duplicate")) + .expect_err("duplicate scopes must fail"); + assert_eq!( + duplicate.code(), + ContractDiagnosticCode::CatalogDuplicateScope + ); + + let escaping = ContractCatalog::from_json_str(fixture("escaping")) + .expect_err("parent traversal must fail"); + assert_eq!(escaping.code(), ContractDiagnosticCode::CatalogPath); +} + +#[test] +fn catalog_rejects_unknown_kinds_duplicate_owners_outputs_and_unbounded_globs() { + let mut unknown_kind: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + unknown_kind["entries"]["application-manifest"]["kind"] = + Value::String("future_artifact".to_string()); + let unknown_kind = ContractCatalog::from_json_str( + &serde_json::to_string(&unknown_kind).expect("unknown kind JSON"), + ) + .expect_err("unknown kinds must fail"); + assert_eq!( + unknown_kind.code(), + ContractDiagnosticCode::CatalogUnknownKind + ); + + let mut duplicate_owner: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + duplicate_owner["entries"]["deployment-plan"]["owner"] = + duplicate_owner["entries"]["application-manifest"]["owner"].clone(); + let duplicate_owner = ContractCatalog::from_json_str( + &serde_json::to_string(&duplicate_owner).expect("duplicate owner JSON"), + ) + .expect_err("duplicate owners must fail"); + assert_eq!( + duplicate_owner.code(), + ContractDiagnosticCode::CatalogDuplicateOwner + ); + + let mut duplicate_output: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + duplicate_output["entries"]["deployment-plan"]["outputs"]["deployment"] = + duplicate_output["entries"]["application-manifest"]["outputs"]["application"].clone(); + let duplicate_output = ContractCatalog::from_json_str( + &serde_json::to_string(&duplicate_output).expect("duplicate output JSON"), + ) + .expect_err("duplicate outputs must fail"); + assert_eq!( + duplicate_output.code(), + ContractDiagnosticCode::CatalogDuplicateOutput + ); + + let mut unbounded_glob: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + unbounded_glob["entries"]["application-manifest"]["provenance"]["sources"][0] = + Value::String("migrations/**/*.sql".to_string()); + unbounded_glob["entries"]["application-manifest"]["provenance"] + .as_object_mut() + .expect("provenance object") + .remove("glob_limit"); + let unbounded_glob = ContractCatalog::from_json_str( + &serde_json::to_string(&unbounded_glob).expect("unbounded glob JSON"), + ) + .expect_err("unbounded globs must fail"); + assert_eq!( + unbounded_glob.code(), + ContractDiagnosticCode::CatalogUnboundedGlob + ); +} + +#[test] +fn catalog_rejects_recursive_globs_even_with_a_match_limit() { + let mut recursive_glob: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + recursive_glob["entries"]["application-manifest"]["provenance"]["sources"][0] = + Value::String("migrations/**/*.sql".to_string()); + recursive_glob["entries"]["application-manifest"]["provenance"]["glob_limit"] = Value::from(1); + let recursive_glob = ContractCatalog::from_json_str( + &serde_json::to_string(&recursive_glob).expect("recursive glob JSON"), + ) + .expect_err("recursive globs must fail despite a match limit"); + assert_eq!( + recursive_glob.code(), + ContractDiagnosticCode::CatalogUnboundedGlob + ); +} + +#[test] +fn catalog_glob_discovery_bounds_candidate_directory_entries() { + let root = TemporaryDirectory::new("glob-candidates"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let candidates = root.path().join("candidates"); + fs::create_dir(&candidates).expect("candidate directory"); + for index in 0..=MAX_CATALOG_DIRECTORY_ENTRIES { + fs::write( + candidates.join(format!("candidate-{index:04}.txt")), + b"candidate", + ) + .expect("candidate fixture"); + } + + let error = glob_catalog("candidates/*.sql", 1) + .validate_paths(root.path()) + .expect_err("glob candidate traversal must be bounded independently of matches"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogInputLimit); +} + +#[cfg(unix)] +#[test] +fn catalog_glob_uses_canonical_symlinked_parent_for_candidate_limits() { + let root = TemporaryDirectory::new_short("glob-symlink-parent"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let target = root.path().join("candidate-target"); + fs::create_dir(&target).expect("candidate target directory"); + for index in 0..=MAX_CATALOG_DIRECTORY_ENTRIES { + fs::write( + target.join(format!("candidate-{index:04}.txt")), + b"candidate", + ) + .expect("candidate fixture"); + } + symlink(&target, root.path().join("candidates")).expect("symlinked candidate directory"); + + let error = glob_catalog("candidates/*.sql", 1) + .validate_paths(root.path()) + .expect_err("symlinked glob candidate traversal must be bounded"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogInputLimit); +} + +#[test] +fn catalog_accepts_a_glob_within_a_bounded_candidate_directory() { + let root = TemporaryDirectory::new("bounded-glob"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let candidates = root.path().join("candidates"); + fs::create_dir(&candidates).expect("candidate directory"); + fs::write(candidates.join("selected.sql"), b"candidate").expect("candidate fixture"); + + glob_catalog("candidates/*.sql", 1) + .validate_paths(root.path()) + .expect("bounded glob candidate traversal"); +} + +#[test] +fn catalog_rejects_absolute_paths_and_exhausting_filesystem_traversal() { + let absolute = ContractCatalog::from_json_str( + &serde_json::json!({ + "schema_version": 1, + "entries": { + "absolute": { + "id": "absolute", + "kind": "migration_inventory", + "scope": { "id": "path/absolute" }, + "owner": "test/absolute", + "identity": { + "kind": "migration_inventory", + "value": "sha256:absolute" + }, + "provenance": { + "sources": ["/etc/passwd"], + "generator": "test.absolute" + }, + "outputs": { "output": "inside.txt" } + } + } + }) + .to_string(), + ) + .expect_err("absolute paths must fail"); + assert_eq!(absolute.code(), ContractDiagnosticCode::CatalogPath); + + let root = TemporaryDirectory::new("traversal"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let empty_tree = root.path().join("empty-tree"); + fs::create_dir(&empty_tree).expect("empty tree"); + for index in 0..=MAX_CATALOG_DIRECTORIES { + fs::create_dir(empty_tree.join(format!("directory-{index:04}"))) + .expect("empty child directory"); + } + let traversal = path_catalog("empty-tree") + .validate_paths(root.path()) + .expect_err("empty directories must be bounded"); + assert_eq!(traversal.code(), ContractDiagnosticCode::CatalogInputLimit); + + let deep_tree = root.path().join("deep-tree"); + fs::create_dir(&deep_tree).expect("deep tree"); + let mut current = deep_tree; + for depth in 0..=MAX_CATALOG_DIRECTORY_DEPTH { + current = current.join(format!("level-{depth:02}")); + fs::create_dir(¤t).expect("deep child directory"); + } + let depth_error = path_catalog("deep-tree") + .validate_paths(root.path()) + .expect_err("directory depth must be bounded"); + assert_eq!( + depth_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); + + let many_entries = root.path().join("many-entries"); + fs::create_dir(&many_entries).expect("many-entry directory"); + for index in 0..=MAX_CATALOG_DIRECTORY_ENTRIES { + fs::write(many_entries.join(format!("file-{index:04}")), b"entry") + .expect("many-entry file"); + } + let entry_error = path_catalog("many-entries") + .validate_paths(root.path()) + .expect_err("directory entries must be bounded"); + assert_eq!( + entry_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); +} + +#[test] +fn catalog_rejects_excessive_json_bytes_and_depth() { + let oversized = format!( + "{{\"schema_version\":1,\"entries\":{{}},\"padding\":\"{}\"}}", + "x".repeat(MAX_CATALOG_BYTES) + ); + let byte_error = ContractCatalog::from_json_str(&oversized) + .expect_err("oversized JSON must fail before parsing"); + assert_eq!(byte_error.code(), ContractDiagnosticCode::CatalogInputLimit); + + let mut deeply_nested = String::from("{\"padding\":"); + for _ in 0..=MAX_CATALOG_JSON_DEPTH { + deeply_nested.push('['); + } + deeply_nested.push_str("null"); + for _ in 0..=MAX_CATALOG_JSON_DEPTH { + deeply_nested.push(']'); + } + deeply_nested.push('}'); + let depth_error = + ContractCatalog::from_json_str(&deeply_nested).expect_err("deeply nested JSON must fail"); + assert_eq!( + depth_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); +} + +#[cfg(unix)] +#[test] +fn catalog_rejects_symlink_and_special_file_paths() { + let root = TemporaryDirectory::new_short("filesystem"); + let outside = TemporaryDirectory::new("outside"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let outside_file = outside.path().join("outside.txt"); + fs::write(&outside_file, b"outside").expect("outside fixture"); + + symlink(&outside_file, root.path().join("escaped")).expect("symlink fixture"); + let symlink_error = path_catalog("escaped") + .validate_paths(root.path()) + .expect_err("symlink escapes must fail"); + assert_eq!( + symlink_error.code(), + ContractDiagnosticCode::CatalogSymlinkEscape + ); + + let socket_path = root.path().join("special.sock"); + let _listener = UnixListener::bind(&socket_path).expect("socket fixture"); + let special_error = path_catalog("special.sock") + .validate_paths(root.path()) + .expect_err("special files must fail"); + assert_eq!( + special_error.code(), + ContractDiagnosticCode::CatalogSpecialFile + ); +} + +#[cfg(unix)] +#[test] +fn client_inventory_loader_rejects_symlink_inputs() { + let root = TemporaryDirectory::new_short("inventory-symlink"); + let inventory = root.path().join("distributed.clients.json"); + fs::write( + &inventory, + include_str!("../../../tests/e2e-ui/ui/distributed.clients.json"), + ) + .expect("inventory fixture"); + let link = root.path().join("inventory-link.json"); + symlink(&inventory, &link).expect("inventory symlink fixture"); + + let error = ClientInventory::from_path(&link) + .expect_err("inventory symlink inputs must be rejected before following them"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogSymlinkEscape); + assert_eq!(error.message(), "client inventory must not be a symlink"); +} + +#[test] +fn artifact_chain_rejects_cycles_kind_mismatch_and_environment_values() { + let cycle = + ContractCatalog::from_json_str(fixture("cycle")).expect_err("predecessor cycles must fail"); + assert_eq!(cycle.code(), ContractDiagnosticCode::ChainCycle); + + let mut missing_predecessor: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + missing_predecessor["entries"]["deployment-plan"]["predecessor"]["entry_id"] = + Value::String("missing-predecessor".to_string()); + let missing_predecessor = ContractCatalog::from_json_str( + &serde_json::to_string(&missing_predecessor).expect("missing predecessor JSON"), + ) + .expect_err("missing predecessor must fail"); + assert_eq!( + missing_predecessor.code(), + ContractDiagnosticCode::ChainMissingPredecessor + ); + + let mut kind_mismatch: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + kind_mismatch["entries"]["deployment-plan"]["predecessor"]["identity"]["kind"] = + Value::String("deployment_plan".to_string()); + let kind_mismatch = ContractCatalog::from_json_str( + &serde_json::to_string(&kind_mismatch).expect("kind mismatch JSON"), + ) + .expect_err("predecessor kind mismatch must fail"); + assert_eq!( + kind_mismatch.code(), + ContractDiagnosticCode::ChainKindMismatch + ); + + let environment = ContractCatalog::from_json_str(fixture("environment")) + .expect_err("raw environment values must fail"); + assert_eq!(environment.code(), ContractDiagnosticCode::EnvironmentValue); +} + +#[test] +fn human_and_json_diagnostics_preserve_same_facts_and_redact_values() { + let migration = ContractDiagnostic::new( + ContractDiagnosticCode::MigrationInventory, + Some(ContractArtifactKind::MigrationInventory), + Some("repository/migrations"), + "distributed::migrations", + ["migrations/postgres/0005_new.sql"], + ["distributed.contracts.json"], + None::<&str>, + Some("sha256:before"), + Some("sha256:after"), + Some("add migration 5"), + Some(true), + "distributed contracts check --base ", + ) + .with_detail("migration inventory is missing a registered file"); + let schema = ContractDiagnostic::new( + ContractDiagnosticCode::SchemaDrift, + Some(ContractArtifactKind::SurfaceClientManifest), + Some("e2e-ui"), + "query-layer::surface", + ["tests/e2e-ui/ui/distributed.clients.json"], + ["tests/e2e-ui/ui/src/lib/generated/user/manifest.json"], + Some("models.Todo.owner.nullable"), + Some("postgres://user:password@example.invalid/db"), + Some("Bearer hidden-token"), + Some("surface schema drift"), + None, + "distributed contracts accept --scope client:e2e-ui", + ); + let mut result = ContractCheckResult::default(); + result.push(migration.clone()); + result.push(schema.clone()); + + let human = result.human(); + let json: Value = serde_json::from_str(&result.to_json().expect("diagnostic JSON")) + .expect("diagnostic JSON object"); + assert!(human.contains("CTL-MIG-INVENTORY")); + assert!(human.contains("CTL-SCHEMA-DRIFT")); + assert!(human.contains("[REDACTED]")); + assert!(!human.contains("postgres://")); + assert_eq!( + json["diagnostics"] + .as_array() + .expect("diagnostic list") + .len(), + 2 + ); + assert_eq!(json["diagnostics"][1]["code"], "CTL-SCHEMA-DRIFT"); + assert_eq!(json["diagnostics"][1]["expected"], "[REDACTED]"); + assert_eq!(json["diagnostics"][1]["observed"], "[REDACTED]"); + assert_eq!( + result, + serde_json::from_str(&result.to_json().expect("diagnostic JSON")) + .expect("diagnostic JSON round-trip") + ); + assert!(!result + .to_json() + .expect("diagnostic JSON") + .contains("postgres://")); + assert_eq!( + result.canonical_bytes().expect("canonical result"), + result.canonical_bytes().expect("second canonical result") + ); + + let decoded: ContractDiagnostic = serde_json::from_value(serde_json::json!({ + "code": "CTL-SCHEMA-DRIFT", + "scope": "secret=scope", + "owner": "postgres://user:password@example.invalid/db", + "source_paths": ["token=source"], + "derived_paths": ["secret=derived"], + "semantic_path": "password=semantic", + "expected": "secret=expected", + "observed": "Bearer observed-token", + "required_classification": "token=classification", + "repair_command": "secret=repair", + "detail": "password=detail" + })) + .expect("diagnostic JSON round-trip"); + assert!(!decoded.human().contains("postgres://")); + assert!(!decoded.human().contains("password=")); + assert!(!decoded.human().contains("token=")); + + let mut directly_mutated = migration; + directly_mutated.scope = Some("secret=scope".to_string()); + directly_mutated.owner = "postgres://user:password@example.invalid/db".to_string(); + directly_mutated + .source_paths + .insert("token=source".to_string()); + directly_mutated + .derived_paths + .insert("secret=derived".to_string()); + directly_mutated.semantic_path = Some("password=semantic".to_string()); + directly_mutated.required_classification = Some("token=classification".to_string()); + directly_mutated.repair_command = "secret=repair".to_string(); + directly_mutated.detail = "password=detail".to_string(); + + let direct_human = directly_mutated.human(); + let direct_json = serde_json::to_string(&directly_mutated).expect("direct diagnostic JSON"); + let direct_debug = format!("{directly_mutated:?}"); + assert!(!direct_human.contains("postgres://")); + assert!(!direct_human.contains("password=")); + assert!(!direct_human.contains("token=")); + assert!(!direct_json.contains("postgres://")); + assert!(!direct_json.contains("password=")); + assert!(!direct_json.contains("token=")); + assert!(!direct_debug.contains("postgres://")); + assert!(!direct_debug.contains("password=")); + assert!(!direct_debug.contains("token=")); +} + +#[test] +fn client_inventory_canonicalizes_client_and_document_order() { + let inventory = ClientInventory::from_path( + repository_root().join("tests/e2e-ui/ui/distributed.clients.json"), + ) + .expect("client inventory"); + let mut reversed = inventory.clone(); + reversed.clients.reverse(); + assert_eq!( + inventory.canonical_bytes().expect("canonical inventory"), + reversed + .canonical_bytes() + .expect("reversed canonical inventory") + ); +} + +#[test] +fn canonical_bytes_reject_direct_public_secret_mutation() { + let mut catalog = ContractCatalog::from_json_str(fixture("valid")).expect("valid catalog"); + catalog + .entries + .get_mut("application-manifest") + .expect("application entry") + .identity + .value = "postgres://user:password@example.invalid/db".to_string(); + let catalog_error = catalog + .canonical_bytes() + .expect_err("catalog serialization must validate public mutations"); + assert_eq!( + catalog_error.code(), + ContractDiagnosticCode::EnvironmentValue + ); + + let mut inventory = ClientInventory::from_json_str(include_str!( + "../../../tests/e2e-ui/ui/distributed.clients.json" + )) + .expect("valid client inventory"); + inventory.clients[0] + .documents + .insert("src/routes/token=secret.graphql".to_string()); + let inventory_error = inventory + .canonical_bytes() + .expect_err("client serialization must validate public mutations"); + assert_eq!( + inventory_error.code(), + ContractDiagnosticCode::EnvironmentValue + ); +} + +#[test] +fn check_and_aggregate_renderers_do_not_leak_mutated_artifact_identity() { + let mut catalog = ContractCatalog::from_json_str(fixture("valid")).expect("valid catalog"); + catalog + .entries + .get_mut("application-manifest") + .expect("application entry") + .identity + .value = "postgres://user:password@example.invalid/db".to_string(); + + let checked = catalog.check(repository_root()); + assert!(checked.artifacts.is_empty()); + let checked_json = checked.to_json().expect("checked JSON"); + let checked_canonical = + String::from_utf8(checked.canonical_bytes().expect("checked canonical JSON")) + .expect("checked JSON is UTF-8"); + let checked_debug = format!("{checked:?}"); + assert!(!checked_json.contains("postgres://")); + assert!(!checked_canonical.contains("postgres://")); + assert!(!checked_debug.contains("postgres://")); + + let mut directly_mutated = ContractCheckResult { + catalog_identity: Some("token=identity".to_string()), + ..Default::default() + }; + directly_mutated.artifacts.insert( + "secret=entry".to_string(), + ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "password=artifact", + ), + ); + let direct_json = directly_mutated.to_json().expect("aggregate JSON"); + let direct_canonical = String::from_utf8( + directly_mutated + .canonical_bytes() + .expect("aggregate canonical JSON"), + ) + .expect("aggregate JSON is UTF-8"); + let direct_debug = format!("{directly_mutated:?}"); + assert!(!direct_json.contains("token=identity")); + assert!(!direct_json.contains("secret=entry")); + assert!(!direct_json.contains("password=artifact")); + assert!(!direct_canonical.contains("token=identity")); + assert!(!direct_canonical.contains("secret=entry")); + assert!(!direct_canonical.contains("password=artifact")); + assert!(!direct_debug.contains("token=identity")); + assert!(!direct_debug.contains("secret=entry")); + assert!(!direct_debug.contains("password=artifact")); +} + +#[test] +fn client_inventory_rust_schema_matches_shared_parity_vectors() { + let vectors: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/contracts/client-inventory-parity.json" + )) + .expect("client inventory parity JSON"); + for vector in vectors["vectors"].as_array().expect("parity vectors") { + let name = vector["name"].as_str().expect("parity vector name"); + let expected = vector["valid"].as_bool().expect("parity vector result"); + let input = serde_json::to_string(&vector["inventory"]).expect("parity inventory JSON"); + assert_eq!( + ClientInventory::from_json_str(&input).is_ok(), + expected, + "Rust client inventory parity vector `{name}`" + ); + } +} + +#[test] +fn migration_inventory_rejects_missing_extra_and_dialect_drift() { + let (root, sqlite_sql, postgres_sql) = create_migration_fixture("inventory-vectors"); + let inventory = MigrationInventory::from_repository_root(root.path()) + .expect("valid migration fixture inventory"); + assert_eq!(inventory.migrations[0].version, 1); + + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let mut missing_dialect: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + missing_dialect["migrations"][0] + .as_object_mut() + .expect("migration object") + .remove("sqlite"); + let missing_dialect_error = MigrationInventory::from_json_str( + &serde_json::to_string(&missing_dialect).expect("missing dialect JSON"), + ) + .expect_err("missing dialect must fail"); + assert_eq!( + missing_dialect_error.code(), + ContractDiagnosticCode::MigrationInventory + ); + + fs::remove_file(root.path().join("migrations/sqlite/0001_initial.sql")) + .expect("remove declared migration"); + let missing_file = check_migration_inventory(root.path()); + assert!(!missing_file.is_ok()); + assert!(missing_file.human().contains("CTL-MIG-INVENTORY")); + assert!(missing_file + .human() + .contains("migrations/sqlite/0001_initial.sql")); + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + &sqlite_sql, + ); + + write_repository_file( + root.path(), + "migrations/sqlite/0002_extra.sql", + b"CREATE TABLE extra (id INTEGER);\n", + ); + let extra_file = check_migration_inventory(root.path()); + assert!(!extra_file.is_ok()); + assert!(extra_file + .human() + .contains("migrations/sqlite/0002_extra.sql")); + fs::remove_file(root.path().join("migrations/sqlite/0002_extra.sql")) + .expect("remove extra migration"); + + let mut dialect_drift: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + dialect_drift["migrations"][0]["postgres"]["path"] = + Value::String("migrations/mysql/0001_initial.sql".to_string()); + let dialect_drift_error = MigrationInventory::from_json_str( + &serde_json::to_string(&dialect_drift).expect("dialect drift JSON"), + ) + .expect_err("dialect drift must fail"); + assert_eq!( + dialect_drift_error.code(), + ContractDiagnosticCode::MigrationInventory + ); + assert!(dialect_drift_error + .message() + .contains("migrations/mysql/0001_initial.sql")); + assert_eq!( + migration_checksum(&postgres_sql), + inventory.migrations[0].postgres.sha256 + ); +} + +#[test] +fn migration_inventory_rejects_duplicate_and_non_consecutive_versions() { + let (root, sqlite_sql, postgres_sql) = create_migration_fixture("inventory-order"); + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let mut non_consecutive: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + non_consecutive["migrations"][0]["version"] = Value::from(2_u64); + let error = MigrationInventory::from_json_str( + &serde_json::to_string(&non_consecutive).expect("non-consecutive JSON"), + ) + .expect_err("non-consecutive version must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + + let mut duplicate = + serde_json::from_slice::(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + duplicate["migrations"] = json!([ + migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &sqlite_sql, + "migrations/postgres/0001_initial.sql", + &postgres_sql, + ), + migration_entry( + 1, + "migrations/sqlite/0002_duplicate.sql", + &sqlite_sql, + "migrations/postgres/0002_duplicate.sql", + &postgres_sql, + ) + ]); + let error = MigrationInventory::from_json_str( + &serde_json::to_string(&duplicate).expect("duplicate JSON"), + ) + .expect_err("duplicate version must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); +} + +#[test] +fn migration_inventory_rejects_checksum_path_and_symlink_mutations() { + let (root, sqlite_sql, postgres_sql) = create_migration_fixture("inventory-security"); + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let mut bad_checksum: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + bad_checksum["migrations"][0]["sqlite"]["sha256"] = Value::String("0".repeat(64)); + write_json_value(&inventory_path, &bad_checksum); + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("checksum mutation must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + assert!(error.message().contains("0001_initial.sql")); + + let valid = migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &sqlite_sql, + "migrations/postgres/0001_initial.sql", + &postgres_sql, + ); + write_migration_inventory(root.path(), std::slice::from_ref(&valid)); + let mut bad_path: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + bad_path["migrations"][0]["sqlite"]["path"] = + Value::String("migrations/sqlite/../outside.sql".to_string()); + let error = MigrationInventory::from_json_str( + &serde_json::to_string(&bad_path).expect("path mutation JSON"), + ) + .expect_err("path traversal must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + + bad_path["migrations"][0]["sqlite"]["path"] = Value::String( + root.path() + .join("outside.sql") + .to_string_lossy() + .into_owned(), + ); + let error = MigrationInventory::from_json_str( + &serde_json::to_string(&bad_path).expect("absolute path JSON"), + ) + .expect_err("absolute path must fail without exposing the machine path"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + assert!(!error + .message() + .contains(&root.path().to_string_lossy().to_string())); + + #[cfg(unix)] + { + write_migration_inventory(root.path(), std::slice::from_ref(&valid)); + fs::remove_file(root.path().join("migrations/sqlite/0001_initial.sql")) + .expect("remove migration for symlink fixture"); + let outside = root.path().join("outside.sql"); + fs::write(&outside, &sqlite_sql).expect("outside SQL"); + symlink( + &outside, + root.path().join("migrations/sqlite/0001_initial.sql"), + ) + .expect("migration symlink"); + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("symlink migration must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogSymlinkEscape); + } + + let declared_path = root.path().join("migrations/sqlite/0001_initial.sql"); + if declared_path.is_symlink() { + fs::remove_file(&declared_path).expect("remove migration symlink"); + } else if declared_path.exists() { + fs::remove_file(&declared_path).expect("remove migration file for special-file fixture"); + } + fs::create_dir(&declared_path).expect("migration special-file fixture"); + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("directory at declared SQL path must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogSpecialFile); + assert!(!error + .message() + .contains(&root.path().to_string_lossy().to_string())); +} + +#[test] +fn migration_inventory_redacts_sensitive_and_non_normal_paths() { + let (root, sqlite_sql, postgres_sql) = create_migration_fixture("inventory-path-redaction"); + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let unique_secret = "migration-path-secret-7f5e1c9b"; + let mut sensitive: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + sensitive["migrations"][0]["sqlite"]["path"] = + Value::String(format!("migrations/sqlite/password={unique_secret}.sql")); + let sensitive_json = serde_json::to_string(&sensitive).expect("sensitive path JSON"); + let error = MigrationInventory::from_json_str(&sensitive_json) + .expect_err("credential-like path must fail safely"); + for rendered in [ + error.message().to_string(), + error.to_string(), + format!("{error:?}"), + ] { + assert!( + !rendered.contains(unique_secret), + "sensitive path leaked from error rendering: {rendered}" + ); + } + write_json_value(&inventory_path, &sensitive); + let checked = check_migration_inventory(root.path()); + for rendered in [ + checked.human(), + checked.to_json().expect("sensitive diagnostic JSON"), + format!("{checked:?}"), + ] { + assert!( + !rendered.contains(unique_secret), + "sensitive path leaked from diagnostic rendering: {rendered}" + ); + } + + let traversal_sentinel = "migration-traversal-sentinel-3c2a8e1d"; + let mut traversal = sensitive; + traversal["migrations"][0]["sqlite"]["path"] = + Value::String(format!("migrations/sqlite/../{traversal_sentinel}.sql")); + let traversal_error = MigrationInventory::from_json_str( + &serde_json::to_string(&traversal).expect("traversal path JSON"), + ) + .expect_err("non-normal path must fail safely"); + assert!(!traversal_error.message().contains(traversal_sentinel)); + assert!(!format!("{traversal_error:?}").contains(traversal_sentinel)); + + let safe_relative = "migrations/sqlite/safe-relative-diagnostic.sql"; + traversal["migrations"][0]["sqlite"]["path"] = Value::String(safe_relative.to_string()); + MigrationInventory::from_json_str( + &serde_json::to_string(&traversal).expect("safe relative path JSON"), + ) + .expect("safe relative path should remain structurally valid"); + write_json_value(&inventory_path, &traversal); + let safe_error = MigrationInventory::from_repository_root(root.path()) + .expect_err("missing safe relative path must be reported"); + assert!(safe_error.message().contains(safe_relative)); + let safe_checked = check_migration_inventory(root.path()); + assert!(safe_checked.human().contains(safe_relative)); + assert!(safe_checked + .to_json() + .expect("safe relative diagnostic JSON") + .contains(safe_relative)); + + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &sqlite_sql, + "migrations/postgres/0001_initial.sql", + &postgres_sql, + )], + ); + write_repository_file( + root.path(), + &format!("migrations/sqlite/password={unique_secret}.sql"), + b"CREATE TABLE secret_path (id INTEGER);\n", + ); + let extra_checked = check_migration_inventory(root.path()); + for rendered in [ + extra_checked.human(), + extra_checked.to_json().expect("extra diagnostic JSON"), + format!("{extra_checked:?}"), + ] { + assert!( + !rendered.contains(unique_secret), + "sensitive extra path leaked from diagnostic rendering: {rendered}" + ); + } +} + +#[test] +fn migration_inventory_rejects_json_nesting_before_parse_and_ignores_strings() { + let nested_json = |count: usize| format!("{}0{}", "[".repeat(count), "]".repeat(count)); + let at_limit = format!( + r#"{{"schema_version":1,"migrations":[],"padding":{}}}"#, + nested_json(MAX_MIGRATION_JSON_DEPTH - 1) + ); + let at_limit_error = MigrationInventory::from_json_str(&at_limit) + .expect_err("the configured JSON nesting limit should reach serde"); + assert!(!at_limit_error + .message() + .contains("maximum JSON nesting depth")); + + let over_limit = format!( + r#"{{"schema_version":1,"migrations":[],"padding":{}}}"#, + nested_json(MAX_MIGRATION_JSON_DEPTH) + ); + let over_limit_error = MigrationInventory::from_json_str(&over_limit) + .expect_err("JSON beyond the configured nesting limit must fail"); + assert!(over_limit_error + .message() + .contains("maximum JSON nesting depth")); + + let string_delimiters = r#"{"schema_version":1,"migrations":[],"padding":"braces {[ ]} and an escaped quote: \" plus [nested]"}"#; + let string_error = MigrationInventory::from_json_str(string_delimiters) + .expect_err("unknown string field should fail structural parsing"); + assert!(!string_error + .message() + .contains("maximum JSON nesting depth")); +} + +#[test] +fn migration_inventory_rejects_total_and_top_level_entry_floods() { + let (root, _, _) = create_migration_fixture("entry-flood"); + for index in 0..=MAX_MIGRATION_TOTAL_ENTRIES { + write_repository_file( + root.path(), + &format!("migrations/sqlite/noise-{index:04}.txt"), + b"not SQL", + ); + } + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("non-SQL entries must count toward the dialect traversal bound"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + assert!(error.message().contains("entries")); + assert!(!error + .message() + .contains(&root.path().to_string_lossy().to_string())); + + let (root, _, _) = create_migration_fixture("top-level-entry-flood"); + for index in 0..=MAX_MIGRATION_TOP_LEVEL_ENTRIES { + write_repository_file( + root.path(), + &format!("migrations/top-level-noise-{index:03}.txt"), + b"ignored top-level file", + ); + } + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("top-level migrations entries must be bounded"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + assert!(error.message().contains("migrations directory")); + assert!(!error + .message() + .contains(&root.path().to_string_lossy().to_string())); +} + +#[test] +fn migration_inventory_directory_diagnostics_are_deterministic() { + let (root, _, _) = create_migration_fixture("directory-order"); + fs::create_dir(root.path().join("migrations/zzz-invalid")) + .expect("late invalid dialect directory"); + fs::create_dir(root.path().join("migrations/aaa-invalid")) + .expect("early invalid dialect directory"); + + let first = check_migration_inventory(root.path()); + let second = check_migration_inventory(root.path()); + assert_eq!(first.human(), second.human()); + assert_eq!( + first.to_json().expect("first diagnostic JSON"), + second.to_json().expect("second diagnostic JSON") + ); + assert!(first.human().contains("migrations/aaa-invalid")); + assert!(!first.human().contains("migrations/zzz-invalid")); +} + +fn write_json_value(path: &Path, value: &Value) { + fs::write( + path, + serde_json::to_vec_pretty(value).expect("JSON value serialization"), + ) + .expect("JSON value file"); +} + +#[test] +fn migration_inventory_is_deterministic_and_preserves_runtime_order() { + let inventory = MigrationInventory::from_repository_root(repository_root()) + .expect("repository migration inventory"); + let versions = inventory + .migrations + .iter() + .map(|migration| migration.version) + .collect::>(); + assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!( + inventory.canonical_bytes().expect("canonical inventory"), + inventory + .canonical_bytes() + .expect("second canonical inventory") + ); +} + +#[test] +fn released_v3_3_4_migration_fixture_matches_provenance() { + let baseline: Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/migrations/v3.3.4/baseline.json" + )) + .expect("released migration baseline JSON"); + assert_eq!(baseline["release_tag"], "v3.3.4"); + assert_eq!( + baseline["revision"], + "f57543ddeb9e293cf366dba1a2330b34ce6509f0" + ); + assert_eq!(baseline["migration_count"], 1); + assert_eq!( + baseline["migrations"][0]["sqlite"]["sha256"], + migration_checksum(include_bytes!( + "../../../tests/fixtures/migrations/v3.3.4/sqlite/0001_initial.sql" + )) + ); + assert_eq!( + baseline["migrations"][0]["postgres"]["sha256"], + migration_checksum(include_bytes!( + "../../../tests/fixtures/migrations/v3.3.4/postgres/0001_initial.sql" + )) + ); +} + +#[test] +fn baseline_history_rejects_coordinated_sql_and_checksum_edits() { + let (root, base_sqlite, base_postgres) = create_migration_fixture("history-edit"); + let base_revision = commit_migration_fixture(root.path()); + + let mutated_sqlite = b"CREATE TABLE one (id INTEGER PRIMARY KEY, changed INTEGER);\n"; + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + mutated_sqlite, + ); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + mutated_sqlite, + "migrations/postgres/0001_initial.sql", + &base_postgres, + )], + ); + let current = MigrationInventory::from_repository_root(root.path()) + .expect("coordinated local checksum edit remains current-tree valid"); + let refused = current.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("CTL-MIG-HISTORY")); + assert!(refused + .human() + .contains("migrations/sqlite/0001_initial.sql")); + assert!(refused.human().contains("add migration 2")); + + let migration_two_sqlite = b"ALTER TABLE one ADD COLUMN added INTEGER;\n"; + let migration_two_postgres = b"ALTER TABLE one ADD COLUMN added BIGINT;\n"; + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + &base_sqlite, + ); + write_repository_file( + root.path(), + "migrations/sqlite/0002_next.sql", + migration_two_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0002_next.sql", + migration_two_postgres, + ); + write_migration_inventory( + root.path(), + &[ + migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &base_sqlite, + "migrations/postgres/0001_initial.sql", + &base_postgres, + ), + migration_entry( + 2, + "migrations/sqlite/0002_next.sql", + migration_two_sqlite, + "migrations/postgres/0002_next.sql", + migration_two_postgres, + ), + ], + ); + let repaired = MigrationInventory::from_repository_root(root.path()) + .expect("restored baseline plus new migration inventory"); + let accepted = repaired.check_history(root.path(), &base_revision); + assert!(accepted.is_verified(), "{}", accepted.human()); +} + +#[test] +fn baseline_history_rejects_description_delete_renumber_and_reorder() { + let (root, first_sqlite, first_postgres) = create_migration_fixture("history-vectors"); + let second_sqlite = b"ALTER TABLE one ADD COLUMN added INTEGER;\n"; + let second_postgres = b"ALTER TABLE one ADD COLUMN added BIGINT;\n"; + write_repository_file( + root.path(), + "migrations/sqlite/0002_next.sql", + second_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0002_next.sql", + second_postgres, + ); + let baseline_entries = vec![ + migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &first_sqlite, + "migrations/postgres/0001_initial.sql", + &first_postgres, + ), + migration_entry( + 2, + "migrations/sqlite/0002_next.sql", + second_sqlite, + "migrations/postgres/0002_next.sql", + second_postgres, + ), + ]; + write_migration_inventory(root.path(), &baseline_entries); + let base_revision = commit_migration_fixture(root.path()); + let baseline = MigrationInventory::from_repository_root(root.path()) + .expect("two-migration baseline inventory"); + + let mut description = baseline.clone(); + description.migrations[0].description = "edited".to_string(); + let refused = description.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("description changed")); + + fs::remove_file(root.path().join("migrations/sqlite/0002_next.sql")) + .expect("remove deleted sqlite migration"); + fs::remove_file(root.path().join("migrations/postgres/0002_next.sql")) + .expect("remove deleted postgres migration"); + let mut deleted = baseline.clone(); + deleted.migrations.pop(); + let refused = deleted.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("baseline migration 2 was deleted")); + assert!(refused.human().contains("add migration 3")); + + write_repository_file( + root.path(), + "migrations/sqlite/0002_next.sql", + second_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0002_next.sql", + second_postgres, + ); + let mut renumbered = baseline.clone(); + renumbered.migrations[1].version = 3; + let refused = renumbered.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("CTL-MIG-HISTORY")); + assert!(refused.human().contains("order or numbering changed")); + + let mut reordered = baseline; + reordered.migrations.swap(0, 1); + let refused = reordered.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("CTL-MIG-HISTORY")); + assert!(refused.human().contains("order or numbering changed")); +} + +#[test] +fn baseline_newer_deletion_repair_never_reuses_immutable_version() { + let (root, first_sqlite, first_postgres) = create_migration_fixture("history-newer"); + let second_sqlite = b"ALTER TABLE one ADD COLUMN second INTEGER;\n"; + let second_postgres = b"ALTER TABLE one ADD COLUMN second BIGINT;\n"; + let third_sqlite = b"ALTER TABLE one ADD COLUMN third INTEGER;\n"; + let third_postgres = b"ALTER TABLE one ADD COLUMN third BIGINT;\n"; + write_repository_file( + root.path(), + "migrations/sqlite/0002_second.sql", + second_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0002_second.sql", + second_postgres, + ); + write_repository_file( + root.path(), + "migrations/sqlite/0003_third.sql", + third_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0003_third.sql", + third_postgres, + ); + let baseline_entries = vec![ + migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &first_sqlite, + "migrations/postgres/0001_initial.sql", + &first_postgres, + ), + migration_entry( + 2, + "migrations/sqlite/0002_second.sql", + second_sqlite, + "migrations/postgres/0002_second.sql", + second_postgres, + ), + migration_entry( + 3, + "migrations/sqlite/0003_third.sql", + third_sqlite, + "migrations/postgres/0003_third.sql", + third_postgres, + ), + ]; + write_migration_inventory(root.path(), &baseline_entries); + let base_revision = commit_migration_fixture(root.path()); + let baseline = + MigrationInventory::from_repository_root(root.path()).expect("newer baseline inventory"); + + fs::remove_file(root.path().join("migrations/sqlite/0003_third.sql")) + .expect("remove deleted third sqlite migration"); + fs::remove_file(root.path().join("migrations/postgres/0003_third.sql")) + .expect("remove deleted third postgres migration"); + let mut deleted = baseline.clone(); + deleted.migrations.pop(); + let refused = deleted.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("baseline migration 3 was deleted")); + assert!(refused.human().contains("add migration 4")); + assert!(!refused.human().contains("add migration 3")); +} + +#[test] +fn oversized_git_baseline_object_is_unavailable_without_leaking_contents() { + let (root, base_sqlite, base_postgres) = create_migration_fixture("history-oversized"); + let oversized_sql = vec![b'x'; MAX_MIGRATION_SQL_BYTES + 1]; + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + &oversized_sql, + ); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &oversized_sql, + "migrations/postgres/0001_initial.sql", + &base_postgres, + )], + ); + let base_revision = commit_migration_fixture(root.path()); + + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + &base_sqlite, + ); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &base_sqlite, + "migrations/postgres/0001_initial.sql", + &base_postgres, + )], + ); + let current = MigrationInventory::from_repository_root(root.path()) + .expect("current tree remains valid after oversized baseline"); + let refused = current.check_history(root.path(), &base_revision); + assert!(refused.is_unavailable()); + assert!(!refused.is_verified()); + assert!(refused + .baseline + .reason() + .expect("oversized baseline reason") + .contains("exceeds")); + let human = refused.human(); + assert!(human.contains("history evidence unavailable")); + assert!(!human.contains("xxxxxxxx")); + + let (root, current_sqlite, current_postgres) = + create_migration_fixture("history-inventory-limit"); + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let mut oversized_inventory: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory baseline")) + .expect("inventory baseline JSON"); + oversized_inventory["padding"] = Value::String("y".repeat(MAX_MIGRATION_INVENTORY_BYTES)); + write_json_value(&inventory_path, &oversized_inventory); + let inventory_base_revision = commit_migration_fixture(root.path()); + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + ¤t_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0001_initial.sql", + ¤t_postgres, + ); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + ¤t_sqlite, + "migrations/postgres/0001_initial.sql", + ¤t_postgres, + )], + ); + let current = MigrationInventory::from_repository_root(root.path()) + .expect("current tree remains valid after oversized inventory baseline"); + let refused = current.check_history(root.path(), &inventory_base_revision); + assert!(refused.is_unavailable()); + assert!(refused + .baseline + .reason() + .expect("oversized inventory baseline reason") + .contains("exceeds")); +} + +#[test] +fn unavailable_merge_base_is_reported_not_verified() { + let root = repository_root(); + let inventory = + MigrationInventory::from_repository_root(&root).expect("repository migration inventory"); + let result = inventory.check_history(&root, "missing-merge-base-for-test"); + assert!(result.is_unavailable()); + assert!(!result.is_verified()); + assert!(matches!( + result.baseline, + BaselineAvailability::Unavailable { .. } + )); + assert!(result.human().contains("history evidence unavailable")); + assert!(result.human().contains("merge_base_available=false")); +} diff --git a/distributed_cli/src/contracts/transaction.rs b/distributed_cli/src/contracts/transaction.rs new file mode 100644 index 000000000..cdd6b6643 --- /dev/null +++ b/distributed_cli/src/contracts/transaction.rs @@ -0,0 +1,318 @@ +//! Read-only contract check and exact-scope accept transactions. +//! +//! `check` never writes tracked files. `accept` stages outputs, replaces +//! atomically on success, and restores the prior set on failure. + +use super::catalog::ContractCatalog; +use super::chain::{check_predecessor_chain, ObservedPredecessor}; +use super::diagnostic::{ + ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, +}; +use super::ContractArtifactKind; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Exact accept scopes supported by the aggregate CLI. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractAcceptScope { + Catalog, + MigrationInventory, + SurfaceClientManifest, + GeneratedClientTree, + ApplicationManifest, + DeploymentPlan, + /// Exact client-program scope: `program:`. + Program { id: String }, +} + +impl ContractAcceptScope { + pub fn parse(value: &str) -> Option { + match value { + "catalog" => Some(Self::Catalog), + "migration_inventory" => Some(Self::MigrationInventory), + "surface_client_manifest" => Some(Self::SurfaceClientManifest), + "generated_client_tree" => Some(Self::GeneratedClientTree), + "application_manifest" => Some(Self::ApplicationManifest), + "deployment_plan" => Some(Self::DeploymentPlan), + other if other.starts_with("program:") => { + let id = other.trim_start_matches("program:").to_string(); + if id.is_empty() { + None + } else { + Some(Self::Program { id }) + } + } + _ => None, + } + } + + pub fn as_str(&self) -> String { + match self { + Self::Catalog => "catalog".into(), + Self::MigrationInventory => "migration_inventory".into(), + Self::SurfaceClientManifest => "surface_client_manifest".into(), + Self::GeneratedClientTree => "generated_client_tree".into(), + Self::ApplicationManifest => "application_manifest".into(), + Self::DeploymentPlan => "deployment_plan".into(), + Self::Program { id } => format!("program:{id}"), + } + } +} + +/// Result of a read-only aggregate check. +#[derive(Clone, Debug, Serialize)] +pub struct ContractsCheckReport { + pub ok: bool, + pub result: ContractCheckResult, + pub human: String, +} + +/// Result of an exact-scope accept transaction. +#[derive(Clone, Debug, Serialize)] +pub struct ContractsAcceptReport { + pub ok: bool, + pub scope: String, + pub changed_paths: Vec, + pub noop: bool, + pub rolled_back: bool, + pub diagnostics: Vec, +} + +/// Aggregate read-only contracts check over a catalog root. +pub fn contracts_check( + catalog: &ContractCatalog, + root: &Path, + predecessors: impl IntoIterator, +) -> ContractsCheckReport { + let mut result = catalog.check(root); + let chain = check_predecessor_chain(predecessors); + result.diagnostics.extend(chain.diagnostics); + let ok = result.diagnostics.is_empty(); + let human = result + .diagnostics + .iter() + .map(|diagnostic| diagnostic.human()) + .collect::>() + .join("\n"); + ContractsCheckReport { ok, result, human } +} + +/// Accept one exact scope by replacing declared relative paths under `root`. +/// +/// `staged` maps portable relative paths to their new bytes. Paths must stay +/// inside `root` after physical resolution. On any failure the prior contents +/// of every touched path are restored. +pub fn contracts_accept( + root: &Path, + scope: ContractAcceptScope, + staged: &BTreeMap>, +) -> Result { + if staged.is_empty() { + return Ok(ContractsAcceptReport { + ok: true, + scope: scope.as_str(), + changed_paths: Vec::new(), + noop: true, + rolled_back: false, + diagnostics: Vec::new(), + }); + } + + let mut resolved = BTreeMap::new(); + for (relative, bytes) in staged { + let path = resolve_under_root(root, relative)?; + resolved.insert(relative.clone(), (path, bytes.clone())); + } + + // Detect no-op: every path already has identical bytes. + let mut noop = true; + for (path, bytes) in resolved.values() { + match fs::read(path) { + Ok(existing) if existing == *bytes => {} + _ => { + noop = false; + break; + } + } + } + if noop { + return Ok(ContractsAcceptReport { + ok: true, + scope: scope.as_str(), + changed_paths: Vec::new(), + noop: true, + rolled_back: false, + diagnostics: Vec::new(), + }); + } + + // Snapshot prior contents for rollback. + let mut prior = BTreeMap::new(); + for (relative, (path, _)) in &resolved { + prior.insert( + relative.clone(), + if path.exists() { + Some(fs::read(path).map_err(|error| error.to_string())?) + } else { + None + }, + ); + } + + let mut changed = Vec::new(); + for (relative, (path, bytes)) in &resolved { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let staging = path.with_extension("distributed-accept-staging"); + if let Err(error) = fs::write(&staging, bytes) { + let _ = rollback(root, &prior); + return Err(error.to_string()); + } + if let Err(error) = fs::rename(&staging, path) { + let _ = fs::remove_file(&staging); + let _ = rollback(root, &prior); + return Err(error.to_string()); + } + changed.push(relative.clone()); + } + + Ok(ContractsAcceptReport { + ok: true, + scope: scope.as_str(), + changed_paths: changed, + noop: false, + rolled_back: false, + diagnostics: Vec::new(), + }) +} + +fn rollback( + root: &Path, + prior: &BTreeMap>>, +) -> Result<(), String> { + for (relative, contents) in prior { + let path = resolve_under_root(root, relative)?; + match contents { + Some(bytes) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&path, bytes).map_err(|e| e.to_string())?; + } + None if path.exists() => { + fs::remove_file(&path).map_err(|e| e.to_string())?; + } + None => {} + } + } + Ok(()) +} + +fn resolve_under_root(root: &Path, relative: &str) -> Result { + if relative.is_empty() + || relative.starts_with('/') + || relative.contains('\0') + || Path::new(relative) + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(format!( + "accept path `{relative}` escapes or is not a portable relative path" + )); + } + 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) { + return Err(format!( + "accept path `{relative}` resolves outside the catalog root" + )); + } + return Ok(canonical); + } + // Path may not exist yet — validate parents. + if let Some(parent) = candidate.parent() { + if parent.exists() { + let parent = parent.canonicalize().map_err(|e| e.to_string())?; + if !parent.starts_with(&root) { + return Err(format!( + "accept path `{relative}` parent resolves outside the catalog root" + )); + } + } + } + Ok(candidate) +} + +/// Build a diagnostic when an accept scope is unknown or broad. +pub fn unknown_scope_diagnostic(scope: &str) -> ContractDiagnostic { + ContractDiagnostic::new( + ContractDiagnosticCode::CatalogInvalid, + Some(ContractArtifactKind::ApplicationManifest), + None::<&str>, + "contracts.accept", + std::iter::empty::<&str>(), + std::iter::empty::<&str>(), + Some("scope"), + Some("exact catalog-owned scope"), + Some(scope), + Some("use_exact_scope"), + None, + "distributed contracts accept --scope ", + ) + .with_detail(format!("unknown or broad accept scope `{scope}`")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_root() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("distributed-contracts-accept-{nanos}")); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn accept_is_idempotent_and_rolls_back_on_escape() { + let root = temp_root(); + let mut staged = BTreeMap::new(); + staged.insert("contracts/app.json".into(), b"{\"ok\":true}".to_vec()); + let first = contracts_accept(&root, ContractAcceptScope::ApplicationManifest, &staged) + .expect("first accept"); + assert!(first.ok); + assert!(!first.noop); + assert_eq!(first.changed_paths, vec!["contracts/app.json".to_string()]); + + let second = contracts_accept(&root, ContractAcceptScope::ApplicationManifest, &staged) + .expect("second accept"); + assert!(second.noop); + + let mut bad = BTreeMap::new(); + bad.insert("../escape.json".into(), b"nope".to_vec()); + assert!(contracts_accept(&root, ContractAcceptScope::Catalog, &bad).is_err()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn check_report_is_no_write() { + // Empty catalog check shape only — catalog construction is covered elsewhere. + let report = ContractsCheckReport { + ok: true, + result: ContractCheckResult::default(), + human: String::new(), + }; + assert!(report.ok); + } +} diff --git a/distributed_cli/src/generate/mod.rs b/distributed_cli/src/generate/mod.rs index 189cb4044..9dec9a5ce 100644 --- a/distributed_cli/src/generate/mod.rs +++ b/distributed_cli/src/generate/mod.rs @@ -601,7 +601,7 @@ mod tests { assert!(main.contains("tracer_provider.shutdown()")); let service = contents(&project, "src/service.rs"); - assert!(service.contains(".tracing(TracingManifest::otlp())")); + assert!(service.contains(".tracing(TracingDescriptor::otlp())")); let values = contents(&project, ".gitops/deploy/values.yaml"); assert!(values.contains("enabled: true")); diff --git a/distributed_cli/src/generate/service_crate.rs b/distributed_cli/src/generate/service_crate.rs index 4de045aad..e28a99b4d 100644 --- a/distributed_cli/src/generate/service_crate.rs +++ b/distributed_cli/src/generate/service_crate.rs @@ -96,7 +96,7 @@ tokio = {{ version = "1", features = ["macros", "net", "rt-multi-thread"] }} pub mod manifest; {models}{read_models}{query}pub mod service; -pub use manifest::distributed_manifest; +pub use manifest::{{application_manifest, read_model_catalog, service_descriptor}}; "# ) } @@ -205,17 +205,33 @@ async fn main() -> Result<(), {error_type}> {{ .map(|model| format!(" .read_model::<{}>()\n", model.view_ident)) .collect::(); format!( - r#"use distributed::{{ - DistributedProjectManifest, ServiceManifest, +r#"use distributed::{{ + Application, ApplicationManifest, ReadModelCatalog, SurfaceSpec, }}; -{read_model_import}pub fn distributed_manifest() -> DistributedProjectManifest {{ - DistributedProjectManifest::new({project_name}) -{read_model_registration} .service(crate::service::manifest()) +{read_model_import}pub fn application_manifest() -> ApplicationManifest {{ + let catalog = read_model_catalog(); + let surface = distributed::graphql::build_surface( + &catalog.tables, + &distributed::graphql::SurfaceOptions::sqlite(), + ) + .expect("read-model catalog should compile into an application Surface"); + let surface = SurfaceSpec::from_surface({project_name}, &surface) + .expect("application Surface contract should be portable"); + Application::new({project_name}) + .surface(surface) + .build() + .expect("application manifest should compile") + .manifest() + .clone() }} -pub fn service_manifest() -> ServiceManifest {{ - crate::service::manifest() +pub fn read_model_catalog() -> ReadModelCatalog {{ + ReadModelCatalog::new({project_name}) +{read_model_registration}}} + +pub fn service_descriptor() -> distributed::microsvc::ServiceDescriptor {{ + crate::service::descriptor() }} "#, project_name = rust_string(&self.names.package_name), @@ -234,13 +250,13 @@ pub fn service_manifest() -> ServiceManifest {{ let mut manifest_imports = vec![ "microsvc::{Routes, Service}", repo_import, - "ServiceManifest", + "ServiceDescriptor", ]; if self.metrics == Some(MetricsTarget::Prometheus) { - manifest_imports.push("MetricsEndpointManifest"); + manifest_imports.push("MetricsEndpointDescriptor"); } if self.tracing { - manifest_imports.push("TracingManifest"); + manifest_imports.push("TracingDescriptor"); } if self.query_api && !self.commands.is_empty() { manifest_imports.push("AggregateRepository"); @@ -316,12 +332,12 @@ pub fn service_manifest() -> ServiceManifest {{ ServiceTransport::Knative => "knative", }; let manifest_metrics = if self.metrics == Some(MetricsTarget::Prometheus) { - " .metrics(MetricsEndpointManifest::prometheus_default())\n" + " .metrics(MetricsEndpointDescriptor::prometheus_default())\n" } else { "" }; let manifest_tracing = if self.tracing { - " .tracing(TracingManifest::otlp())\n" + " .tracing(TracingDescriptor::otlp())\n" } else { "" }; @@ -411,8 +427,8 @@ pub async fn build_with_graphql() -> Result, Box ServiceManifest {{ - ServiceManifest::new({service_name}) +pub fn descriptor() -> ServiceDescriptor {{ + ServiceDescriptor::new({service_name}) {manifest_commands}{manifest_events}{manifest_metrics}{manifest_tracing} .transport({transport}) }} "#, @@ -441,8 +457,8 @@ pub fn build(repo: ServiceRepo) -> Arc {{ Arc::new(Service::new().named({service_name}).routes(routes)) }} -pub fn manifest() -> ServiceManifest {{ - ServiceManifest::new({service_name}) +pub fn descriptor() -> ServiceDescriptor {{ + ServiceDescriptor::new({service_name}) {manifest_commands}{manifest_events}{manifest_metrics}{manifest_tracing} .transport({transport}) }} "#, @@ -779,7 +795,7 @@ pub fn build_engine( // issuer (still OidcBearer; ambient headers never trusted). For local // GraphiQL ambient headers only, pass IdentityMode::DevHeaders explicitly. let identity = distributed::graphql::public_oidc_identity_from_env(); - GraphqlEngine::from_manifest(&crate::distributed_manifest(), source)? + GraphqlEngine::from_schema_catalog(&crate::read_model_catalog(), source)? .service(service){protocol_key_builder} .roles(roles::ALL) .grant_all(roles::USER) diff --git a/distributed_cli/src/lib.rs b/distributed_cli/src/lib.rs index b55cd15a9..8c25387d4 100644 --- a/distributed_cli/src/lib.rs +++ b/distributed_cli/src/lib.rs @@ -1,40 +1,49 @@ -//! The `dctl` CLI for Distributed services — both a binary and a library. +//! The `distributed` CLI for Distributed applications — both a binary and a library. //! -//! It bundles two things in one crate so there is no cross-repo coordination: -//! -//! - **Pure generation** (the [`generate`]/[`atlas`] modules): the rules for a -//! Distributed service project — Cargo layout, Rust source templates, manifest -//! wiring, GitOps/Knative inference, GitHub workflows, and Atlas schema -//! resources. These perform no I/O — [`generate_service_scaffold`] takes a -//! [`ServiceScaffoldSpec`] and returns a [`GeneratedProject`]; -//! [`render_atlas_schema`] wraps desired-state SQL into an `AtlasSchema`. -//! - **The command surface** (the [`cli`] module): the clap types and [`run`] -//! dispatcher that own the filesystem / process side effects (writing files, -//! running `gh`, compiling the manifest harness). -//! -//! The `dctl` binary parses [`ServiceArgs`] and calls [`run`]. Another CLI (e.g. -//! `hops`) can depend on this crate, mount [`ServiceArgs`] under its own -//! subcommand, and dispatch with [`run`] — re-exporting the commands rather than -//! reimplementing them. +//! It bundles pure generation (`generate` / `atlas`), the contract lifecycle +//! surface (`contracts`), and the clap command surface (`cli`). The standalone +//! binary parses [`DistributedArgs`] and dispatches with [`run_distributed`]. +//! Host CLIs such as `hops` may mount [`ServiceArgs`] and dispatch with [`run`]. mod atlas; mod cli; mod client_compiler; +pub mod contracts; mod generate; mod manifest_harness; mod skills; pub use atlas::{render_atlas_schema, AtlasDatabaseUrl, AtlasSchemaSpec}; pub use cli::{ - run, AgentHarness, Bus, ClientArgs, ClientManifestArgs, DescribeArgs, Framework, GitopsPromote, - ManifestFormat, Metrics, ScaffoldArgs, SchemaArgs, SchemaDialect, SchemaFormat, ServiceArgs, - ServiceCommands, SkillsArgs, SkillsCommands, SkillsInitArgs, Store, Transport, + run, run_distributed, AgentHarness, Bus, ClientArgs, ClientManifestArgs, ContractsAcceptArgs, + ContractsArgs, ContractsCheckArgs, ContractsCommands, ContractsOutput, DescribeArgs, + DistributedArgs, DistributedCommands, Framework, GitopsPromote, ManifestFormat, Metrics, + ScaffoldArgs, SchemaArgs, SchemaDialect, SchemaFormat, ServiceArgs, ServiceCommands, + SkillsArgs, SkillsCommands, SkillsInitArgs, Store, Transport, }; pub use client_compiler::{ compile_client, ClientCompileError, ClientCompileInput, ClientDocument, ClientRouteDiscovery, ClientRouteRegistration, ClientSourceLocation, ClientSurfaceSelector, GeneratedClientFile, GeneratedClientProject, GeneratedOperationSummary, GeneratedRoutePlan, }; +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, +}; pub use generate::{generate_service_scaffold, package_name}; pub use skills::{embedded_skills, generate_skills, EmbeddedFile, EmbeddedSkill, SkillsInitSpec}; diff --git a/distributed_cli/src/main.rs b/distributed_cli/src/main.rs index d3ed4e23a..a8099b5de 100644 --- a/distributed_cli/src/main.rs +++ b/distributed_cli/src/main.rs @@ -1,18 +1,18 @@ use clap::Parser; -use distributed_cli::ServiceArgs; +use distributed_cli::DistributedArgs; -/// The `dctl` CLI: scaffold Distributed services, compile typed client -/// artifacts, describe manifests, and render schema artifacts. +/// The `distributed` CLI: contracts lifecycle, scaffold, client compile, +/// describe manifests, and render schema artifacts. #[derive(Parser, Debug)] -#[command(name = "dctl", version, about, long_about = None)] +#[command(name = "distributed", version, about, long_about = None)] struct Cli { #[command(flatten)] - args: ServiceArgs, + args: DistributedArgs, } fn main() { let cli = Cli::parse(); - if let Err(err) = distributed_cli::run(&cli.args) { + if let Err(err) = distributed_cli::run_distributed(&cli.args) { eprintln!("error: {err}"); std::process::exit(1); } diff --git a/distributed_cli/src/manifest_harness.rs b/distributed_cli/src/manifest_harness.rs index 235e56836..b577bb926 100644 --- a/distributed_cli/src/manifest_harness.rs +++ b/distributed_cli/src/manifest_harness.rs @@ -1,4 +1,4 @@ -//! The manifest harness: `describe`/`schema`/`client-manifest` compile a tiny +//! The artifact harness: `describe`/`schema`/`client-manifest` compile a tiny //! generated crate that depends on the target service and calls its portable //! export entrypoint. This module owns that codegen and the nested `cargo` //! invocations; the `cli` module maps flags onto @@ -46,9 +46,8 @@ impl HarnessMode { fn default_entrypoint(self) -> &'static str { match self { HarnessMode::ClientManifest => "distributed_client_surface", - HarnessMode::DescribeJson | HarnessMode::SchemaSql(_) | HarnessMode::SchemaGraphql => { - "distributed_manifest" - } + HarnessMode::DescribeJson => "application_manifest", + HarnessMode::SchemaSql(_) | HarnessMode::SchemaGraphql => "read_model_catalog", } } } @@ -76,14 +75,14 @@ pub(crate) fn run_manifest_harness( // resolves its inherited dependencies against the generated harness. let harness_root = package .target_directory - .join("dctl-manifest-harness") + .join("distributed-manifest-harness") .join(&package.name); let harness_dir = harness_root.join(mode.cache_key()); fs::create_dir_all(harness_dir.join("src"))?; fs::write( harness_dir.join("Cargo.toml"), harness_cargo_toml( - &format!("dctl-manifest-harness-{}", mode.cache_key()), + &format!("distributed-manifest-harness-{}", mode.cache_key()), &crate_ident, &package.name, &package.directory, @@ -161,8 +160,7 @@ fn harness_main_rs(entrypoint: &str, mode: HarnessMode) -> String { HarnessMode::DescribeJson => format!( r#"fn main() {{ let manifest = {entrypoint}(); - let envelope = distributed::DistributedManifestEnvelope::new(manifest); - println!("{{}}", serde_json::to_string_pretty(&envelope).expect("manifest should serialize")); + println!("{{}}", serde_json::to_string_pretty(&manifest).expect("manifest should serialize")); }} "# ), @@ -173,12 +171,10 @@ fn harness_main_rs(entrypoint: &str, mode: HarnessMode) -> String { }; format!( r#"fn main() {{ - let manifest = {entrypoint}(); - let envelope = distributed::DistributedManifestEnvelope::new(manifest); - let statements = envelope - .project + let catalog = {entrypoint}(); + let statements = catalog .sql_statements(distributed::table::TableSqlDialect::{dialect}) - .expect("manifest SQL should render"); + .expect("read-model SQL should render"); if !statements.is_empty() {{ println!("{{}}", statements.join("\n\n")); }} @@ -188,12 +184,9 @@ fn harness_main_rs(entrypoint: &str, mode: HarnessMode) -> String { } HarnessMode::SchemaGraphql => format!( r#"fn main() {{ - let manifest = {entrypoint}(); - let envelope = distributed::DistributedManifestEnvelope::new(manifest); - let sdl = envelope - .project - .graphql_sdl() - .expect("manifest GraphQL SDL should render"); + let catalog = {entrypoint}(); + let sdl = distributed::graphql::graphql_sdl_for_tables(&catalog.tables) + .expect("read-model GraphQL SDL should render"); print!("{{}}", sdl); }} "# @@ -347,7 +340,7 @@ mod tests { #[test] fn harness_is_standalone_inside_cached_target_directory() { let cargo_toml = harness_cargo_toml( - "dctl-manifest-harness-schema-postgres", + "distributed-manifest-harness-schema-postgres", "todo_model", "todo-model", Path::new("/tmp/todo-model"), @@ -357,13 +350,13 @@ mod tests { ); assert!(cargo_toml.contains("\n[workspace]\n")); - assert!(cargo_toml.contains("name = \"dctl-manifest-harness-schema-postgres\"")); + assert!(cargo_toml.contains("name = \"distributed-manifest-harness-schema-postgres\"")); } #[test] fn schema_harness_uses_public_table_module_sql_dialect() { let main_rs = harness_main_rs( - "orders_service::distributed_manifest", + "orders_service::read_model_catalog", HarnessMode::SchemaSql(SchemaDialect::Postgres), ); diff --git a/distributed_cli/src/skills.rs b/distributed_cli/src/skills.rs index 9f5d42e66..5930c5d9d 100644 --- a/distributed_cli/src/skills.rs +++ b/distributed_cli/src/skills.rs @@ -43,7 +43,7 @@ pub struct EmbeddedSkill { static SKILLS: [EmbeddedSkill; 4] = [ EmbeddedSkill { name: "distributed-usage", - description: "Build Distributed CQRS/event-sourced Rust services where you mostly write models and handlers while the framework and dctl generate persistence, transports, manifests, and deploy wiring. Use model-first TDD to specify plain aggregate behavior with fast unit tests before implementing models and thin handlers. Use when designing, testing, writing, or modifying a Distributed service or domain model.", + description: "Build Distributed CQRS/event-sourced Rust services where you mostly write models and handlers while the framework and distributed generate persistence, transports, manifests, and deploy wiring. Use model-first TDD to specify plain aggregate behavior with fast unit tests before implementing models and thin handlers. Use when designing, testing, writing, or modifying a Distributed service or domain model.", files: &[EmbeddedFile { relative_path: "SKILL.md", contents: include_str!("../skills/distributed-usage/SKILL.md"), @@ -51,7 +51,7 @@ static SKILLS: [EmbeddedSkill; 4] = [ }, EmbeddedSkill { name: "distributed-ci", - description: "Set up CI, release workflows, and GitOps promotion for a Distributed service with dctl scaffold flags (--github, --gitops, --gitops-promote). Use when configuring pipelines, previews, releases, or deploy automation.", + description: "Set up CI, release workflows, and GitOps promotion for a Distributed service with distributed scaffold flags (--github, --gitops, --gitops-promote). Use when configuring pipelines, previews, releases, or deploy automation.", files: &[EmbeddedFile { relative_path: "SKILL.md", contents: include_str!("../skills/distributed-ci/SKILL.md"), @@ -59,7 +59,7 @@ static SKILLS: [EmbeddedSkill; 4] = [ }, EmbeddedSkill { name: "distributed-schema", - description: "Inspect a Distributed service manifest and render schema artifacts - dctl describe (manifest JSON), dctl schema (migration SQL or an Atlas Operator resource), and the distributed_manifest() envelope contract. Use when working on read-model schemas, migrations, or schema automation.", + description: "Inspect a Distributed read-model catalog and render schema artifacts - distributed describe (application JSON), distributed schema (migration SQL or an Atlas Operator resource), and the explicit application/read-model artifact contract. Use when working on read-model schemas, migrations, or schema automation.", files: &[EmbeddedFile { relative_path: "SKILL.md", contents: include_str!("../skills/distributed-schema/SKILL.md"), @@ -87,7 +87,7 @@ pub fn embedded_skills() -> &'static [EmbeddedSkill] { pub const AGENTS_MD_FILE: &str = "AGENTS.md"; const AGENTS_MD_BEGIN: &str = - ""; + ""; const AGENTS_MD_END: &str = ""; /// What to generate. The pure input to [`generate_skills`]. All paths in the diff --git a/distributed_cli/tests/cli_client.rs b/distributed_cli/tests/cli_client.rs index 4c3d4bbfb..f3f3dc986 100644 --- a/distributed_cli/tests/cli_client.rs +++ b/distributed_cli/tests/cli_client.rs @@ -1,4 +1,4 @@ -//! Integration tests for `dctl client`: drive the real binary against a small +//! Integration tests for `distributed client`: drive the real binary against a small //! manifest-v2 project and verify generation, read-only drift checking, //! authorization-surface selection, document discovery, and explicit `@load` //! route registration. @@ -312,12 +312,12 @@ fn write_document(project: &Path, relative: &str, source: &str) { } fn dctl_client(project: &Path, args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_dctl")) + Command::new(env!("CARGO_BIN_EXE_distributed")) .arg("client") .args(args) .current_dir(project) .output() - .expect("dctl should run") + .expect("distributed should run") } fn generate(project: &Path, documents: &str, extra: &[&str]) -> Output { @@ -743,7 +743,7 @@ fn generation_rejects_an_unproven_old_surface_chunk_without_provenance() { fs::create_dir_all(generated.join("operations")).expect("create old output tree"); fs::write( generated.join("operations/admin-only.ts"), - "/** GENERATED by dctl client. Do not edit. */\nexport const secret = true;\n", + "/** GENERATED by distributed client. Do not edit. */\nexport const secret = true;\n", ) .expect("write unproven elevated chunk"); let before = snapshot_tree(&generated); diff --git a/distributed_cli/tests/cli_manifest.rs b/distributed_cli/tests/cli_manifest.rs index 90e6bf417..11d1d7319 100644 --- a/distributed_cli/tests/cli_manifest.rs +++ b/distributed_cli/tests/cli_manifest.rs @@ -17,20 +17,20 @@ fn fixture_manifest() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/orders-service/Cargo.toml") } -/// Run `dctl ` against the fixture, returning stdout. Always passes +/// Run `distributed ` against the fixture, returning stdout. Always passes /// `--manifest-path` and `--distributed-path` so resolution is deterministic. -fn dctl(args: &[&str]) -> String { +fn distributed(args: &[&str]) -> String { let root = distributed_root(); let manifest = fixture_manifest(); - let output = Command::new(env!("CARGO_BIN_EXE_dctl")) + let output = Command::new(env!("CARGO_BIN_EXE_distributed")) .args(args) .args(["--manifest-path", manifest.to_str().unwrap()]) .args(["--distributed-path", root.to_str().unwrap()]) .output() - .expect("dctl should run"); + .expect("distributed should run"); assert!( output.status.success(), - "dctl {args:?} failed:\n{}", + "distributed {args:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8_lossy(&output.stdout).into_owned() @@ -39,7 +39,7 @@ fn dctl(args: &[&str]) -> String { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn describe_emits_manifest_json() { - let json = dctl(&["describe"]); + let json = distributed(&["describe"]); assert!(json.contains("\"schema_version\""), "json: {json}"); assert!(json.contains("\"orders\""), "json: {json}"); } @@ -47,7 +47,7 @@ fn describe_emits_manifest_json() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn client_manifest_uses_service_surface_export() { - let json = dctl(&["client-manifest"]); + let json = distributed(&["client-manifest"]); let manifest: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(manifest["manifest_version"], 2); assert_eq!(manifest["protocol_version"], 1); @@ -104,7 +104,7 @@ fn client_manifest_uses_service_surface_export() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn emitted_client_manifest_is_accepted_by_client_compiler() { - let json = dctl(&["client-manifest"]); + let json = distributed(&["client-manifest"]); let manifest: serde_json::Value = serde_json::from_str(&json).unwrap(); let project = distributed_cli::compile_client(distributed_cli::ClientCompileInput::new( manifest, @@ -124,7 +124,7 @@ fn emitted_client_manifest_is_accepted_by_client_compiler() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn schema_renders_postgres_sql() { - let sql = dctl(&["schema", "--dialect", "postgres"]); + let sql = distributed(&["schema", "--dialect", "postgres"]); assert!(sql.contains("CREATE TABLE"), "sql: {sql}"); assert!(sql.contains("orders"), "sql: {sql}"); } @@ -132,7 +132,7 @@ fn schema_renders_postgres_sql() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn schema_renders_sqlite_sql() { - let sql = dctl(&["schema", "--dialect", "sqlite"]); + let sql = distributed(&["schema", "--dialect", "sqlite"]); assert!(sql.contains("CREATE TABLE"), "sql: {sql}"); assert!(sql.contains("orders"), "sql: {sql}"); // SQLite renders upper-case storage classes; postgres uses lower-case @@ -143,7 +143,7 @@ fn schema_renders_sqlite_sql() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn schema_renders_atlas_resource() { - let yaml = dctl(&[ + let yaml = distributed(&[ "schema", "--format", "atlas", diff --git a/distributed_cli/tests/cli_scaffold.rs b/distributed_cli/tests/cli_scaffold.rs index e453d3239..499a7722b 100644 --- a/distributed_cli/tests/cli_scaffold.rs +++ b/distributed_cli/tests/cli_scaffold.rs @@ -1,4 +1,4 @@ -//! Integration tests for `dctl scaffold`: drive the real binary and assert the +//! Integration tests for `distributed scaffold`: drive the real binary and assert the //! generated project tree. Pure generation + filesystem, so these are fast and //! need no nested compilation — `cli_scaffold_compile.rs` owns the (ignored) //! end-to-end compile checks. @@ -15,7 +15,7 @@ fn distributed_root() -> PathBuf { .to_path_buf() } -/// Run `dctl scaffold orders --path / ` against a +/// Run `distributed scaffold orders --path / ` against a /// fresh directory, returning the raw process output and the output directory. fn run_scaffold(dir_name: &str, extra_args: &[&str]) -> (Output, PathBuf) { let out_dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(dir_name); @@ -24,10 +24,10 @@ fn run_scaffold(dir_name: &str, extra_args: &[&str]) -> (Output, PathBuf) { (output, out_dir) } -/// Run `dctl scaffold orders --path ` without touching +/// Run `distributed scaffold orders --path ` without touching /// the directory first (error-path tests pre-populate it). fn scaffold_into(out_dir: &Path, extra_args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_dctl")) + Command::new(env!("CARGO_BIN_EXE_distributed")) .args([ "scaffold", "orders", @@ -38,7 +38,7 @@ fn scaffold_into(out_dir: &Path, extra_args: &[&str]) -> Output { ]) .args(extra_args) .output() - .expect("dctl should run") + .expect("distributed should run") } /// Scaffold and assert success, returning the output directory. @@ -46,7 +46,7 @@ fn scaffold(dir_name: &str, extra_args: &[&str]) -> PathBuf { let (output, out_dir) = run_scaffold(dir_name, extra_args); assert!( output.status.success(), - "dctl scaffold {extra_args:?} failed:\n{}", + "distributed scaffold {extra_args:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); out_dir @@ -242,10 +242,10 @@ fn scaffold_refuses_a_non_empty_directory() { #[test] fn describe_reports_a_missing_manifest() { let missing = Path::new(env!("CARGO_TARGET_TMPDIR")).join("no-such-dir/Cargo.toml"); - let output = Command::new(env!("CARGO_BIN_EXE_dctl")) + let output = Command::new(env!("CARGO_BIN_EXE_distributed")) .args(["describe", "--manifest-path", missing.to_str().unwrap()]) .output() - .expect("dctl should run"); + .expect("distributed should run"); assert!(!output.status.success(), "describe should fail"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( diff --git a/distributed_cli/tests/cli_scaffold_compile.rs b/distributed_cli/tests/cli_scaffold_compile.rs index f5088d520..9811ed7a7 100644 --- a/distributed_cli/tests/cli_scaffold_compile.rs +++ b/distributed_cli/tests/cli_scaffold_compile.rs @@ -1,4 +1,4 @@ -//! End-to-end compile tests for `dctl scaffold`: scaffold a project, point its +//! End-to-end compile tests for `distributed scaffold`: scaffold a project, point its //! `distributed` dependency at this workspace, and `cargo check` the output. //! //! The fast generation tests only assert rendered text, so template drift @@ -23,7 +23,7 @@ fn distributed_root() -> PathBuf { fn scaffold(dir_name: &str, extra_args: &[&str]) -> PathBuf { let out_dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(dir_name); let _ = fs::remove_dir_all(&out_dir); - let output = Command::new(env!("CARGO_BIN_EXE_dctl")) + let output = Command::new(env!("CARGO_BIN_EXE_distributed")) .args([ "scaffold", "orders", @@ -34,10 +34,10 @@ fn scaffold(dir_name: &str, extra_args: &[&str]) -> PathBuf { ]) .args(extra_args) .output() - .expect("dctl should run"); + .expect("distributed should run"); assert!( output.status.success(), - "dctl scaffold {extra_args:?} failed:\n{}", + "distributed scaffold {extra_args:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); out_dir diff --git a/distributed_cli/tests/cli_skills_init.rs b/distributed_cli/tests/cli_skills_init.rs index 8c7305d08..32b4d94b8 100644 --- a/distributed_cli/tests/cli_skills_init.rs +++ b/distributed_cli/tests/cli_skills_init.rs @@ -1,4 +1,4 @@ -//! Integration tests for `dctl skills init` / `dctl skills list`: drive the +//! Integration tests for `distributed skills init` / `distributed skills list`: drive the //! real binary against temp directories and assert the extracted skill tree, //! harness wiring, idempotent re-runs, and drift semantics. No network, no //! repo checkout — the skills are embedded in the binary. @@ -9,7 +9,7 @@ use std::process::{Command, Output}; const SKILL_NAMES: [&str; 3] = ["distributed-usage", "distributed-ci", "distributed-schema"]; const BEGIN: &str = - ""; + ""; const END: &str = ""; /// A fresh project directory under the target tmpdir. @@ -20,21 +20,21 @@ fn project_dir(name: &str) -> PathBuf { dir } -/// Run `dctl skills ` with the given project directory as cwd. +/// Run `distributed skills ` with the given project directory as cwd. fn dctl_skills(cwd: &Path, args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_dctl")) + Command::new(env!("CARGO_BIN_EXE_distributed")) .arg("skills") .args(args) .current_dir(cwd) .output() - .expect("dctl should run") + .expect("distributed should run") } fn init_ok(cwd: &Path, args: &[&str]) -> (String, String) { let output = dctl_skills(cwd, args); assert!( output.status.success(), - "dctl skills {args:?} failed:\n{}", + "distributed skills {args:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); ( diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-chain-cycle.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-chain-cycle.json new file mode 100644 index 000000000..6cf457354 --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-chain-cycle.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "entries": { + "application": { + "id": "application", + "kind": "application_manifest", + "scope": { "id": "cycle/application" }, + "owner": "cycle/application", + "identity": { "kind": "application_manifest", "value": "sha256:application" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.application" + }, + "predecessor": { + "entry_id": "deployment", + "identity": { "kind": "deployment_plan", "value": "sha256:deployment" } + }, + "outputs": { "application": "distributed_cli/src/lib.rs" } + }, + "deployment": { + "id": "deployment", + "kind": "deployment_plan", + "scope": { "id": "cycle/deployment" }, + "owner": "cycle/deployment", + "identity": { "kind": "deployment_plan", "value": "sha256:deployment" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.deployment" + }, + "predecessor": { + "entry_id": "application", + "identity": { "kind": "application_manifest", "value": "sha256:application" } + }, + "outputs": { "deployment": "distributed_cli/src/cli.rs" } + } + } +} diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-duplicate-scope.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-duplicate-scope.json new file mode 100644 index 000000000..86fa36485 --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-duplicate-scope.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "entries": [ + { + "id": "first", + "kind": "application_manifest", + "scope": { "id": "duplicate-scope" }, + "owner": "owner/first", + "identity": { "kind": "application_manifest", "value": "sha256:first" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.first" + }, + "outputs": { "first": "distributed_cli/Cargo.toml" } + }, + { + "id": "second", + "kind": "deployment_plan", + "scope": { "id": "duplicate-scope" }, + "owner": "owner/second", + "identity": { "kind": "deployment_plan", "value": "sha256:second" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.second" + }, + "outputs": { "second": "distributed_cli/src/lib.rs" } + } + ] +} diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-environment-value.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-environment-value.json new file mode 100644 index 000000000..61f10b3f6 --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-environment-value.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "entries": { + "environment-value": { + "id": "environment-value", + "kind": "resolved_deployment", + "scope": { "id": "environment/value" }, + "owner": "dpl/environment/value", + "identity": { "kind": "resolved_deployment", "value": "sha256:environment-value" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.environment" + }, + "outputs": { "deployment": "distributed_cli/src/lib.rs" }, + "environment": { + "DATABASE_URL": "postgres://user:password@example.invalid/db" + } + } + } +} diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-escaping-path.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-escaping-path.json new file mode 100644 index 000000000..4176d5fa3 --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-escaping-path.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "entries": { + "escaping": { + "id": "escaping", + "kind": "migration_inventory", + "scope": { "id": "repository/escaping" }, + "owner": "migrations/escaping", + "identity": { "kind": "migration_inventory", "value": "sha256:escaping" }, + "provenance": { + "sources": ["../outside.json"], + "generator": "test.escaping" + }, + "outputs": { "output": "distributed_cli/Cargo.toml" } + } + } +} diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-valid.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-valid.json new file mode 100644 index 000000000..8096b70fe --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-valid.json @@ -0,0 +1,83 @@ +{ + "schema_version": 1, + "entries": { + "application-manifest": { + "id": "application-manifest", + "kind": "application_manifest", + "scope": { "id": "application/e2e-ui" }, + "owner": "cmp/e2e-ui/application", + "identity": { "kind": "application_manifest", "value": "sha256:application" }, + "provenance": { + "sources": ["tests/e2e-ui/crates/service/src/lib.rs"], + "generator": "cmp.application_manifest", + "source_revision": "1b0011c00c7ea426139e3dcd38b57ea34d031b57" + }, + "outputs": { + "application": "tests/e2e-ui/crates/service/Cargo.toml" + }, + "lifecycle": ["check"] + }, + "deployment-plan": { + "id": "deployment-plan", + "kind": "deployment_plan", + "scope": { "id": "deployment/e2e-ui" }, + "owner": "cmp/e2e-ui/deployment", + "identity": { "kind": "deployment_plan", "value": "sha256:deployment" }, + "provenance": { + "sources": ["tests/e2e-ui/crates/service/src/service.rs"], + "generator": "cmp.deployment_plan" + }, + "predecessor": { + "entry_id": "application-manifest", + "identity": { "kind": "application_manifest", "value": "sha256:application" } + }, + "outputs": { + "deployment": "tests/e2e-ui/crates/runner/Cargo.toml" + }, + "lifecycle": ["check"] + }, + "client-program": { + "id": "client-program", + "kind": "client_program_descriptor", + "scope": { "id": "client/e2e-ui" }, + "owner": "query-layer/e2e-ui/program", + "identity": { "kind": "client_program_descriptor", "value": "sha256:client-program" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client_program" + }, + "predecessor": { + "entry_id": "deployment-plan", + "identity": { "kind": "deployment_plan", "value": "sha256:deployment" } + }, + "outputs": { + "client-program": "tests/e2e-ui/ui/src/lib/generated/public/manifest.json" + }, + "lifecycle": ["check"] + }, + "resolved-deployment": { + "id": "resolved-deployment", + "kind": "resolved_deployment", + "scope": { "id": "resolved/e2e-ui" }, + "owner": "dpl/e2e-ui/resolution", + "identity": { "kind": "resolved_deployment", "value": "sha256:resolved" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "dpl.resolved_deployment" + }, + "predecessor": { + "entry_id": "client-program", + "identity": { "kind": "client_program_descriptor", "value": "sha256:client-program" } + }, + "outputs": { + "resolved": "tests/e2e-ui/ui/src/lib/generated/admin/manifest.json" + }, + "environment_policy": { + "identity": "sha256:environment-policy", + "name": "local", + "reference": "dpl/environment/local" + }, + "lifecycle": ["check", "render"] + } + } +} diff --git a/distributed_cli/tests/fixtures/contracts/client-inventory-parity.json b/distributed_cli/tests/fixtures/contracts/client-inventory-parity.json new file mode 100644 index 000000000..59848da0f --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/client-inventory-parity.json @@ -0,0 +1,150 @@ +{ + "vectors": [ + { + "name": "null optional entrypoint is valid", + "valid": true, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example", + "manifest_entrypoint": null + } + ] + } + }, + { + "name": "unknown top-level field is invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + } + ], + "extra": true + } + }, + { + "name": "module segments must begin with an alphanumeric", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/.client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "module segments cannot contain dot-dot", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client..nested", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "dot-dot identifiers are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e..ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "secret-like paths are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/token=secret.graphql"], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "windows absolute output paths are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "C:/generated/example" + } + ] + } + }, + { + "name": "duplicate documents are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": [ + "src/routes/example/+page.graphql", + "src/routes/example/+page.graphql" + ], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "duplicate modules are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + }, + { + "module": "$distributed/client", + "surface": "e2e-admin", + "documents": ["src/routes/admin/+page.graphql"], + "output": "src/lib/generated/admin" + } + ] + } + } + ] +} diff --git a/distributed_cli/tests/fixtures/generated-commands.ts b/distributed_cli/tests/fixtures/generated-commands.ts index 8b4079f0e..6994f8724 100644 --- a/distributed_cli/tests/fixtures/generated-commands.ts +++ b/distributed_cli/tests/fixtures/generated-commands.ts @@ -1,4 +1,4 @@ -/** GENERATED by dctl client. Do not edit. */ +/** GENERATED by distributed client. Do not edit. */ import { createReplicaCommandRuntime, diff --git a/distributed_cli/tests/fixtures/generated-operation.ts b/distributed_cli/tests/fixtures/generated-operation.ts index 58891f080..bba1893da 100644 --- a/distributed_cli/tests/fixtures/generated-operation.ts +++ b/distributed_cli/tests/fixtures/generated-operation.ts @@ -1,4 +1,4 @@ -/** GENERATED by dctl client. Do not edit. */ +/** GENERATED by distributed client. Do not edit. */ import type { ReplicaOperationArtifact, ReplicaValue } from '@hops-ops/distributed/replica'; type Operation_ScalarInputs_Input_todo_bool_exp = { diff --git a/distributed_cli/tests/fixtures/generated-scalar-operation.ts b/distributed_cli/tests/fixtures/generated-scalar-operation.ts index 9a6e0af3c..8666e7cc8 100644 --- a/distributed_cli/tests/fixtures/generated-scalar-operation.ts +++ b/distributed_cli/tests/fixtures/generated-scalar-operation.ts @@ -1,4 +1,4 @@ -/** GENERATED by dctl client. Do not edit. */ +/** GENERATED by distributed client. Do not edit. */ import type { ReplicaOperationArtifact } from '@hops-ops/distributed/replica'; export type Operation_RustRuntimeBridge_Variables = { diff --git a/distributed_cli/tests/fixtures/orders-service/Cargo.toml b/distributed_cli/tests/fixtures/orders-service/Cargo.toml index 8e41be490..763f189a3 100644 --- a/distributed_cli/tests/fixtures/orders-service/Cargo.toml +++ b/distributed_cli/tests/fixtures/orders-service/Cargo.toml @@ -1,4 +1,4 @@ -# Standalone fixture compiled by the `dctl` manifest harness in integration tests. +# Standalone fixture compiled by the `distributed` manifest harness in integration tests. # Its own `[workspace]` decouples it from the repo workspace. [package] name = "orders-service" diff --git a/distributed_cli/tests/fixtures/orders-service/src/lib.rs b/distributed_cli/tests/fixtures/orders-service/src/lib.rs index 213a48ed3..8e81ee8e9 100644 --- a/distributed_cli/tests/fixtures/orders-service/src/lib.rs +++ b/distributed_cli/tests/fixtures/orders-service/src/lib.rs @@ -1,5 +1,5 @@ -//! Minimal Distributed service fixture for `dctl` manifest-harness integration -//! tests: one read model (→ an `orders` table) registered in the project manifest. +//! Minimal Distributed service fixture for `distributed` manifest-harness integration +//! tests: one read model (→ an `orders` table) registered in the read-model catalog. use std::any::TypeId; @@ -10,8 +10,8 @@ use distributed::graphql::{ }; use distributed::microsvc::{CausalCommandContext, HandlerError, Routes, Service}; use distributed::{ - Aggregate, AggregateRepository, DistributedProjectManifest, Entity, EventRecord, - InMemoryRepository, ReadModel, + Aggregate, AggregateRepository, Application, ApplicationManifest, Entity, EventRecord, + InMemoryRepository, ReadModel, ReadModelCatalog, SurfaceSpec, }; use serde::{Deserialize, Serialize}; @@ -102,16 +102,34 @@ async fn project_order( )) } -/// The entrypoint `dctl describe`/`dctl schema` call by default -/// (`::distributed_manifest`). -pub fn distributed_manifest() -> DistributedProjectManifest { - DistributedProjectManifest::new("orders").read_model::() +/// The entrypoint `distributed schema` calls by default +/// (`::read_model_catalog`). This is the physical read-model catalog, +/// not the logical application manifest. +pub fn read_model_catalog() -> ReadModelCatalog { + ReadModelCatalog::new("orders").read_model::() } -/// Pool-free client export used by `dctl client-manifest`. Both the CLI harness +/// The logical application artifact is built from the same non-executable +/// Surface contract as the client export; physical catalog SQL remains a +/// separate read-model utility. +pub fn application_manifest() -> ApplicationManifest { + let catalog = read_model_catalog(); + let surface = build_surface(&catalog.tables, &SurfaceOptions::sqlite()) + .expect("fixture Surface should build"); + let surface = SurfaceSpec::from_surface("orders", &surface) + .expect("fixture Surface contract should compile"); + Application::new("orders") + .surface(surface) + .build() + .expect("fixture application manifest should compile") + .manifest() + .clone() +} + +/// Pool-free client export used by `distributed client-manifest`. Both the CLI harness /// and a runtime engine finish through `DistributedClientSurfaceExport::manifest`. pub fn distributed_client_surface() -> DistributedClientSurfaceExport { - let project = distributed_manifest(); + let catalog = read_model_catalog(); let service = Service::new().named("orders").routes( Routes::new() .with_repo(AggregateRepository::<_, FixtureAggregate>::new( @@ -124,7 +142,7 @@ pub fn distributed_client_surface() -> DistributedClientSurfaceExport { ) .handle(project_order), ); - let full = build_surface(&project.tables, &SurfaceOptions::sqlite()) + let full = build_surface(&catalog.tables, &SurfaceOptions::sqlite()) .expect("fixture Surface should build") .with_service(&service) .expect("fixture typed service should bind") @@ -139,6 +157,6 @@ pub fn distributed_client_surface() -> DistributedClientSurfaceExport { )]); let user = surface_for_role(&full, "user", &grants).expect("fixture role policy should be valid"); - DistributedClientSurfaceExport::from_project(&project, user) + DistributedClientSurfaceExport::from_selected("orders", user) .expect("fixture Surface should be role-selected") } diff --git a/distributed_macros/Cargo.toml b/distributed_macros/Cargo.toml index 81fd26f83..91b01e077 100644 --- a/distributed_macros/Cargo.toml +++ b/distributed_macros/Cargo.toml @@ -9,7 +9,12 @@ repository.workspace = true [lib] proc-macro = true +[features] +application-runtime = ["distributed/application-runtime"] +runtime = ["application-runtime"] + [dependencies] +proc-macro-crate = "3.5" proc-macro2 = "1.0" quote = "1.0" sha2 = "0.10" diff --git a/distributed_macros/src/application.rs b/distributed_macros/src/application.rs new file mode 100644 index 000000000..1004e4378 --- /dev/null +++ b/distributed_macros/src/application.rs @@ -0,0 +1,123 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Expr, Ident, LitStr, Token, Type, Visibility}; + +struct ApplicationInput { + visibility: Visibility, + name: Ident, + id: LitStr, + modules: Vec, + surfaces: Vec, + capabilities: Vec, + extensions: Vec, +} + +impl Parse for ApplicationInput { + fn parse(input: ParseStream<'_>) -> syn::Result { + let visibility: Visibility = input.parse()?; + let name: Ident = input.parse()?; + if input.peek(Token![:]) { + input.parse::()?; + let _: Type = input.parse()?; + } + if input.peek(Token![=]) { + input.parse::()?; + } + let content; + syn::braced!(content in input); + let mut output = Self { + visibility, + name, + id: LitStr::new("", proc_macro2::Span::call_site()), + modules: Vec::new(), + surfaces: Vec::new(), + capabilities: Vec::new(), + extensions: Vec::new(), + }; + while !content.is_empty() { + let field: Ident = content.parse()?; + content.parse::()?; + match field.to_string().as_str() { + "id" => output.id = content.parse()?, + "modules" => output.modules = parse_expr_array(&content)?, + "surfaces" => output.surfaces = parse_expr_array(&content)?, + "capabilities" | "required_capabilities" => { + output.capabilities = parse_expr_array(&content)? + } + "extensions" => output.extensions = parse_expr_array(&content)?, + other => { + return Err(syn::Error::new( + field.span(), + format!("unknown application field `{other}`"), + )) + } + } + if content.peek(Token![,]) { + content.parse::()?; + } + } + if output.id.value().is_empty() { + output.id = LitStr::new( + &output.name.to_string().to_ascii_lowercase(), + output.name.span(), + ); + } + Ok(output) + } +} + +fn parse_expr_array(input: ParseStream<'_>) -> syn::Result> { + let content; + syn::bracketed!(content in input); + Ok(Punctuated::::parse_terminated(&content)? + .into_iter() + .collect()) +} + +pub fn expand(input: proc_macro2::TokenStream) -> syn::Result { + let framework = crate::shared::framework_path()?; + let input = syn::parse2::(input)?; + let ApplicationInput { + visibility, + name, + id, + modules, + surfaces, + capabilities, + extensions, + } = input; + let accessor = format_ident!("{}", name.to_string().to_lowercase()); + let modules = modules.iter().map(|value| quote! { (&*#value).clone() }); + let surfaces = surfaces.iter().map(|value| quote! { (&*#value).clone() }); + let capabilities = capabilities + .iter() + .map(|value| quote! { #value }) + .collect::>(); + let extensions = extensions + .iter() + .map(|value| quote! { (&*#value).clone() }) + .collect::>(); + let capability_builder = if capabilities.is_empty() { + quote! {} + } else { + quote! { .required_capabilities([#(#capabilities),*]) } + }; + Ok(quote! { + #visibility static #name: ::std::sync::LazyLock<#framework::application::Application> = + ::std::sync::LazyLock::new(|| { + #framework::application::Application::new(#id) + .modules([#(#modules),*]) + .surfaces([#(#surfaces),*]) + .extensions([#(#extensions),*]) + #capability_builder + .build() + .unwrap_or_else(|error| panic!("invalid generated application: {error}")) + }); + + #visibility fn #accessor() -> &'static #framework::application::Application { + &#name + } + }) +} diff --git a/distributed_macros/src/command.rs b/distributed_macros/src/command.rs new file mode 100644 index 000000000..511002062 --- /dev/null +++ b/distributed_macros/src/command.rs @@ -0,0 +1,510 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Expr, FnArg, Ident, ItemFn, LitStr, PathArguments, ReturnType, Token, Type}; + +#[derive(Default)] +struct CommandArgs { + id: Option, + field_name: Option, + input: Option, + outcome: Option, + roles: Vec, + emits: Vec, + applies: Option, + defaults: Option, + generated_defaults: Vec<(Ident, Ident)>, +} + +impl Parse for CommandArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut args = Self::default(); + while !input.is_empty() { + let key: Ident = input.parse()?; + match key.to_string().as_str() { + "roles" => { + let content; + syn::parenthesized!(content in input); + let values = Punctuated::::parse_terminated(&content)?; + for value in values { + match value { + syn::Expr::Path(path) if path.path.segments.len() == 1 => { + args.roles.push(LitStr::new( + &path.path.segments[0].ident.to_string(), + path.path.segments[0].ident.span(), + )); + } + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(value), + .. + }) => args.roles.push(value), + other => { + return Err(syn::Error::new_spanned( + other, + "command roles must be identifiers or string literals", + )) + } + } + } + } + "emits" => { + let content; + syn::parenthesized!(content in input); + args.emits = Punctuated::::parse_terminated(&content)? + .into_iter() + .collect(); + } + "applies" => { + let content; + syn::parenthesized!(content in input); + args.applies = Some(content.parse()?); + } + "defaults" | "input_defaults" => { + input.parse::()?; + args.defaults = Some(input.parse()?); + } + "default" => { + let content; + syn::parenthesized!(content in input); + let target: Ident = content.parse()?; + content.parse::()?; + let generator: Ident = content.parse()?; + if !content.is_empty() { + let arguments; + syn::parenthesized!(arguments in content); + if !arguments.is_empty() { + return Err( + arguments.error("input-default generators take no arguments") + ); + } + } + if !matches!(generator.to_string().as_str(), "uuid_v7" | "ulid") { + return Err(syn::Error::new( + generator.span(), + "unknown input-default generator; expected uuid_v7() or ulid()", + )); + } + args.generated_defaults.push((target, generator)); + } + "id" | "field" | "field_name" => { + input.parse::()?; + let value: LitStr = input.parse()?; + if key == "id" { + args.id = Some(value); + } else { + args.field_name = Some(value); + } + } + "input" | "outcome" => { + input.parse::()?; + let value: Type = input.parse()?; + if key == "input" { + args.input = Some(value); + } else { + args.outcome = Some(value); + } + } + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown command option `{other}`"), + )) + } + } + if input.peek(Token![,]) { + input.parse::()?; + } + } + Ok(args) + } +} + +pub fn expand( + attr: proc_macro2::TokenStream, + item: proc_macro2::TokenStream, +) -> syn::Result { + let framework = crate::shared::framework_path()?; + let args = syn::parse2::(attr)?; + let function = syn::parse2::(item)?; + let id = args.id.ok_or_else(|| { + syn::Error::new( + function.sig.ident.span(), + "command declaration requires `id = \"...\"`", + ) + })?; + if !function.sig.asyncness.is_some() { + return Err(syn::Error::new_spanned( + &function.sig.fn_token, + "typed command handlers must be async", + )); + } + let typed_args = function + .sig + .inputs + .iter() + .filter_map(|argument| match argument { + FnArg::Typed(argument) => Some(argument), + FnArg::Receiver(_) => None, + }) + .collect::>(); + if function.sig.inputs.iter().any(|argument| matches!(argument, FnArg::Receiver(_))) + || typed_args.len() != 2 + { + return Err(syn::Error::new_spanned( + &function.sig.inputs, + "typed command handler must have exactly `(context: &CausalCommandContext<'_, Aggregate>, input: Input)` parameters", + )); + } + let context_ty = &typed_args[0].ty; + if !is_causal_context_type(context_ty) { + return Err(syn::Error::new_spanned( + &typed_args[0].ty, + "first typed command parameter must be `&CausalCommandContext<'_, Aggregate>`", + )); + } + let aggregate = causal_context_aggregate_type(context_ty).ok_or_else(|| { + syn::Error::new_spanned( + context_ty, + "first typed command parameter must name the aggregate in `CausalCommandContext`", + ) + })?; + let inferred_input = (*typed_args[1].ty).clone(); + let declared_input = args.input.clone(); + let input = args.input.unwrap_or(inferred_input); + let outcome = args + .outcome + .or_else(|| infer_prepared_outcome(&function.sig.output)) + .ok_or_else(|| { + syn::Error::new_spanned( + &function.sig.output, + "command outcome is not inferable; provide `outcome = PreparedCommand`", + ) + })?; + validate_prepared_return(&function.sig.output, &outcome)?; + if let Some(input) = declared_input.as_ref() { + if !same_type(input, &typed_args[1].ty) { + return Err(syn::Error::new_spanned( + &typed_args[1].ty, + "handler input parameter does not match the declared `input = ...` type", + )); + } + } + let field_name = args.field_name.unwrap_or_else(|| { + LitStr::new( + &id.value() + .chars() + .map(|character| { + if matches!(character, '.' | '-') { + '_' + } else { + character + } + }) + .collect::(), + id.span(), + ) + }); + let function_name = &function.sig.ident; + let command_name = format_ident!("{}_command", function_name); + let spec_name = format_ident!("{}_spec", function_name); + let command_static = format_ident!("{}_COMMAND", function_name.to_string().to_uppercase()); + let spec_static = format_ident!("{}_SPEC", function_name.to_string().to_uppercase()); + let mount_name = format_ident!("{}_mount", function_name); + let mount_static = format_ident!("{}_MOUNT", function_name.to_string().to_uppercase()); + let definition_name = format_ident!("{}_definition", function_name); + let definition_static = format_ident!( + "{}_DEFINITION", + function_name.to_string().to_uppercase() + ); + let command_id_static = format_ident!( + "{}_COMMAND_ID", + function_name.to_string().to_uppercase() + ); + let accessor_name = format_ident!("{}_application_command", function_name); + let register_name = format_ident!("{}_register", function_name); + let visibility = &function.vis; + let roles = args.roles; + let emits = args.emits; + let applies = args.applies; + let defaults = args.defaults; + let generated_defaults = args.generated_defaults; + let mut builder = quote! { + #framework::graphql::typed_command::<#input, #outcome>(#id) + .field_name(#field_name) + }; + if !roles.is_empty() { + builder.extend(quote! { .roles([#(#roles),*]) }); + } + if !emits.is_empty() { + builder.extend(quote! { .emits(#framework::events!(#(#emits),*)) }); + } + if let Some(applies) = applies { + builder.extend(quote! { .applies(#applies) }); + } + if let Some(defaults) = defaults { + builder.extend(quote! { .input_defaults(#defaults) }); + } else if !generated_defaults.is_empty() { + let defaults = generated_defaults.iter().map(|(field, generator)| { + quote! { default input.#field = #generator(); } + }); + builder.extend(quote! { + .input_defaults(#framework::command_input_defaults! { + input: #input; + #(#defaults)* + }) + }); + } + Ok(quote! { + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #function + + #visibility fn #command_name() -> #framework::graphql::TypedCommand<#input, #outcome> { + #builder + } + + #visibility static #command_static: ::std::sync::LazyLock< + #framework::graphql::TypedCommand<#input, #outcome> + > = ::std::sync::LazyLock::new(#command_name); + + #visibility fn #spec_name() -> #framework::application::ApplicationResult< + #framework::application::CommandSpec + > { + (#command_static).spec() + } + + #visibility static #spec_static: ::std::sync::LazyLock< + #framework::application::CommandSpec + > = ::std::sync::LazyLock::new(|| + #spec_name().unwrap_or_else(|error| panic!("invalid generated command spec: {error}")) + ); + + #visibility fn #accessor_name() -> &'static #framework::application::CommandSpec { + &#spec_static + } + + #visibility const #command_id_static: &str = #id; + + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #visibility fn #mount_name() -> #framework::application::CommandMount { + #framework::application::CommandMount::from_typed_route( + (#spec_static).clone(), + #id, + ) + } + + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #visibility static #mount_static: ::std::sync::LazyLock< + #framework::application::CommandMount + > = ::std::sync::LazyLock::new(#mount_name); + + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #visibility fn #register_name( + routes: #framework::microsvc::Routes, + ) -> #framework::microsvc::Routes + where + D: #framework::microsvc::CausalRouteDependencies + + Send + + Sync + + 'static, + #aggregate: #framework::Aggregate + Send + Sync + 'static, + #input: #framework::__private::serde::de::DeserializeOwned + Send + 'static, + #outcome: #framework::graphql::CommandOutcome, + { + routes + .typed_command((#command_static).clone()) + .mount((#mount_static).clone()) + .handle(#function_name) + } + + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #visibility fn #definition_name() -> #framework::application::CommandDefinition { + #framework::application::CommandDefinition::from_typed_command( + (#command_static).clone(), + Some((#mount_static).clone()), + ) + .unwrap_or_else(|error| panic!("invalid generated command definition: {error}")) + } + + #[allow(unexpected_cfgs)] + #[cfg(not(feature = "application-runtime"))] + #visibility fn #definition_name() -> #framework::application::CommandDefinition { + #framework::application::CommandDefinition::from_typed_command( + (#command_static).clone(), + None, + ) + .unwrap_or_else(|error| panic!("invalid generated command definition: {error}")) + } + + #visibility static #definition_static: ::std::sync::LazyLock< + #framework::application::CommandDefinition + > = ::std::sync::LazyLock::new(#definition_name); + }) +} + +fn is_causal_context_type(ty: &Type) -> bool { + let Type::Reference(reference) = ty else { + return false; + }; + let Type::Path(path) = reference.elem.as_ref() else { + return false; + }; + path.path + .segments + .last() + .is_some_and(|segment| segment.ident == "CausalCommandContext") +} + +fn causal_context_aggregate_type(ty: &Type) -> Option { + let Type::Reference(reference) = ty else { + return None; + }; + let Type::Path(path) = reference.elem.as_ref() else { + return None; + }; + let segment = path.path.segments.last()?; + if segment.ident != "CausalCommandContext" { + return None; + } + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return None; + }; + arguments.args.iter().find_map(|argument| match argument { + syn::GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + }) +} + +fn validate_prepared_return(output: &ReturnType, outcome: &Type) -> syn::Result<()> { + let Some((_, output)) = (match output { + ReturnType::Type(arrow, output) => Some((arrow, output.as_ref())), + ReturnType::Default => None, + }) else { + return Err(syn::Error::new_spanned( + output, + "typed command handler must return `Result, HandlerError>`", + )); + }; + let Type::Path(result) = output else { + return Err(syn::Error::new_spanned( + output, + "typed command handler must return `Result, HandlerError>`", + )); + }; + let Some(result_segment) = result.path.segments.last() else { + return Err(syn::Error::new_spanned(output, "missing Result return type")); + }; + if result_segment.ident != "Result" { + return Err(syn::Error::new_spanned( + output, + "typed command handler return type must be Result, HandlerError>", + )); + } + let PathArguments::AngleBracketed(arguments) = &result_segment.arguments else { + return Err(syn::Error::new_spanned( + output, + "typed command handler Result must declare PreparedCommand and HandlerError types", + )); + }; + let mut types = arguments.args.iter().filter_map(|argument| match argument { + syn::GenericArgument::Type(ty) => Some(ty), + _ => None, + }); + let Some(prepared) = types.next() else { + return Err(syn::Error::new_spanned(output, "missing PreparedCommand return type")); + }; + let Some(error) = types.next() else { + return Err(syn::Error::new_spanned(output, "missing HandlerError return type")); + }; + let Type::Path(prepared) = prepared else { + return Err(syn::Error::new_spanned( + prepared, + "first Result type must be PreparedCommand", + )); + }; + let Some(prepared_segment) = prepared.path.segments.last() else { + return Err(syn::Error::new_spanned(prepared, "missing PreparedCommand type")); + }; + if prepared_segment.ident != "PreparedCommand" { + return Err(syn::Error::new_spanned( + prepared, + "first Result type must be PreparedCommand", + )); + } + let PathArguments::AngleBracketed(prepared_args) = &prepared_segment.arguments else { + return Err(syn::Error::new_spanned( + prepared, + "PreparedCommand must declare its outcome type", + )); + }; + let Some(syn::GenericArgument::Type(actual_outcome)) = prepared_args.args.first() else { + return Err(syn::Error::new_spanned( + prepared, + "PreparedCommand must declare its outcome type", + )); + }; + if !same_type(actual_outcome, outcome) { + return Err(syn::Error::new_spanned( + actual_outcome, + "PreparedCommand outcome does not match the declared `outcome = ...` type", + )); + } + let Type::Path(error) = error else { + return Err(syn::Error::new_spanned( + error, + "second Result type must be HandlerError", + )); + }; + if error + .path + .segments + .last() + .is_none_or(|segment| segment.ident != "HandlerError") + { + return Err(syn::Error::new_spanned( + error, + "second Result type must be HandlerError", + )); + } + Ok(()) +} + +fn same_type(left: &Type, right: &Type) -> bool { + quote!(#left).to_string().replace(' ', "") == quote!(#right).to_string().replace(' ', "") +} + +fn infer_prepared_outcome(output: &ReturnType) -> Option { + let ReturnType::Type(_, output) = output else { + return None; + }; + let Type::Path(path) = output.as_ref() else { + return None; + }; + let result = path.path.segments.last()?; + if result.ident != "Result" { + return None; + } + let PathArguments::AngleBracketed(arguments) = &result.arguments else { + return None; + }; + let first = arguments.args.first()?; + let syn::GenericArgument::Type(Type::Path(prepared)) = first else { + return None; + }; + let prepared = prepared.path.segments.last()?; + if prepared.ident != "PreparedCommand" { + return None; + } + let PathArguments::AngleBracketed(arguments) = &prepared.arguments else { + return None; + }; + match arguments.args.first()? { + syn::GenericArgument::Type(output) => Some(output.clone()), + _ => None, + } +} diff --git a/distributed_macros/src/command_input_defaults.rs b/distributed_macros/src/command_input_defaults.rs index ae18bc0a6..24bebfd50 100644 --- a/distributed_macros/src/command_input_defaults.rs +++ b/distributed_macros/src/command_input_defaults.rs @@ -16,7 +16,10 @@ mod keyword { pub fn expand_input_defaults(input: proc_macro::TokenStream) -> proc_macro::TokenStream { match syn::parse::(input) { - Ok(defaults) => defaults.expand().into(), + Ok(defaults) => match crate::shared::framework_path() { + Ok(framework) => defaults.expand(framework).into(), + Err(error) => error.to_compile_error().into(), + }, Err(error) => error.to_compile_error().into(), } } @@ -55,14 +58,14 @@ impl Parse for CommandInputDefaults { } impl CommandInputDefaults { - fn expand(self) -> TokenStream { + fn expand(self, framework: TokenStream) -> TokenStream { let input = self.input; let defaults = self .defaults .into_iter() - .map(|default| default.expand(&input)); + .map(|default| default.expand(&input, &framework)); quote! { - distributed::graphql::__command_input_defaults::<#input>( + #framework::graphql::__command_input_defaults::<#input>( vec![#(#defaults),*] ) } @@ -110,7 +113,7 @@ impl InputDefault { Ok(Self { field, generator }) } - fn expand(self, input: &Path) -> TokenStream { + fn expand(self, input: &Path, framework: &TokenStream) -> TokenStream { let marker_name = format_ident!( "__Distributed{}EffectInputField_{}", input.segments.last().unwrap().ident, @@ -120,10 +123,10 @@ impl InputDefault { let marker = marker_path(input, marker_name); match self.generator { InputDefaultGenerator::UuidV7 => quote! { - distributed::graphql::__input_default_uuid_v7::<#input, #marker>() + #framework::graphql::__input_default_uuid_v7::<#input, #marker>() }, InputDefaultGenerator::Ulid => quote! { - distributed::graphql::__input_default_ulid::<#input, #marker>() + #framework::graphql::__input_default_ulid::<#input, #marker>() }, } } diff --git a/distributed_macros/src/digest.rs b/distributed_macros/src/digest.rs index 523ab7317..fa5378747 100644 --- a/distributed_macros/src/digest.rs +++ b/distributed_macros/src/digest.rs @@ -4,14 +4,16 @@ use syn::{parse::Parser, Expr, Ident, ItemFn, LitStr, Token}; use crate::shared::{ ensure_sourced_result_signature, extract_params_with_types, generate_digest_call, - wrap_result_body_with_guard, + framework_path, wrap_result_body_with_guard, }; pub(crate) fn expand_digest(attr: TokenStream2, item: TokenStream2) -> syn::Result { let args = parse_digest_args.parse2(attr)?; let mut func = syn::parse2::(item)?; - let signature_synthesized = ensure_sourced_result_signature(&mut func.sig, "digest")?; + let framework = framework_path()?; + let signature_synthesized = + ensure_sourced_result_signature(&mut func.sig, "digest", &framework)?; let params = extract_params_with_types(&func.sig, "digest")?; let param_names: Vec<&Ident> = params.iter().map(|(name, _)| name).collect(); diff --git a/distributed_macros/src/domain_event.rs b/distributed_macros/src/domain_event.rs index 5e4cbc7ab..b03be56fa 100644 --- a/distributed_macros/src/domain_event.rs +++ b/distributed_macros/src/domain_event.rs @@ -5,7 +5,7 @@ use syn::{Data, DeriveInput, Fields, LitInt, LitStr}; use crate::shared::{ canonical_object_schema, projection_body_metadata_tokens, schema_fingerprint, - validate_domain_event_name_literal, + validate_domain_event_name_literal, framework_path, }; pub(crate) fn derive_domain_event(input: TokenStream) -> TokenStream { @@ -16,6 +16,7 @@ pub(crate) fn derive_domain_event(input: TokenStream) -> TokenStream { } pub(crate) fn expand_domain_event(input: DeriveInput) -> syn::Result { + let framework = framework_path()?; let (event_name, version) = parse_domain_event_descriptor(&input)?; let fields = named_fields(&input)?; if !input.generics.params.is_empty() { @@ -46,6 +47,7 @@ pub(crate) fn expand_domain_event(input: DeriveInput) -> syn::Result syn::Result syn::Result distributed::DomainEventDescriptor { - ::DESCRIPTOR.clone() + fn descriptor() -> #framework::DomainEventDescriptor { + ::DESCRIPTOR.clone() } } - impl distributed::domain_event::DomainEventBodyContract for #name {} + impl #framework::domain_event::DomainEventBodyContract for #name {} - impl distributed::projection::lower::ProjectionBodyMetadata for #name { + impl #framework::projection::lower::ProjectionBodyMetadata for #name { #projection_metadata } }) diff --git a/distributed_macros/src/domain_state.rs b/distributed_macros/src/domain_state.rs index bf4e02ae4..37ed47a46 100644 --- a/distributed_macros/src/domain_state.rs +++ b/distributed_macros/src/domain_state.rs @@ -3,7 +3,9 @@ use proc_macro2::Span; use quote::quote; use syn::{Data, DeriveInput, Fields, LitInt, LitStr}; -use crate::shared::{canonical_object_schema, projection_body_metadata_tokens, schema_fingerprint}; +use crate::shared::{ + canonical_object_schema, framework_path, projection_body_metadata_tokens, schema_fingerprint, +}; pub(crate) fn derive_domain_state(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); @@ -13,6 +15,7 @@ pub(crate) fn derive_domain_state(input: TokenStream) -> TokenStream { } pub(crate) fn expand_domain_state(input: DeriveInput) -> syn::Result { + let framework = framework_path()?; let version = parse_domain_state_version(&input)?; let fields = named_fields(&input)?; if !input.generics.params.is_empty() { @@ -43,6 +46,7 @@ pub(crate) fn expand_domain_state(input: DeriveInput) -> syn::Result syn::Result syn::Result syn::Result { let args = parse_enqueue_args.parse2(attr)?; let mut func = syn::parse2::(item)?; - let signature_synthesized = ensure_sourced_result_signature(&mut func.sig, "enqueue")?; + let framework = framework_path()?; + let signature_synthesized = + ensure_sourced_result_signature(&mut func.sig, "enqueue", &framework)?; let emitter_field = &args.emitter_field; let event_name = &args.event_name; diff --git a/distributed_macros/src/entry_tests.rs b/distributed_macros/src/entry_tests.rs index 0eb04899c..43c56aba3 100644 --- a/distributed_macros/src/entry_tests.rs +++ b/distributed_macros/src/entry_tests.rs @@ -206,6 +206,49 @@ mod tests { assert!(output.contains("DomainEventBodyContract < TodoState >")); } + #[test] + fn expand_sourced_transition_exports_unconditional_known_state_values() { + let attr = quote! { + entity, + aggregate_type = "todo", + domain_state = TodoState, + }; + let item = quote! { + impl Todo { + pub fn complete(&mut self) { + self.record_completed().unwrap(); + } + + #[event("todo.completed", version = 1, domain)] + fn record_completed(&mut self) { + self.status = TodoStatus::Completed; + self.assignee_id = None; + if self.audit_enabled { + self.audit_label = "conditional"; + } + } + } + }; + + let output = sourced::expand_sourced(attr, item).unwrap().to_string(); + + assert!( + output.contains("fn command_event_known_values"), + "got: {output}" + ); + assert!( + output.contains("__command_projection_state_known_values"), + "got: {output}" + ); + assert!(output.contains("\"status\""), "got: {output}"); + assert!(output.contains("TodoStatus :: Completed"), "got: {output}"); + assert!(output.contains("\"assignee_id\""), "got: {output}"); + assert!( + !output.contains("\"audit_label\""), + "conditional assignments are not safe preview facts: {output}" + ); + } + #[test] fn expand_sourced_identity_mode_generates_independent_public_descriptor() { let attr = quote! { diff --git a/distributed_macros/src/graphql_types.rs b/distributed_macros/src/graphql_types.rs index 96b1ec564..dc2d50953 100644 --- a/distributed_macros/src/graphql_types.rs +++ b/distributed_macros/src/graphql_types.rs @@ -97,19 +97,23 @@ impl RenameRule { } pub fn expand_graphql_input(input: DeriveInput) -> syn::Result { + let framework = crate::shared::framework_path()?; expand( input, - quote! { distributed::graphql::GraphqlInputType }, - quote! { distributed::graphql::GraphqlInputType }, + quote! { #framework::graphql::GraphqlInputType }, + quote! { #framework::graphql::GraphqlInputType }, + framework, SerdeDirection::Deserialize, ) } pub fn expand_graphql_output(input: DeriveInput) -> syn::Result { + let framework = crate::shared::framework_path()?; expand( input, - quote! { distributed::graphql::GraphqlOutputType }, - quote! { distributed::graphql::GraphqlOutputType }, + quote! { #framework::graphql::GraphqlOutputType }, + quote! { #framework::graphql::GraphqlOutputType }, + framework, SerdeDirection::Serialize, ) } @@ -118,6 +122,7 @@ fn expand( input: DeriveInput, trait_path: TokenStream, nested_trait: TokenStream, + framework: TokenStream, serde_direction: SerdeDirection, ) -> syn::Result { let name = &input.ident; @@ -159,17 +164,17 @@ fn expand( let (type_name, nullable, list, item_nullable, nested) = map_type(&field.ty, field, &nested_trait)?; let effect_path_kind = if !list && nested.is_some() { - quote! { distributed::graphql::EffectInputObjectKind } + quote! { #framework::graphql::EffectInputObjectKind } } else { - quote! { distributed::graphql::EffectInputTerminalKind } + quote! { #framework::graphql::EffectInputTerminalKind } }; - let effect_wire = effect_input_wire_tokens(&type_name, list, nested.is_some()); + let effect_wire = effect_input_wire_tokens(&framework, &type_name, list, nested.is_some()); let nested_tokens = match nested { Some(tokens) => quote! { Some(::std::boxed::Box::new(#tokens)) }, None => quote! { None }, }; field_tokens.push(quote! { - distributed::graphql::GraphqlTypeField { + #framework::graphql::GraphqlTypeField { name: #field_name_str.to_string(), type_name: #type_name.to_string(), nullable: #nullable, @@ -184,16 +189,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! { distributed::graphql::EffectNullable } + quote! { #framework::graphql::EffectNullable } } else { - quote! { distributed::graphql::EffectRequired } + quote! { #framework::graphql::EffectRequired } }; effect_input_markers.push(quote! { #[doc(hidden)] #[allow(non_camel_case_types)] #visibility struct #marker; - impl distributed::graphql::EffectInputFieldMarker for #marker { + impl #framework::graphql::EffectInputFieldMarker for #marker { type Input = #name; type Value = #field_ty; type NonNullValue = #non_null_ty; @@ -212,8 +217,8 @@ fn expand( let type_name_str = name.to_string(); Ok(quote! { impl #trait_path for #name { - fn graphql_type() -> distributed::graphql::GraphqlTypeDef { - distributed::graphql::GraphqlTypeDef::new( + fn graphql_type() -> #framework::graphql::GraphqlTypeDef { + #framework::graphql::GraphqlTypeDef::new( #type_name_str, vec![#(#field_tokens),*], ).with_type_id(::std::any::TypeId::of::<#name>()) @@ -224,22 +229,27 @@ fn expand( }) } -fn effect_input_wire_tokens(type_name: &str, list: bool, nested: bool) -> proc_macro2::TokenStream { +fn effect_input_wire_tokens( + framework: &TokenStream, + type_name: &str, + list: bool, + nested: bool, +) -> proc_macro2::TokenStream { if list { - return quote! { distributed::graphql::EffectWireList }; + return quote! { #framework::graphql::EffectWireList }; } if nested { - return quote! { distributed::graphql::EffectWireObject }; + return quote! { #framework::graphql::EffectWireObject }; } match type_name { - "String" | "ID" => quote! { distributed::graphql::EffectWireString }, - "Boolean" => quote! { distributed::graphql::EffectWireBoolean }, - "BigInt" | "Int" => quote! { distributed::graphql::EffectWireBigInt }, - "Float" => quote! { distributed::graphql::EffectWireFloat }, - "JSON" => quote! { distributed::graphql::EffectWireJson }, - "Bytea" => quote! { distributed::graphql::EffectWireBytea }, - "Timestamptz" => quote! { distributed::graphql::EffectWireTimestamp }, - _ => quote! { distributed::graphql::EffectWireUnsupported }, + "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 }, } } diff --git a/distributed_macros/src/lib.rs b/distributed_macros/src/lib.rs index 0dc1645d4..23305bd53 100644 --- a/distributed_macros/src/lib.rs +++ b/distributed_macros/src/lib.rs @@ -1,10 +1,13 @@ mod aggregate; +mod application; +mod command; mod command_input_defaults; mod digest; mod domain_event; mod domain_state; mod enqueue; mod graphql_types; +mod module; mod mutation; // Event-owning `projection!` authoring removed (mutation projectors cutover). mod read_model; @@ -15,6 +18,31 @@ mod sourced; use proc_macro::TokenStream; use syn::DeriveInput; +/// Generate one typed command's portable contract and optional executable +/// mount from the same handler declaration. +#[proc_macro_attribute] +pub fn command(attr: TokenStream, item: TokenStream) -> TokenStream { + command::expand(attr.into(), item.into()) + .unwrap_or_else(|error| error.to_compile_error()) + .into() +} + +/// Register an explicit logical module. +#[proc_macro] +pub fn module(input: TokenStream) -> TokenStream { + module::expand(input.into()) + .unwrap_or_else(|error| error.to_compile_error()) + .into() +} + +/// Register an explicit application and its selected modules/surfaces. +#[proc_macro] +pub fn application(input: TokenStream) -> TokenStream { + application::expand(input.into()) + .unwrap_or_else(|error| error.to_compile_error()) + .into() +} + /// Attribute macro that automatically queues a local event for emission. #[proc_macro_attribute] pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { diff --git a/distributed_macros/src/module.rs b/distributed_macros/src/module.rs new file mode 100644 index 000000000..a1df31f3a --- /dev/null +++ b/distributed_macros/src/module.rs @@ -0,0 +1,165 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Expr, Ident, LitStr, Token, Type, Visibility}; + +struct ModuleInput { + visibility: Visibility, + name: Ident, + id: LitStr, + commands: Vec, + projections: Vec, + surfaces: Vec, + capabilities: Vec, +} + +impl Parse for ModuleInput { + fn parse(input: ParseStream<'_>) -> syn::Result { + let visibility: Visibility = input.parse()?; + let name: Ident = input.parse()?; + if input.peek(Token![:]) { + input.parse::()?; + let _: Type = input.parse()?; + } + if input.peek(Token![=]) { + input.parse::()?; + } + let content; + syn::braced!(content in input); + let mut output = Self { + visibility, + name, + id: LitStr::new("", proc_macro2::Span::call_site()), + commands: Vec::new(), + projections: Vec::new(), + surfaces: Vec::new(), + capabilities: Vec::new(), + }; + while !content.is_empty() { + let field: Ident = content.parse()?; + content.parse::()?; + match field.to_string().as_str() { + "id" => output.id = content.parse()?, + "commands" => output.commands = parse_expr_array(&content)?, + "mounts" => { + return Err(syn::Error::new( + field.span(), + "command declarations derive their optional runtime mount; maintain one `commands: [*_DEFINITION]` list and remove `mounts`", + )) + } + "projections" => output.projections = parse_expr_array(&content)?, + "surfaces" => output.surfaces = parse_expr_array(&content)?, + "capabilities" | "required_capabilities" => { + output.capabilities = parse_expr_array(&content)? + } + other => { + return Err(syn::Error::new( + field.span(), + format!("unknown module field `{other}`"), + )) + } + } + if content.peek(Token![,]) { + content.parse::()?; + } + } + if output.id.value().is_empty() { + output.id = LitStr::new( + &output.name.to_string().to_ascii_lowercase(), + output.name.span(), + ); + } + Ok(output) + } +} + +fn parse_expr_array(input: ParseStream<'_>) -> syn::Result> { + let content; + syn::bracketed!(content in input); + Ok(Punctuated::::parse_terminated(&content)? + .into_iter() + .collect()) +} + +pub fn expand(input: proc_macro2::TokenStream) -> syn::Result { + let framework = crate::shared::framework_path()?; + let input = syn::parse2::(input)?; + let ModuleInput { + visibility, + name, + id, + commands, + projections, + surfaces, + capabilities, + } = input; + let accessor = format_ident!("{}", name.to_string().to_lowercase()); + let command_ids = commands + .iter() + .map(command_definition_id) + .collect::>>()?; + let commands = commands.iter().map(|value| quote! { (&*#value).clone() }); + let projections = projections + .iter() + .map(|value| quote! { (&*#value).clone() }); + let surfaces = surfaces.iter().map(|value| quote! { (&*#value).clone() }); + let capabilities = capabilities + .iter() + .map(|value| quote! { #value }) + .collect::>(); + let capability_builder = if capabilities.is_empty() { + quote! {} + } else { + quote! { .required_capabilities([#(#capabilities),*]) } + }; + Ok(quote! { + const _: () = #framework::application::assert_unique_command_ids( + &[#(#command_ids),*] + ); + + #visibility static #name: ::std::sync::LazyLock<#framework::application::Module> = + ::std::sync::LazyLock::new(|| { + #framework::application::Module::builder(#id) + .command_definitions([#(#commands),*]) + .projections([#(#projections),*]) + .surfaces([#(#surfaces),*]) + #capability_builder + .build() + .unwrap_or_else(|error| panic!("invalid generated module: {error}")) + }); + + #visibility fn #accessor() -> &'static #framework::application::Module { + &#name + } + }) +} + +fn command_definition_id(value: &Expr) -> syn::Result { + let Expr::Path(path) = value else { + return Err(syn::Error::new_spanned( + value, + "module command entries must be generated *_DEFINITION paths", + )); + }; + let mut id_path = path.path.clone(); + let Some(last) = id_path.segments.last_mut() else { + return Err(syn::Error::new_spanned( + value, + "module command entries must be generated *_DEFINITION paths", + )); + }; + if !last.ident.to_string().ends_with("_DEFINITION") { + return Err(syn::Error::new_spanned( + value, + "module command entries must use the generated *_DEFINITION value so spec and mount cannot diverge", + )); + } + let span = last.ident.span(); + let name = last.ident.to_string(); + let name = name + .strip_suffix("_DEFINITION") + .expect("checked generated definition suffix"); + last.ident = syn::Ident::new(&format!("{name}_COMMAND_ID"), span); + Ok(id_path) +} diff --git a/distributed_macros/src/shared.rs b/distributed_macros/src/shared.rs index 9c96f32da..b0b8ff0df 100644 --- a/distributed_macros/src/shared.rs +++ b/distributed_macros/src/shared.rs @@ -1,3 +1,4 @@ +use proc_macro_crate::{crate_name, FoundCrate}; use quote::{quote, ToTokens}; use sha2::{Digest, Sha256}; use syn::{ @@ -8,6 +9,28 @@ use syn::{ // Shared helpers // ============================================================================ +/// Resolve the framework crate as it is named by the consuming package. +/// +/// Proc-macro output is compiled in the caller's crate, so a literal +/// `::distributed` path is wrong when the dependency is renamed or re-exported. +/// `FoundCrate::Itself` also keeps framework-internal macro tests hygienic. +pub(crate) fn framework_path() -> syn::Result { + match crate_name("distributed") { + Ok(FoundCrate::Itself) => Ok(quote!(crate)), + Ok(FoundCrate::Name(name)) => { + let ident = syn::Ident::new(&name, proc_macro2::Span::call_site()); + Ok(quote!(::#ident)) + } + Err(error) => Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "unable to resolve the `distributed` dependency for generated code: {}; add the framework dependency or re-export it under that package", + error + ), + )), + } +} + /// Extract parameter names and types from a method signature (excludes `self`). /// /// Every parameter must be a plain identifier: its name is recorded in the @@ -53,10 +76,11 @@ fn returns_result(sig: &syn::Signature) -> bool { pub(crate) fn ensure_sourced_result_signature( sig: &mut syn::Signature, attr_name: &str, + framework: &proc_macro2::TokenStream, ) -> Result { match &sig.output { ReturnType::Default => { - sig.output = syn::parse_quote!(-> distributed::SourcedResult<()>); + sig.output = syn::parse_quote!(-> #framework::SourcedResult<()>); Ok(true) } ReturnType::Type(_, _) if returns_result(sig) => Ok(false), @@ -338,6 +362,7 @@ fn projection_field_metadata_with_rename<'a>( } pub(crate) fn projection_body_metadata_tokens( + framework: &proc_macro2::TokenStream, role: &str, type_name: &str, version: u64, @@ -378,12 +403,12 @@ pub(crate) fn projection_body_metadata_tokens( let present = field.present; let always_present = field.always_present; quote! { - distributed::projection::lower::ProjectionBodyFieldMetadata { + #framework::projection::lower::ProjectionBodyFieldMetadata { rust_name: #rust_name, wire_name: #wire_name, rust_type: #rust_type, portable_type: - distributed::projection::lower::ProjectionPortableType::#portable_kind, + #framework::projection::lower::ProjectionPortableType::#portable_kind, nullable: #nullable, present: #present, always_present: #always_present, @@ -392,7 +417,7 @@ pub(crate) fn projection_body_metadata_tokens( }); Ok(quote! { const PROJECTION_FIELDS: &'static [ - distributed::projection::lower::ProjectionBodyFieldMetadata + #framework::projection::lower::ProjectionBodyFieldMetadata ] = &[#(#entries),*]; const PROJECTION_SCHEMA_FINGERPRINT: &'static str = #fingerprint; }) diff --git a/distributed_macros/src/sourced.rs b/distributed_macros/src/sourced.rs index b0acb840e..41c731db8 100644 --- a/distributed_macros/src/sourced.rs +++ b/distributed_macros/src/sourced.rs @@ -3,7 +3,7 @@ use quote::{format_ident, quote}; use syn::{ parse::{ParseStream, Parser}, visit::Visit, - Expr, FnArg, Ident, ItemImpl, LitStr, Path, Token, Type, + Expr, FnArg, Ident, ItemImpl, LitStr, Member, Path, Stmt, Token, Type, }; use crate::aggregate::{ @@ -12,7 +12,7 @@ use crate::aggregate::{ }; use crate::shared::{ canonical_object_schema, ensure_sourced_result_signature, extract_params_with_types, - generate_digest_call, generate_enqueue_call, projection_body_metadata_tokens, + framework_path, generate_digest_call, generate_enqueue_call, projection_body_metadata_tokens, schema_fingerprint, validate_domain_event_name_literal, wrap_result_body_with_guard_and_postlude, }; @@ -214,6 +214,34 @@ struct EventMethodInfo { event_name: LitStr, method_name: Ident, params: Vec<(Ident, syn::Type)>, + /// Present when this recorder has `domain` and therefore a generated + /// outward domain-event marker type. + command_event: Option, +} + +#[derive(Clone)] +struct DomainCommandEvent { + domain_event_type: Ident, + domain_state: Option, + known_state_values: Vec, +} + +#[derive(Clone)] +struct KnownStateValue { + field: Ident, + source: KnownStateValueSource, +} + +#[derive(Clone)] +enum KnownStateValueSource { + Constant(Expr), + Null, +} + +/// Public aggregate method that may capture one or more domain events. +struct DomainCommandTransition { + method_name: Ident, + events: Vec, } struct DomainExpansion { @@ -314,6 +342,7 @@ fn expand_domain_capture( }; let version = event_version(event_attr.version.as_ref()); let event_name = &event_attr.event_name; + let framework = framework_path()?; match mode { DomainMode::State => { @@ -382,6 +411,7 @@ fn expand_domain_capture( #(#projection_field_definitions),* }))?; let projection_metadata = projection_body_metadata_tokens( + &framework, "domain_event", &body_type_name, version.base10_parse::()?, @@ -630,6 +660,7 @@ fn expand_domain_capture( } pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let framework = framework_path()?; let args = parse_sourced_args.parse2(attr)?; let mut impl_block = syn::parse2::(item)?; @@ -721,8 +752,13 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res if event_attr.domain.is_some() { ensure_domain_body_has_no_early_exit(&method.block)?; } + 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 signature_synthesized = - ensure_sourced_result_signature(&mut method.sig, "event")?; + ensure_sourced_result_signature(&mut method.sig, "event", &framework)?; let params = extract_params_with_types(&method.sig, "event")?; let param_name_refs: Vec<&Ident> = @@ -773,10 +809,24 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res ); method.block = new_body; + let command_event = if event_attr.domain.is_some() { + Some(DomainCommandEvent { + domain_event_type: identity_domain_event_type( + &struct_name, + &event_attr.event_name, + )?, + domain_state: state_domain.then(|| args.domain_state.clone()).flatten(), + known_state_values, + }) + } else { + None + }; + event_methods.push(EventMethodInfo { event_name: event_attr.event_name, method_name: method.sig.ident.clone(), params, + command_event, }); } Ok(None) => { /* not an event method, skip */ } @@ -785,6 +835,9 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res } } + let domain_command_transitions = + discover_domain_command_transitions(&impl_block, &event_methods); + let deletion_identity = if uses_deletion_identity { let identity = format_ident!("{struct_name}DomainIdentity"); let deletion_type_name = format!("DomainDeletion<{identity}>"); @@ -964,6 +1017,9 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res &upcasters_method, ); + let domain_commands_module = + expand_domain_commands_module(&struct_name, &domain_command_transitions); + let expanded = quote! { #impl_block #enum_def @@ -971,6 +1027,7 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res #try_from_impl #(#generated_domain_types)* #deletion_identity + #domain_commands_module #upcaster_wrappers #aggregate_impl }; @@ -978,6 +1035,265 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res Ok(expanded) } +/// Collect only straight-line recorder assignments whose serialized value is +/// independent of command input and current aggregate state. Nested control +/// flow and arbitrary Rust expressions deliberately remain unknown. +fn infer_unconditional_known_state_values(block: &syn::Block) -> Vec { + let mut values = + std::collections::BTreeMap::)>::new(); + for statement in &block.stmts { + let Stmt::Expr(Expr::Assign(assign), _) = statement else { + continue; + }; + let Expr::Field(field) = ungroup_expr(&assign.left) else { + continue; + }; + if !is_self_receiver(&field.base) { + continue; + } + let Member::Named(name) = &field.member else { + continue; + }; + values.insert( + name.to_string(), + (name.clone(), known_state_value_source(&assign.right)), + ); + } + values + .into_values() + .filter_map(|(field, source)| source.map(|source| KnownStateValue { field, source })) + .collect() +} + +fn known_state_value_source(expression: &Expr) -> Option { + let expression = ungroup_expr(expression); + match expression { + Expr::Path(path) if path.path.is_ident("None") => Some(KnownStateValueSource::Null), + // Qualified paths cover enum variants and associated constants without + // treating a local variable as a compile-time value. + Expr::Path(path) if path.path.segments.len() >= 2 => { + Some(KnownStateValueSource::Constant(expression.clone())) + } + Expr::Lit(literal) + if matches!( + literal.lit, + syn::Lit::Bool(_) | syn::Lit::Str(_) | syn::Lit::Char(_) + ) => + { + Some(KnownStateValueSource::Constant(expression.clone())) + } + Expr::Call(call) + if call.args.len() == 1 + && matches!( + ungroup_expr(&call.func), + Expr::Path(path) if path.path.is_ident("Some") + ) + && known_state_value_source(call.args.first().expect("one Some argument")) + .is_some() => + { + Some(KnownStateValueSource::Constant(expression.clone())) + } + _ => None, + } +} + +fn ungroup_expr(expression: &Expr) -> &Expr { + match expression { + Expr::Paren(paren) => ungroup_expr(&paren.expr), + Expr::Group(group) => ungroup_expr(&group.expr), + other => other, + } +} + +fn discover_domain_command_transitions( + impl_block: &ItemImpl, + event_methods: &[EventMethodInfo], +) -> Vec { + let recorders: std::collections::BTreeMap = event_methods + .iter() + .filter_map(|event| { + event + .command_event + .clone() + .map(|command_event| (event.method_name.to_string(), command_event)) + }) + .collect(); + if recorders.is_empty() { + return Vec::new(); + } + + let mut transitions = Vec::new(); + for item in &impl_block.items { + let syn::ImplItem::Fn(method) = item else { + continue; + }; + // Domain recorders themselves are not command transitions. + if recorders.contains_key(&method.sig.ident.to_string()) { + continue; + } + if !matches!(method.vis, syn::Visibility::Public(_)) { + continue; + } + + let mut finder = DomainEventCallFinder { + recorders: &recorders, + found: std::collections::BTreeMap::new(), + }; + finder.visit_block(&method.block); + if finder.found.is_empty() { + continue; + } + transitions.push(DomainCommandTransition { + method_name: method.sig.ident.clone(), + events: finder.found.into_values().collect(), + }); + } + transitions +} + +struct DomainEventCallFinder<'a> { + recorders: &'a std::collections::BTreeMap, + found: std::collections::BTreeMap, +} + +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()) { + self.found + .entry(command_event.domain_event_type.to_string()) + .or_insert_with(|| command_event.clone()); + } + } + syn::visit::visit_expr_method_call(self, node); + } +} + +fn is_self_receiver(expression: &Expr) -> bool { + match expression { + Expr::Path(path) => path.path.is_ident("self"), + Expr::Paren(paren) => is_self_receiver(&paren.expr), + Expr::Group(group) => is_self_receiver(&group.expr), + _ => false, + } +} + +fn method_name_to_type_ident(method_name: &Ident) -> Ident { + let pascal = method_name + .to_string() + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect::(); + format_ident!("{}", pascal) +} + +fn expand_domain_commands_module( + aggregate: &Ident, + transitions: &[DomainCommandTransition], +) -> TokenStream2 { + if transitions.is_empty() { + return TokenStream2::new(); + } + + let transition_items = transitions.iter().map(|transition| { + let type_name = method_name_to_type_ident(&transition.method_name); + let method_name = transition.method_name.to_string(); + let aggregate_name = aggregate.to_string(); + let event_types = transition + .events + .iter() + .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() { + return None; + } + let event_type = &event.domain_event_type; + let fields = event.known_state_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) + }, + KnownStateValueSource::Null => quote! { + distributed::graphql::CommandProjectionPreviewSource::Null + }, + }; + quote! { (#field, #source) } + }); + Some(quote! { + distributed::graphql::__command_projection_state_known_values::< + super::#event_type, + #state, + >(vec![#(#fields),*]) + }) + }); + let has_known_values = transition + .events + .iter() + .any(|event| event.domain_state.is_some() && !event.known_state_values.is_empty()); + let known_values_method = has_known_values.then(|| { + quote! { + fn command_event_known_values( + ) -> Vec { + #[allow(unused_imports)] + use super::*; + vec![#(#known_value_items),*] + } + } + }); + let doc = format!( + "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`]." + ); + 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([ + #( + distributed::graphql::__command_projection_event_descriptor::< + super::#event_types, + >() + ),* + ]) + } + + #known_values_method + } + } + }); + + let aggregate_name = aggregate.to_string(); + let module_doc = format!( + "Domain command transitions for `{aggregate_name}`.\n\n\ + Each type is a zero-sized witness for the outward domain events a public \ + aggregate method may capture. Prefer \ + `typed_command(...).emits_events::()` over a \ + hand-duplicated `events![...]` list when the domain method already owns \ + the transition." + ); + + quote! { + #[doc = #module_doc] + pub mod domain_commands { + #(#transition_items)* + } + } +} + // ============================================================================ // #[derive(ReadModel)] derive macro // ============================================================================ diff --git a/distributed_macros/tests/application.rs b/distributed_macros/tests/application.rs new file mode 100644 index 000000000..3fb9f1146 --- /dev/null +++ b/distributed_macros/tests/application.rs @@ -0,0 +1,124 @@ +#![allow(unexpected_cfgs)] +#![allow(dead_code)] +#![allow(unused_imports)] + +use distributed::graphql::Succeeded; +use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::{Aggregate, DomainEvent, Entity, EventRecord, GraphqlInput, GraphqlOutput}; +use serde::{Deserialize, Serialize}; + +#[derive(Default)] +pub struct FixtureAggregate { + entity: Entity, +} + +impl Aggregate for FixtureAggregate { + type ReplayError = String; + + fn aggregate_type() -> &'static str { + "application-macro-fixture" + } + + 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(()) + } +} + +#[derive(Clone, Deserialize, GraphqlInput)] +pub struct CreateInput { + id: String, + title: String, +} + +#[derive(Clone, Serialize, GraphqlOutput)] +pub struct CreateOutput { + id: String, +} + +#[derive(Clone, Serialize, DomainEvent)] +#[domain_event(name = "todo.created", version = 1)] +pub struct TodoCreated { + id: String, +} + +#[distributed::command( + id = "todo.create", + roles(user, admin), + emits(TodoCreated), + applies(distributed::event_preview! { + TodoCreated => TodoCreated { + id: input.id, + ..unknown + } + }), + default(title = uuid_v7), + input = CreateInput, + outcome = Succeeded +)] +pub async fn handle( + _context: &CausalCommandContext<'_, FixtureAggregate>, + _input: CreateInput, +) -> Result>, HandlerError> { + unimplemented!() +} + +distributed::module! { + pub TODO_MODULE { + id: "todo", + commands: [HANDLE_DEFINITION], + capabilities: ["events"], + } +} + +distributed::application! { + pub TODO_APPLICATION { + id: "todo-app", + modules: [TODO_MODULE], + capabilities: ["identity"], + } +} + +distributed::application! { + pub IMPLICIT_APPLICATION { + modules: [TODO_MODULE], + } +} + +#[test] +fn command_module_and_application_macros_share_one_portable_spec() { + let spec = handle_spec().expect("generated command 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.effects.is_null()); + assert!(!spec.fingerprint.is_empty()); + assert_eq!( + spec.canonical_bytes().unwrap(), + spec.canonical_bytes().unwrap() + ); + + assert_eq!(TODO_MODULE.manifest().commands[0], spec); + assert_eq!(TODO_APPLICATION.manifest().modules[0].id, "todo"); + assert_eq!(TODO_APPLICATION.manifest().name, "todo-app"); + assert_eq!( + TODO_APPLICATION.manifest().required_capabilities, + ["events", "identity"] + ); + assert_eq!(IMPLICIT_APPLICATION.manifest().name, "implicit_application"); + + #[cfg(feature = "application-runtime")] + { + assert_eq!(HANDLE_MOUNT.spec().id, "todo.create"); + assert_eq!(HANDLE_MOUNT.spec().fingerprint, spec.fingerprint); + } +} 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 new file mode 100644 index 000000000..2cdff5929 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.rs @@ -0,0 +1,20 @@ +struct MismatchAggregate; +struct ExpectedInput; +struct ActualInput; + +#[distributed::command( + id = "todo.mismatch", + input = ExpectedInput, + outcome = distributed::graphql::Succeeded +)] +async fn declared_type_mismatch( + _context: &distributed::microsvc::CausalCommandContext<'_, MismatchAggregate>, + _input: ActualInput, +) -> Result< + distributed::graphql::PreparedCommand>, + distributed::microsvc::HandlerError, +> { + unreachable!() +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr new file mode 100644 index 000000000..5b581fe76 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr @@ -0,0 +1,5 @@ +error: handler input parameter does not match the declared `input = ...` type + --> tests/compile_fail/application_command_declared_type_mismatch.rs:12:13 + | +12 | _input: ActualInput, + | ^^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs b/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs new file mode 100644 index 000000000..38c11ade6 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs @@ -0,0 +1,45 @@ +use std::sync::LazyLock; + +use distributed::application::{CommandDefinition, CommandSpec, CommandTypeField, CommandTypeSpec}; +use distributed::graphql::CommandConsistency; + +fn spec() -> CommandSpec { + CommandSpec::try_new( + "todo.duplicate", + "todo_duplicate", + CommandTypeSpec { + name: "DuplicateInput".into(), + fields: vec![CommandTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + CommandTypeSpec { + name: "DuplicateOutput".into(), + fields: Vec::new(), + }, + CommandConsistency::Eventual, + ) + .unwrap() +} + +static FIRST_DEFINITION: LazyLock = + LazyLock::new(|| CommandDefinition::contract(spec())); +static SECOND_DEFINITION: LazyLock = + LazyLock::new(|| CommandDefinition::contract(spec())); + +const FIRST_COMMAND_ID: &str = "todo.duplicate"; +const SECOND_COMMAND_ID: &str = "todo.duplicate"; + +distributed::module! { + pub DUPLICATE_MODULE { + id: "duplicates", + commands: [FIRST_DEFINITION, SECOND_DEFINITION], + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_id.stderr b/distributed_macros/tests/compile_fail/application_command_duplicate_id.stderr new file mode 100644 index 000000000..5377eae50 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_id.stderr @@ -0,0 +1,21 @@ +error[E0080]: evaluation panicked: duplicate command identity in module declaration + --> tests/compile_fail/application_command_duplicate_id.rs:38:1 + | +38 | / distributed::module! { +39 | | pub DUPLICATE_MODULE { +40 | | id: "duplicates", +41 | | commands: [FIRST_DEFINITION, SECOND_DEFINITION], +42 | | } +43 | | } + | |_^ evaluation of `_` failed inside this call + | +note: inside `assert_unique_command_ids` + --> $RUST/core/src/panic.rs + | + | $crate::panicking::panic_fmt($crate::const_format_args!($($t)+)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here + | + ::: $WORKSPACE/src/application/mod.rs + | + | panic!("duplicate command identity in module declaration"); + | ---------------------------------------------------------- in this macro invocation diff --git a/distributed_macros/tests/compile_fail/application_command_missing_id.rs b/distributed_macros/tests/compile_fail/application_command_missing_id.rs new file mode 100644 index 000000000..79e63dbce --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_missing_id.rs @@ -0,0 +1,4 @@ +#[distributed::command] +async fn missing_id(_context: (), _input: ()) {} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_missing_id.stderr b/distributed_macros/tests/compile_fail/application_command_missing_id.stderr new file mode 100644 index 000000000..68126840f --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_missing_id.stderr @@ -0,0 +1,5 @@ +error: command declaration requires `id = "..."` + --> tests/compile_fail/application_command_missing_id.rs:2:10 + | +2 | async fn missing_id(_context: (), _input: ()) {} + | ^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs b/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs new file mode 100644 index 000000000..330615c9d --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs @@ -0,0 +1,16 @@ +struct WrongAggregate; +struct WrongInput; + +#[distributed::command( + id = "todo.create", + input = WrongInput, + outcome = distributed::graphql::Succeeded +)] +async fn wrong_handler( + _context: &distributed::microsvc::CausalCommandContext<'_, WrongAggregate>, + _input: WrongInput, +) -> Result<(), distributed::microsvc::HandlerError> { + Ok(()) +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr b/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr new file mode 100644 index 000000000..7b77905ca --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr @@ -0,0 +1,5 @@ +error: first Result type must be PreparedCommand + --> tests/compile_fail/application_command_wrong_handler.rs:12:13 + | +12 | ) -> Result<(), distributed::microsvc::HandlerError> { + | ^^ diff --git a/distributed_macros/tests/compile_fail/application_module_missing_spec.rs b/distributed_macros/tests/compile_fail/application_module_missing_spec.rs new file mode 100644 index 000000000..2f01bf299 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_module_missing_spec.rs @@ -0,0 +1,9 @@ +distributed::module! { + pub TODO_MODULE { + id: "todo", + commands: [], + mounts: [MISSING_COMMAND_MOUNT], + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_module_missing_spec.stderr b/distributed_macros/tests/compile_fail/application_module_missing_spec.stderr new file mode 100644 index 000000000..d6945e08c --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_module_missing_spec.stderr @@ -0,0 +1,5 @@ +error: command declarations derive their optional runtime mount; maintain one `commands: [*_DEFINITION]` list and remove `mounts` + --> tests/compile_fail/application_module_missing_spec.rs:5:9 + | +5 | mounts: [MISSING_COMMAND_MOUNT], + | ^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_module_unknown_field.rs b/distributed_macros/tests/compile_fail/application_module_unknown_field.rs new file mode 100644 index 000000000..58750c207 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_module_unknown_field.rs @@ -0,0 +1,8 @@ +distributed::module! { + pub TODO_MODULE { + id: "todo", + unknown: [], + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_module_unknown_field.stderr b/distributed_macros/tests/compile_fail/application_module_unknown_field.stderr new file mode 100644 index 000000000..8557ec0c3 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_module_unknown_field.stderr @@ -0,0 +1,5 @@ +error: unknown module field `unknown` + --> tests/compile_fail/application_module_unknown_field.rs:4:9 + | +4 | unknown: [], + | ^^^^^^^ diff --git a/docs/application-composition.md b/docs/application-composition.md index b615893a5..9b316caac 100644 --- a/docs/application-composition.md +++ b/docs/application-composition.md @@ -12,7 +12,7 @@ Related pain today: `tests/e2e-ui/crates/service/src/service.rs` (~projection ca | Plane | Contents | Changes when… | |--------|----------|----------------| -| **Logical** | Domains, command defs (`.emits` / `.applies`), projections, client surfaces | Product features | +| **Logical** | Domains, command transitions, projections, client surfaces | Product features | | **Runtime** | Event store, read models, locks, bus, outbox/consumer, GraphQL/HTTP, identity | Deploy cut & dialect | Microservices re-cut **logical mounts** and **process role**. @@ -27,7 +27,7 @@ Dialect (SQLite ↔ Postgres) re-cuts **runtime only**. Framework must **reject** Atomic command mounts on projector-only or query-only processes. -Client optimism is always the same path (`.applies` → optimistic layer → seal). Eventual vs Atomic only changes **where the server applies IR** and **how the client proves the seal**. +Client optimism is always the same path (transition event → role-visible mutation IR → optimistic layer → seal). Eventual vs Atomic only changes **where the server applies IR** and **how the client proves the seal**. --- @@ -38,7 +38,7 @@ Client optimism is always the same path (`.applies` → optimistic layer → sea ```text domains/ aggregates + events projections/ descriptors + mutation IR -commands/ CommandDef (emits, applies, handler) — not bound to one process +commands/ command transition + handler — not bound to one process readmodels/ schemas + grants ``` @@ -63,17 +63,21 @@ Product intent lives next to the handler; `service`/app only **registers**: ```rust // handlers/todo_complete.rs (sketch) pub fn def() -> CommandDef { - command::>(COMMAND) + command_transition::< + domain_commands::Complete, + TodoCompleteInput, + Eventual, + >(COMMAND) .field_name("todos_complete") // or derive from COMMAND .roles(["user", "admin"]) // or route-group default - .emits(events![TodoCompletedDomainEvent]) - .applies(state_preview! { /* optimism contract */ }) .handle(handle) } ``` -Do **not** auto-invent `state_preview` — that is the optimism contract and stays explicit. -Do hide: topology digests, partition codec version, source binding, catalog activation ceremony. +The generated transition supplies its event set and statically known recorder +values. The projection owns the event→mutation mapping; unknown values stay +unknown and force revalidation. Do hide: topology digests, partition codec +version, source binding, catalog activation ceremony. ### SystemSlice (microservices re-cut) @@ -179,13 +183,14 @@ Rough size: **~100–150 lines of product wiring** for logical app + a tiny depl ## Implementation order 1. **Projection mount / inventory** — hide catalog, topology digests, activation. -2. **CommandDef + `.register()`** — co-locate emits/applies/handler with command modules. +2. **CommandDef + `.register()`** — co-locate transitions/handlers with command modules. 3. **Runtime::{sqlite, postgres, in_memory}** — pair repo + locks + bus. 4. **ProcessRole** — Full / CommandWriter / EventualProjector / QueryApi spawn policy. 5. **Atomic mount checks** — fail closed if Atomic is mounted without write path. 6. **Collapse e2e-ui `service.rs` + `runner`** onto the new APIs (no dual/legacy surface). -No back-compat shims: one name per concept (e.g. `.applies` only, not `.preview`). +No second application-owned optimism map: projection mutation IR is the source +of cache effects. --- @@ -202,6 +207,6 @@ No back-compat shims: one name per concept (e.g. `.applies` only, not `.preview` ## Non-goals -- Hiding `.emits` / `.applies` (optimism contract stays visible). +- Hiding the transition or projection that defines optimistic behavior. - Pretending remote Atomic projectors exist. - Second “simple” API that still requires the full catalog dance in user code. diff --git a/examples/graphiql.rs b/examples/graphiql.rs index 024c67fbc..ac4bd1cc6 100644 --- a/examples/graphiql.rs +++ b/examples/graphiql.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use distributed::graphql::GraphqlEngine; use distributed::microsvc::{serve, Service}; use distributed::{ - ColumnType, DistributedProjectManifest, PrimaryKey, TableColumn, TableKind, TableSchema, + ColumnType, ReadModelCatalog, PrimaryKey, TableColumn, TableKind, TableSchema, }; use sqlx::sqlite::SqlitePoolOptions; @@ -76,9 +76,9 @@ async fn seed_pool() -> sqlx::SqlitePool { async fn main() -> Result<(), Box> { let addr = std::env::var("GRAPHIQL_ADDR").unwrap_or_else(|_| "127.0.0.1:4000".into()); let pool = seed_pool().await; - let manifest = DistributedProjectManifest::new("graphiql-demo").table_schema(orders_schema()); + let manifest = ReadModelCatalog::new("graphiql-demo").table_schema(orders_schema()); - let engine = GraphqlEngine::from_manifest(&manifest, pool)? + let engine = GraphqlEngine::from_schema_catalog(&manifest, pool)? .roles(&["user", "anonymous"]) .grant_all("user") .graphiql(true) diff --git a/js/README.md b/js/README.md index fa131b164..6fd004621 100644 --- a/js/README.md +++ b/js/README.md @@ -4,7 +4,7 @@ The generated, end-to-end typed client for [Distributed](https://github.com/hops-ops/distributed) services. Rust table, relationship, role, and command definitions produce one authorized -client surface. `dctl client` combines that surface with application GraphQL +client surface. `distributed client` combines that surface with application GraphQL documents and emits typed operations, live companions, route-load plans, and commands. This package executes those artifacts through one normalized, causally consistent browser replica. @@ -37,9 +37,9 @@ by the `/sveltekit` and `/react` entry points, respectively. The service, not the browser, owns authorization and GraphQL semantics: ```bash -dctl client-manifest > target/distributed-client.json +distributed client-manifest > target/distributed-client.json -dctl client \ +distributed client \ --manifest target/distributed-client.json \ --role user \ --documents 'src/**/*.graphql' \ @@ -128,7 +128,7 @@ keeps ordinary application documents out of the admin tree; each trust boundary has its own Rust manifest entrypoint, generated directory, virtual module, and request-local replica. A single-surface application can omit the second entry. -The Vite integration runs `dctl client` at startup/build, watches GraphQL +The Vite integration runs `distributed client` at startup/build, watches GraphQL documents, stages all surfaces, commits a rollback-capable multi-output transaction, then triggers one reload. It exposes the generated Svelte wrapper through the configured virtual module: @@ -440,7 +440,7 @@ command pipeline, or package-owned codegen executable. To move an existing pilot application: -1. rerun `dctl client` and import its operation/command artifacts; +1. rerun `distributed client` and import its operation/command artifacts; 2. compose one replica through the framework adapter or core transport; 3. remove handwritten cache targets, merge/update callbacks, and invalidation policies; diff --git a/js/scripts/pack-smoke.mjs b/js/scripts/pack-smoke.mjs index 720f77ecf..71b9c8454 100644 --- a/js/scripts/pack-smoke.mjs +++ b/js/scripts/pack-smoke.mjs @@ -430,6 +430,7 @@ assert.deepEqual(Object.keys(replicaSurface).sort(), [ 'createReplicaGraphqlTransport', 'createReplicaIndexMaintenanceRegistry', 'createReplicaIndexedDbPersistence', + 'createWasmJsonPure', 'decideReplicaPaginationMaintenance', 'evaluateReplicaFilter', 'formatReplicaIndexStaleReason', diff --git a/js/src/replica/command-runtime.ts b/js/src/replica/command-runtime.ts index b2ead73da..64998cfb6 100644 --- a/js/src/replica/command-runtime.ts +++ b/js/src/replica/command-runtime.ts @@ -7,6 +7,7 @@ export { replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, replicaCommandProjectedLifecycleOf, + replicaCommandReadRecord, replicaResultObservation } from './command-runtime/index.js'; export type { diff --git a/js/src/replica/command-runtime/create.ts b/js/src/replica/command-runtime/create.ts index f8b7f23e7..517276bd8 100644 --- a/js/src/replica/command-runtime/create.ts +++ b/js/src/replica/command-runtime/create.ts @@ -49,6 +49,7 @@ import { replicaCommandAuthority, replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, + replicaCommandReadRecord, replicaResultObservation } from './symbols.js'; import type { @@ -1157,10 +1158,18 @@ export function createReplicaCommandRuntime< ); } try { + const pureHost = { + readRecord: replica[replicaCommandReadRecord]?.bind(replica), + pureFunctions: options.pureFunctions + }; (replica as SemanticReplica).createOptimisticLayer( prepared.commandId, (writer) => - applyOptimisticEffects(writer, prepared.optimistic.operations), + applyOptimisticEffects( + writer, + prepared.optimistic.operations, + pureHost + ), semanticChanges ); } catch (error) { @@ -1370,8 +1379,9 @@ export function createReplicaCommandRuntime< /* * Ship contract: Atomic seals from the atomic GraphQL row + * direct `records` (confirmDirectProjection). Eventual applies - * projection-delta when present. Same portable IR for `.applies` - * previews either way — different response proof by design. + * projection-delta when present. Both strategies start from the same + * compiler-derived event→mutation preview — different response proof by + * design. */ let actualRequiresRevalidation = false; if (prepared.consistency !== 'atomic') { diff --git a/js/src/replica/command-runtime/index.ts b/js/src/replica/command-runtime/index.ts index 38333ff61..88bb07fbd 100644 --- a/js/src/replica/command-runtime/index.ts +++ b/js/src/replica/command-runtime/index.ts @@ -3,6 +3,7 @@ export { replicaCommandDirectProjection, replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, + replicaCommandReadRecord, replicaResultObservation } from './symbols.js'; export type { diff --git a/js/src/replica/command-runtime/lib/effects.ts b/js/src/replica/command-runtime/lib/effects.ts index b4304792b..6c745bb4a 100644 --- a/js/src/replica/command-runtime/lib/effects.ts +++ b/js/src/replica/command-runtime/lib/effects.ts @@ -3,21 +3,38 @@ import { } from '../../commands.js'; import type { PreparedProjectionOperation, - PreparedProjectionScope + PreparedProjectionScope, + ReplicaPureFunction } from '../../projection-delta/index.js'; import { replicaRecordKey } from '../../identity.js'; import type { ReplicaIndexSemanticChange } from '../../index-maintenance.js'; import type { + ReplicaIdentity, ReplicaModelArtifact, ReplicaOptimisticWriter, ReplicaValue } from '../../types.js'; +export type PureReduceHost = Readonly<{ + /** Read live cache fields for a known record (pre-layer). Fail-closed on miss. */ + readRecord?: ( + model: ReplicaModelArtifact, + identity: ReplicaIdentity + ) => Readonly> | undefined; + pureFunctions?: Readonly>; +}>; + +/** + * Expand pure reducers against known cache rows, then apply ordinary ops. + * Missing row / unknown pure / pure null → skip that reduce (no invent). + */ export function applyOptimisticEffects( writer: ReplicaOptimisticWriter, - effects: readonly PreparedProjectionOperation[] + effects: readonly PreparedProjectionOperation[], + host: PureReduceHost = {} ): void { - for (const effect of effects) { + const expanded = expandReduceKnownRecord(effects, host); + for (const effect of expanded) { switch (effect.kind) { case 'upsert': case 'patch': { @@ -44,13 +61,71 @@ export function applyOptimisticEffects( case 'unlink': case 'invalidate_model': case 'invalidate_relationship': + case 'reduce_known_record': // Task 8 consumes the exact semantic context. Guessing a to-one // record link for a to-many relationship would corrupt truth. + // reduce_known_record is expanded above. break; } } } +function expandReduceKnownRecord( + effects: readonly PreparedProjectionOperation[], + host: PureReduceHost +): readonly PreparedProjectionOperation[] { + const out: PreparedProjectionOperation[] = []; + for (const effect of effects) { + if (effect.kind !== 'reduce_known_record') { + out.push(effect); + continue; + } + const pure = host.pureFunctions?.[effect.fn]; + const read = host.readRecord; + if (pure === undefined || read === undefined) { + continue; + } + const model = modelFromKey(effect.scope); + const identity = identityFromKey(effect.scope); + const current = read(model, identity); + if (current === undefined) { + continue; + } + let next: Readonly> | null; + try { + next = pure(current, effect.args); + } catch { + continue; + } + if (next === null) { + continue; + } + const fields: Record = Object.create(null) as Record< + string, + ReplicaValue + >; + for (const field of effect.assign) { + if (!Object.prototype.hasOwnProperty.call(next, field)) { + continue; + } + fields[field] = next[field] as ReplicaValue; + } + if (Object.keys(fields).length === 0) { + continue; + } + out.push( + Object.freeze({ + kind: 'patch' as const, + scope: effect.scope, + fields: Object.freeze(fields), + unset: Object.freeze([]) as readonly string[], + ifPresent: true as const + }) + ); + } + return Object.freeze(out); +} + export function preparedSemanticChanges( prepared: ReplicaPreparedCommand ): readonly ReplicaIndexSemanticChange[] { @@ -61,9 +136,10 @@ export function preparedSemanticChanges( case 'upsert': case 'patch': case 'delete': + case 'reduce_known_record': // DistributedReplica captures ordinary writer mutations into the // same layer context. Supplying them again would double-apply the - // semantic record operation. + // semantic record operation. Pure reduce expands to patch first. break; case 'link': case 'unlink': { @@ -126,6 +202,7 @@ export function preparedDispatchKeys( case 'upsert': case 'patch': case 'delete': + case 'reduce_known_record': addScope(effect.scope); break; case 'link': diff --git a/js/src/replica/command-runtime/lib/projection.ts b/js/src/replica/command-runtime/lib/projection.ts index 2de051984..376b2fd19 100644 --- a/js/src/replica/command-runtime/lib/projection.ts +++ b/js/src/replica/command-runtime/lib/projection.ts @@ -72,12 +72,19 @@ export function confirmDirectProjection( id: direct.model, identityFields: direct.identityFields }); - const fields = cloneOutputJson( + const outputFields = cloneOutputJson( output, 'projected.output', new Set(), 0 ) as Readonly>; + const fields = Object.freeze({ + ...outputFields, + // Query normalization always supplies the generated model identity. Atomic + // command rows must be canonicalized the same way or a new record remains + // incomplete until a later query/reload contributes `__typename`. + __typename: direct.model + }) as Readonly>; const confirmProtocolProjection = replica[replicaCommandDirectProjection]; if (confirmProtocolProjection !== undefined) { confirmProtocolProjection.call(replica, prepared.commandId, { diff --git a/js/src/replica/command-runtime/lib/util.ts b/js/src/replica/command-runtime/lib/util.ts index ecc46d051..cb2ce31ef 100644 --- a/js/src/replica/command-runtime/lib/util.ts +++ b/js/src/replica/command-runtime/lib/util.ts @@ -54,8 +54,14 @@ export function sameSurface( return ( left.kind === 'role' || (right.kind === 'application' && - left.roles.length === right.roles.length && - left.roles.every((role, index) => role === right.roles[index])) + left.eligible_roles.length === right.eligible_roles.length && + left.eligible_roles.every( + (role, index) => role === right.eligible_roles[index] + ) && + left.schema_roles.length === right.schema_roles.length && + left.schema_roles.every( + (role, index) => role === right.schema_roles[index] + )) ); } @@ -65,7 +71,8 @@ export function cloneSurface(surface: ReplicaClientSurface): ReplicaClientSurfac : Object.freeze({ kind: 'application', name: surface.name, - roles: Object.freeze([...surface.roles]) + eligible_roles: Object.freeze([...surface.eligible_roles]), + schema_roles: Object.freeze([...surface.schema_roles]) }); } diff --git a/js/src/replica/command-runtime/symbols.ts b/js/src/replica/command-runtime/symbols.ts index ed2d8c2b9..f3bbee6c9 100644 --- a/js/src/replica/command-runtime/symbols.ts +++ b/js/src/replica/command-runtime/symbols.ts @@ -38,3 +38,12 @@ export const replicaCommandProjectionDelta = Symbol( export const replicaCommandProjectedLifecycle = Symbol( 'distributed.replica.command-projected-lifecycle' ); +/** + * Package-private live-record field read for pure reduce optimism. + * Returns undefined when the record is not present (fail closed). + * + * @internal + */ +export const replicaCommandReadRecord = Symbol( + 'distributed.replica.command-read-record' +); diff --git a/js/src/replica/command-runtime/types.ts b/js/src/replica/command-runtime/types.ts index dd7533693..bafe5993e 100644 --- a/js/src/replica/command-runtime/types.ts +++ b/js/src/replica/command-runtime/types.ts @@ -23,11 +23,13 @@ import type { ReplicaResultEnvelope, ReplicaValue } from '../types.js'; +import type { ReplicaPureFunction } from '../projection-delta/index.js'; import { replicaCommandAuthority, replicaCommandDirectProjection, replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, + replicaCommandReadRecord, replicaResultObservation } from './symbols.js'; @@ -79,6 +81,10 @@ export type ReplicaCommandAuthorityHost = DistributedReplica & { update: (writer: ReplicaOptimisticWriter) => void, semanticChanges: readonly ReplicaIndexSemanticChange[] ) => boolean; + readonly [replicaCommandReadRecord]?: ( + model: ReplicaModelArtifact, + identity: ReplicaIdentity + ) => Readonly> | undefined; }; export type ReplicaCommandTransportRequest = Readonly<{ @@ -314,6 +320,11 @@ export type ReplicaCommandRuntimeOptions = Readonly<{ status?: ReplicaCommandStatusArtifact; /** Optional shared replica diagnostics sink used only for static artifact inspection. */ diagnostics?: ReplicaDiagnosticsSink; + /** + * Named pure functions referenced by `artifact.projection.pureReduces`. + * Generated clients ship their inventory; apps do not invent board sims ad hoc. + */ + pureFunctions?: Readonly>; }>; export interface ReplicaCommandRuntime< diff --git a/js/src/replica/commands/clone.ts b/js/src/replica/commands/clone.ts index 1d063674e..f6d1eb916 100644 --- a/js/src/replica/commands/clone.ts +++ b/js/src/replica/commands/clone.ts @@ -31,7 +31,8 @@ export function cloneClientSurface(surface: ReplicaClientSurface): ReplicaClient : Object.freeze({ kind: 'application' as const, name: surface.name, - roles: Object.freeze([...surface.roles]) + eligible_roles: Object.freeze([...surface.eligible_roles]), + schema_roles: Object.freeze([...surface.schema_roles]) }); } @@ -316,4 +317,3 @@ export function generateDefault( artifactInvalid(`${path}.generator`); } } - diff --git a/js/src/replica/commands/types.ts b/js/src/replica/commands/types.ts index d1cde4be2..b529bf64c 100644 --- a/js/src/replica/commands/types.ts +++ b/js/src/replica/commands/types.ts @@ -156,7 +156,7 @@ export type ReplicaCommandRevalidationPlan = { }; /** - * Framework-neutral executable command descriptor emitted by `dctl client`. + * Framework-neutral executable command descriptor emitted by `distributed client`. * * The compiler has already validated the Rust-owned declaration. Runtime * validation remains fail-closed so a stale or hand-edited artifact cannot diff --git a/js/src/replica/commands/validate.ts b/js/src/replica/commands/validate.ts index f782b8ca3..add433765 100644 --- a/js/src/replica/commands/validate.ts +++ b/js/src/replica/commands/validate.ts @@ -123,16 +123,37 @@ export function validateClientSurface( if (surface.kind === 'role') return; if ( surface.kind !== 'application' || - !Array.isArray(surface.roles) || - surface.roles.length === 0 + !Array.isArray(surface.eligible_roles) || + !Array.isArray(surface.schema_roles) || + surface.eligible_roles.length === 0 || + surface.schema_roles.length === 0 ) { artifactInvalid(path); } - const roles = new Set(); - for (let index = 0; index < surface.roles.length; index += 1) { - const role = requiredString(surface.roles[index], `${path}.roles[${index}]`); - if (roles.has(role)) artifactInvalid(`${path}.roles[${index}]`); - roles.add(role); + const eligibleRoles = new Set(); + for (let index = 0; index < surface.eligible_roles.length; index += 1) { + const role = requiredString( + surface.eligible_roles[index], + `${path}.eligible_roles[${index}]` + ); + if (eligibleRoles.has(role)) artifactInvalid(`${path}.eligible_roles[${index}]`); + if (index > 0 && surface.eligible_roles[index - 1]! >= role) { + artifactInvalid(`${path}.eligible_roles[${index}]`); + } + eligibleRoles.add(role); + } + const schemaRoles = new Set(); + for (let index = 0; index < surface.schema_roles.length; index += 1) { + const role = requiredString( + surface.schema_roles[index], + `${path}.schema_roles[${index}]` + ); + if (schemaRoles.has(role)) artifactInvalid(`${path}.schema_roles[${index}]`); + if (index > 0 && surface.schema_roles[index - 1]! >= role) { + artifactInvalid(`${path}.schema_roles[${index}]`); + } + if (!eligibleRoles.has(role)) artifactInvalid(`${path}.schema_roles[${index}]`); + schemaRoles.add(role); } } diff --git a/js/src/replica/distributed-replica/helpers.ts b/js/src/replica/distributed-replica/helpers.ts index 4f0b58b4e..e67e19ce0 100644 --- a/js/src/replica/distributed-replica/helpers.ts +++ b/js/src/replica/distributed-replica/helpers.ts @@ -501,7 +501,10 @@ export function replicaClientRequestExtensions< : Object.freeze({ kind: 'application' as const, name: protocol.surface.name, - roles: Object.freeze([...protocol.surface.roles]) + eligible_roles: Object.freeze([ + ...protocol.surface.eligible_roles + ]), + schema_roles: Object.freeze([...protocol.surface.schema_roles]) }); return Object.freeze({ extensions: Object.freeze({ @@ -629,17 +632,34 @@ export function validatedSurfaceIdentity( } if ( value.kind !== 'application' || - !Array.isArray(value.roles) || - value.roles.length === 0 || - value.roles.some( + !Array.isArray(value.eligible_roles) || + !Array.isArray(value.schema_roles) || + value.eligible_roles.length === 0 || + value.schema_roles.length === 0 || + value.eligible_roles.some( (role) => typeof role !== 'string' || role.length === 0 ) || - new Set(value.roles).size !== value.roles.length || - [...value.roles].sort().some((role, index) => role !== value.roles[index]) + value.schema_roles.some( + (role) => typeof role !== 'string' || role.length === 0 + ) || + new Set(value.eligible_roles).size !== value.eligible_roles.length || + new Set(value.schema_roles).size !== value.schema_roles.length || + [...value.eligible_roles].sort().some( + (role, index) => role !== value.eligible_roles[index] + ) || + [...value.schema_roles].sort().some( + (role, index) => role !== value.schema_roles[index] + ) || + value.schema_roles.some((role) => !value.eligible_roles.includes(role)) ) { throw new TypeError('replica artifact client surface is invalid'); } - return JSON.stringify(['application', value.name, value.roles]); + return JSON.stringify([ + 'application', + value.name, + value.eligible_roles, + value.schema_roles + ]); } export function snapshotFrom( diff --git a/js/src/replica/distributed-replica/impl.ts b/js/src/replica/distributed-replica/impl.ts index ce03991f7..0291bc99e 100644 --- a/js/src/replica/distributed-replica/impl.ts +++ b/js/src/replica/distributed-replica/impl.ts @@ -34,6 +34,7 @@ import { replicaCommandAuthority, replicaCommandDirectProjection, replicaCommandProjectionDelta, + replicaCommandReadRecord, replicaResultObservation, type ReplicaCommandAuthorityRegistration, type ReplicaCommandAuthoritySnapshot, @@ -84,6 +85,7 @@ import type { ReplicaResultEnvelope, ReplicaSnapshot, ReplicaTransport, + ReplicaValue, ReplicaWatch, ReplicaWriteSource, WatchReplicaOptions @@ -1644,6 +1646,22 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { }); } + /** + * Package-private live fields for pure-reduce optimism. + * @internal + */ + [replicaCommandReadRecord]( + model: ReplicaModelArtifact, + identity: ReplicaIdentity + ): Readonly> | undefined { + const key = replicaRecordKey(model, identity); + return this.#engine.read((reader) => { + const record = reader.record(key); + if (!record) return undefined; + return Object.freeze({ ...record.fields }); + }); + } + inspectIndex(target: ReplicaIndexTarget): ReplicaIndexInspection | undefined { const key = indexKeyFromTarget(target); return this.#engine.read((reader) => { diff --git a/js/src/replica/index.ts b/js/src/replica/index.ts index bb2282c48..4637414eb 100644 --- a/js/src/replica/index.ts +++ b/js/src/replica/index.ts @@ -113,10 +113,14 @@ export type { ReplicaPreparedEffectKey, ReplicaReceiptVerification } from './commands.js'; +export { createWasmJsonPure } from './projection-delta/index.js'; export type { + CreateWasmJsonPureOptions, PreparedCommandProjection, PreparedProjectionOperation, - ReplicaCommandProjection + ReplicaCommandProjection, + WasmJsonModule, + WasmJsonPureHost } from './projection-delta/index.js'; export { compareReplicaOrder, diff --git a/js/src/replica/operation-binding.ts b/js/src/replica/operation-binding.ts index a7ec9f875..243a77c71 100644 --- a/js/src/replica/operation-binding.ts +++ b/js/src/replica/operation-binding.ts @@ -67,17 +67,34 @@ function validateReplicaSurfaceIdentity(value: ReplicaClientSurface): string { } if ( value.kind !== 'application' || - !Array.isArray(value.roles) || - value.roles.length === 0 || - value.roles.some( + !Array.isArray(value.eligible_roles) || + !Array.isArray(value.schema_roles) || + value.eligible_roles.length === 0 || + value.schema_roles.length === 0 || + value.eligible_roles.some( (role) => typeof role !== 'string' || role.length === 0 ) || - new Set(value.roles).size !== value.roles.length || - [...value.roles].sort().some((role, index) => role !== value.roles[index]) + value.schema_roles.some( + (role) => typeof role !== 'string' || role.length === 0 + ) || + new Set(value.eligible_roles).size !== value.eligible_roles.length || + new Set(value.schema_roles).size !== value.schema_roles.length || + [...value.eligible_roles].sort().some( + (role, index) => role !== value.eligible_roles[index] + ) || + [...value.schema_roles].sort().some( + (role, index) => role !== value.schema_roles[index] + ) || + value.schema_roles.some((role) => !value.eligible_roles.includes(role)) ) { throw new TypeError('replica artifact client surface is invalid'); } - return JSON.stringify(['application', value.name, value.roles]); + return JSON.stringify([ + 'application', + value.name, + value.eligible_roles, + value.schema_roles + ]); } function canonicalTrustedPresetDescriptors( diff --git a/js/src/replica/projection-delta/index.ts b/js/src/replica/projection-delta/index.ts index 73021c425..d2280138b 100644 --- a/js/src/replica/projection-delta/index.ts +++ b/js/src/replica/projection-delta/index.ts @@ -12,6 +12,12 @@ export { operationsFromProjectionDelta, prepareCommandProjection } from './resolve.js'; +export { createWasmJsonPure } from './wasm-pure.js'; +export type { + CreateWasmJsonPureOptions, + WasmJsonModule, + WasmJsonPureHost +} from './wasm-pure.js'; export type { AppliedProjectionDelta, CommandProjectionMetadata, @@ -25,7 +31,9 @@ export type { ProjectionDeltaRecoveryTarget, ProjectionDeltaScope, ProjectionDeltaValue, + ProjectionPreviewPureReduce, ProjectionPreviewScope, ProjectionPreviewValue, - ReplicaCommandProjection + ReplicaCommandProjection, + ReplicaPureFunction } from './types.js'; diff --git a/js/src/replica/projection-delta/resolve.ts b/js/src/replica/projection-delta/resolve.ts index 2afec93f3..de9408c60 100644 --- a/js/src/replica/projection-delta/resolve.ts +++ b/js/src/replica/projection-delta/resolve.ts @@ -26,9 +26,12 @@ export function prepareCommandProjection( const preview = contract.preview.operations.map(({ mutation }) => resolvePreviewMutation(mutation, input, presets) ); + const pure = (contract.pureReduces ?? []).map((reduce) => + resolvePureReduce(reduce, input, presets) + ); return Object.freeze({ contract, - preview: Object.freeze(preview), + preview: Object.freeze([...preview, ...pure]), revalidate: contract.preview.recoveries.length !== 0 }); } catch { @@ -42,6 +45,27 @@ export function prepareCommandProjection( } } +function resolvePureReduce( + reduce: NonNullable[number], + input: unknown, + presets: ReadonlyMap +): PreparedProjectionOperation { + const args: Record = Object.create(null) as Record< + string, + ReplicaValue + >; + for (const { name, value } of reduce.args) { + args[name] = requireValue(resolvePreviewValue(value, input, presets)); + } + return Object.freeze({ + kind: 'reduce_known_record' as const, + fn: reduce.fn, + scope: previewScope(reduce.scope, input, presets), + args: Object.freeze(args), + assign: Object.freeze([...reduce.assign]) + }); +} + export function operationsFromProjectionDelta( scopes: readonly { readonly mutation: diff --git a/js/src/replica/projection-delta/types.ts b/js/src/replica/projection-delta/types.ts index 73a483997..a32a973fc 100644 --- a/js/src/replica/projection-delta/types.ts +++ b/js/src/replica/projection-delta/types.ts @@ -12,8 +12,9 @@ export type ProjectionDeltaSurface = | Readonly<{ kind: 'application'; name: string; - roles: readonly string[]; - }>; + eligible_roles: readonly string[]; + schema_roles: readonly string[]; + }>; export type ProjectionDeltaIdentity = Readonly<{ manifest_version: 2; @@ -242,6 +243,29 @@ export type ProjectionCapabilityArm = Readonly<{ mutations: readonly ProjectionCapabilityMutation[]; }>; +/** + * Pure reduce: load the known cache row for `scope`, run a named pure function + * with resolved `args`, and patch `assign` fields from the result. + * + * Fail-closed: missing row or pure failure → no invent (same as patch ifPresent). + */ +export type ProjectionPreviewPureReduce = Readonly<{ + fn: string; + /** App lib-relative module (gen-client); optional at runtime. */ + clientModule?: string; + /** Named export (gen-client); optional at runtime. */ + clientExport?: string; + scope: ProjectionPreviewScope; + args: readonly Readonly<{ + name: string; + value: ProjectionPreviewValue; + }>[]; + /** Fields taken from the pure result and written onto the known record. */ + assign: readonly string[]; + occurrence_ordinal: number; + projection_refs: readonly number[]; +}>; + export type ProjectionPreviewMutation = | Readonly<{ op: 'upsert'; @@ -326,6 +350,11 @@ export type ReplicaCommandProjection = Readonly<{ target: ProjectionPreviewRecoveryTarget; }>[]; }>; + /** + * Optional pure reducers over known cache rows. Expanded at optimistic + * apply time (needs the live record); not ordinary preview expressions. + */ + pureReduces?: readonly ProjectionPreviewPureReduce[]; fallback: 'revalidate'; }>; @@ -358,6 +387,13 @@ export type PreparedProjectionOperation = kind: 'invalidate_relationship'; relationship: string; source: PreparedProjectionScope; + }> + | Readonly<{ + kind: 'reduce_known_record'; + fn: string; + scope: PreparedProjectionScope; + args: Readonly>; + assign: readonly string[]; }>; export type PreparedProjectionScope = Readonly<{ @@ -365,6 +401,12 @@ export type PreparedProjectionScope = Readonly<{ key: readonly Readonly<{ field: string; value: ReplicaValue }>[]; }>; +/** Named pure function: known record + resolved args → fields to patch. */ +export type ReplicaPureFunction = ( + record: Readonly>, + args: Readonly> +) => Readonly> | null; + export type PreparedCommandProjection = Readonly<{ contract: ReplicaCommandProjection; preview: readonly PreparedProjectionOperation[]; diff --git a/js/src/replica/projection-delta/validate.ts b/js/src/replica/projection-delta/validate.ts index d0d1fbbcf..f65ef52df 100644 --- a/js/src/replica/projection-delta/validate.ts +++ b/js/src/replica/projection-delta/validate.ts @@ -386,14 +386,14 @@ export function validateCommandProjectionArtifact( 'version', 'deltaWireVersion', 'projectionProgramVersion', - 'operationSemanticsVersion', - 'projections', - 'eventSet', - 'capabilities', - 'preview', + 'operationSemanticsVersion', + 'projections', + 'eventSet', + 'capabilities', + 'preview', 'fallback' ], - [], + ['pureReduces'], path ); if ( @@ -677,6 +677,12 @@ export function validateCommandProjectionArtifact( invalid(`${path}.preview.recoveries`); } } + const pureReduces = parsePureReduces( + projection.pureReduces, + occurrences.length, + projections.length, + `${path}.pureReduces` + ); const parsed = Object.freeze({ version: 2 as const, deltaWireVersion: 1 as const, @@ -694,6 +700,7 @@ export function validateCommandProjectionArtifact( operations: Object.freeze(operations), recoveries: Object.freeze(recoveries) }), + ...(pureReduces.length === 0 ? {} : { pureReduces }), fallback: 'revalidate' as const }); if (encoder.encode(JSON.stringify(parsed)).byteLength > MAX_BODY_BYTES) { @@ -702,6 +709,86 @@ export function validateCommandProjectionArtifact( return parsed; } +function parsePureReduces( + value: unknown, + occurrenceCount: number, + projectionCount: number, + path: string +): readonly import('./types.js').ProjectionPreviewPureReduce[] { + if (value === undefined) return Object.freeze([]); + return Object.freeze( + boundedArray(value, path).map((item, index) => { + const itemPath = `${path}[${index}]`; + const reduce = exactRecord( + item, + [ + 'fn', + 'scope', + 'args', + 'assign', + 'occurrence_ordinal', + 'projection_refs' + ], + ['clientModule', 'clientExport'], + itemPath + ); + const fn = reduce.fn; + if (typeof fn !== 'string' || fn.length === 0 || fn.length > 128) { + invalid(`${itemPath}.fn`); + } + const occurrenceOrdinal = boundedOrdinal( + reduce.occurrence_ordinal, + `${itemPath}.occurrence_ordinal` + ); + if (occurrenceOrdinal >= occurrenceCount) invalid(itemPath); + const assign = boundedArray(reduce.assign, `${itemPath}.assign`).map( + (field, fieldIndex) => { + if (typeof field !== 'string' || field.length === 0) { + invalid(`${itemPath}.assign[${fieldIndex}]`); + } + return field as string; + } + ); + if (assign.length === 0) invalid(`${itemPath}.assign`); + const args = boundedArray(reduce.args, `${itemPath}.args`).map( + (arg, argIndex) => { + const argPath = `${itemPath}.args[${argIndex}]`; + const entry = exactRecord(arg, ['name', 'value'], [], argPath); + if (typeof entry.name !== 'string' || entry.name.length === 0) { + invalid(`${argPath}.name`); + } + return Object.freeze({ + name: entry.name as string, + value: parsePreviewValue(entry.value, `${argPath}.value`, 0) + }); + } + ); + const clientModule = + typeof reduce.clientModule === 'string' + ? (reduce.clientModule as string) + : undefined; + const clientExport = + typeof reduce.clientExport === 'string' + ? (reduce.clientExport as string) + : undefined; + return Object.freeze({ + fn: fn as string, + ...(clientModule === undefined ? {} : { clientModule }), + ...(clientExport === undefined ? {} : { clientExport }), + scope: parsePreviewScope(reduce.scope, `${itemPath}.scope`), + args: Object.freeze(args), + assign: Object.freeze(assign), + occurrence_ordinal: occurrenceOrdinal, + projection_refs: projectionRefs( + reduce.projection_refs, + projectionCount, + `${itemPath}.projection_refs` + ) + }); + }) + ); +} + export function validateProjectionMetadataAuthority( metadata: CommandProjectionMetadata, contract: ReplicaCommandProjection, @@ -723,8 +810,12 @@ export function validateProjectionMetadataAuthority( (identity.surface.kind === 'application' && (authority.surface.kind !== 'application' || compareStringArrays( - identity.surface.roles, - authority.surface.roles + identity.surface.eligible_roles, + authority.surface.eligible_roles + ) !== 0 || + compareStringArrays( + identity.surface.schema_roles, + authority.surface.schema_roles ) !== 0)) || identity.schema_fingerprint !== authority.schemaHash || identity.protocol_fingerprint !== authority.protocolHash || @@ -802,23 +893,39 @@ function parseSurface(value: unknown, path: string): ProjectionDeltaSurface { if (value.kind === 'application') { const application = exactRecord( value, - ['kind', 'name', 'roles'], + ['kind', 'name', 'eligible_roles', 'schema_roles'], [], path ); - const roles = boundedArray(application.roles, `${path}.roles`).map( - (role, index) => identityString(role, `${path}.roles[${index}]`) + const eligibleRoles = boundedArray( + application.eligible_roles, + `${path}.eligible_roles` + ).map( + (role, index) => + identityString(role, `${path}.eligible_roles[${index}]`) + ); + const schemaRoles = boundedArray( + application.schema_roles, + `${path}.schema_roles` + ).map((role, index) => + identityString(role, `${path}.schema_roles[${index}]`) ); - if (roles.length === 0) invalid(`${path}.roles`); + if (eligibleRoles.length === 0) invalid(`${path}.eligible_roles`); + if (schemaRoles.length === 0) invalid(`${path}.schema_roles`); assertStrictOrder( - roles, + eligibleRoles, compareUtf8, - `${path}.roles` + `${path}.eligible_roles` ); + assertStrictOrder(schemaRoles, compareUtf8, `${path}.schema_roles`); + if (schemaRoles.some((role) => !eligibleRoles.includes(role))) { + invalid(`${path}.schema_roles`); + } return Object.freeze({ kind: 'application' as const, name: identityString(application.name, `${path}.name`), - roles: Object.freeze(roles) + eligible_roles: Object.freeze(eligibleRoles), + schema_roles: Object.freeze(schemaRoles) }); } invalid(`${path}.kind`); diff --git a/js/src/replica/projection-delta/wasm-pure.ts b/js/src/replica/projection-delta/wasm-pure.ts new file mode 100644 index 000000000..65795f686 --- /dev/null +++ b/js/src/replica/projection-delta/wasm-pure.ts @@ -0,0 +1,109 @@ +/** + * Framework host for pure reduces backed by a wasm-bindgen module. + * + * Domain pures validate record/args and return assign fields (or null). + * This helper only: lazy-load, JSON bridge, fail-closed on any host error. + */ + +import type { ReplicaValue } from '../types.js'; +import type { ReplicaPureFunction } from './types.js'; + +/** Minimal wasm-bindgen module shape used by {@link createWasmJsonPure}. */ +export type WasmJsonModule = { + default: () => Promise; + [exportName: string]: unknown; +}; + +export type CreateWasmJsonPureOptions = Readonly<{ + /** Dynamic import of the wasm-pack module (e.g. `() => import('./pkg/x.js')`). */ + load: () => Promise; + /** + * Named export that accepts `(recordJson: string, argsJson: string)` and + * returns a JSON object string of assign fields, or undefined/null to skip. + */ + exportName: string; + /** When true (default in browsers), start loading immediately. */ + warm?: boolean; +}>; + +export type WasmJsonPureHost = Readonly<{ + /** Resolve when the module is instantiated (no-op on non-browser). */ + ensureReady: () => Promise; + /** Sync pure for `pureFunctions` / pureReduces. */ + pure: ReplicaPureFunction; +}>; + +function isBrowser(): boolean { + return typeof globalThis !== 'undefined' && 'window' in globalThis; +} + +/** + * Build a {@link ReplicaPureFunction} that JSON-roundtrips through a WASM export. + * + * All field validation belongs in the WASM/domain pure. The host never inspects + * individual record/arg keys — missing module or throw → null (fail closed). + */ +export function createWasmJsonPure( + options: CreateWasmJsonPureOptions +): WasmJsonPureHost { + let module: WasmJsonModule | null = null; + let initPromise: Promise | null = null; + + const ensureReady = (): Promise => { + if (!isBrowser()) { + return Promise.resolve(); + } + if (module) { + return Promise.resolve(); + } + if (initPromise) { + return initPromise; + } + initPromise = options + .load() + .then(async (loaded) => { + await loaded.default(); + module = loaded; + }) + .catch((error) => { + initPromise = null; + module = null; + throw error; + }); + return initPromise; + }; + + if (options.warm !== false && isBrowser()) { + void ensureReady().catch(() => { + /* fail closed on first pure call */ + }); + } + + const pure: ReplicaPureFunction = (record, args) => { + if (!module) { + return null; + } + const fn = module[options.exportName]; + if (typeof fn !== 'function') { + return null; + } + try { + const out = (fn as (r: string, a: string) => string | undefined | null)( + JSON.stringify(record), + JSON.stringify(args) + ); + if (out === undefined || out === null || out === '') { + return null; + } + const parsed = JSON.parse(out) as unknown; + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + return Object.freeze(parsed as Readonly>); + } catch { + return null; + } + }; + + return Object.freeze({ ensureReady, pure }); +} diff --git a/js/src/replica/types.ts b/js/src/replica/types.ts index c6b00f8ca..a82b014dc 100644 --- a/js/src/replica/types.ts +++ b/js/src/replica/types.ts @@ -543,7 +543,8 @@ export type ReplicaClientSurface = | { readonly kind: 'application'; readonly name: string; - readonly roles: readonly string[]; + readonly eligible_roles: readonly string[]; + readonly schema_roles: readonly string[]; }; /** Compiler-owned causal artifact. Protocol and variable identity are inseparable. */ diff --git a/js/src/sveltekit/vite.ts b/js/src/sveltekit/vite.ts index f28ca5da1..2fc0fdfca 100644 --- a/js/src/sveltekit/vite.ts +++ b/js/src/sveltekit/vite.ts @@ -69,7 +69,7 @@ export type DistributedSvelteKitManifestSource = | string | Readonly<{ /** - * Arguments passed to the configured dctl command. The first value + * Arguments passed to the configured distributed command. The first value * must be `client-manifest`; stdout becomes ephemeral compiler input. */ args: readonly string[]; @@ -78,13 +78,13 @@ export type DistributedSvelteKitManifestSource = export type DistributedSvelteKitClientCompiler = Readonly<{ /** `$distributed` or an explicit elevated entrypoint such as `$distributed/admin`. */ module: string; - /** Existing manifest path, or canonical `dctl client-manifest` argv. */ + /** Existing manifest path, or canonical `distributed client-manifest` argv. */ manifest: DistributedSvelteKitManifestSource; /** Verify exactly one concrete role. Mutually exclusive with `surface`. */ role?: string; /** Verify exactly one Rust-declared application surface. */ surface?: string; - /** GraphQL globs passed verbatim as repeated `dctl client --documents`. */ + /** GraphQL globs passed verbatim as repeated `distributed client --documents`. */ documents: readonly string[]; /** Explicit `OPERATION=/route` fallbacks. */ routes?: readonly string[]; @@ -93,9 +93,9 @@ export type DistributedSvelteKitClientCompiler = Readonly<{ }>; export type DistributedSvelteKitViteOptions = Readonly<{ - /** Project root used for dctl cwd, document globs, and output containment. */ + /** Project root used for distributed cwd, document globs, and output containment. */ cwd?: string; - /** Executable invoked without a shell. Defaults to `dctl`. */ + /** Executable invoked without a shell. Defaults to `distributed`. */ command?: string; /** Prefix argv, e.g. `cargo run ... --`; never interpreted by a shell. */ commandArgs?: readonly string[]; @@ -184,7 +184,7 @@ export async function generateDistributedSvelteKit( await runCompilerOnce(options, 'generate'); } -/** Check every configured surface through canonical `dctl client --check`; never write. */ +/** Check every configured surface through canonical `distributed client --check`; never write. */ export async function checkDistributedSvelteKit( options: DistributedSvelteKitViteOptions ): Promise { @@ -394,7 +394,7 @@ export function distributedSvelteKitAliases( options: Pick ): Readonly> { const integration = resolveIntegration( - { ...options, command: 'dctl', commandArgs: [] }, + { ...options, command: 'distributed', commandArgs: [] }, options.cwd ?? process.cwd() ); validateResolvedPathsSync(integration); @@ -419,7 +419,7 @@ function resolveIntegration( throw new TypeError('distributedSvelteKit requires configuration'); } const cwd = resolve(options.cwd ?? fallbackCwd); - const command = (options.command ?? 'dctl').trim(); + const command = (options.command ?? 'distributed').trim(); if (command.length === 0) { throw new TypeError('Distributed SvelteKit command must not be empty'); } @@ -771,7 +771,7 @@ async function materializeManifest( JSON.parse(result.stdout) as unknown; } catch (error) { throw new Error( - `dctl client-manifest for ${client.module} did not emit valid JSON`, + `distributed client-manifest for ${client.module} did not emit valid JSON`, { cause: error } ); } @@ -795,13 +795,13 @@ async function validateGeneratedEntrypoint( const metadata = await lstat(entry); if (metadata.isSymbolicLink() || !metadata.isFile()) { throw new Error( - `dctl client for ${module} did not emit a regular ${GENERATED_SVELTEKIT_MODULE}` + `distributed client for ${module} did not emit a regular ${GENERATED_SVELTEKIT_MODULE}` ); } const canonical = await realpath(entry); if (!isWithin(root, canonical)) { throw new Error( - `dctl client entrypoint ${canonical} escaped project root ${root}` + `distributed client entrypoint ${canonical} escaped project root ${root}` ); } } diff --git a/js/tests/replica-command-runtime.test.mjs b/js/tests/replica-command-runtime.test.mjs index 8ae22d968..60dacb914 100644 --- a/js/tests/replica-command-runtime.test.mjs +++ b/js/tests/replica-command-runtime.test.mjs @@ -2471,6 +2471,16 @@ test('commands fail before optimism when no authoritative scope is available', a runtime.dispose(); }); +test('Atomic direct path normalizes a new record with its generated model identity', async () => { + const { replica, runtime } = await directProjectionRuntime(); + assert.deepEqual(replica.record('todo-1').fields, { + __typename: Todo.id, + id: 'todo-1', + title: 'canonical' + }); + runtime.dispose(); +}); + for (const scenario of ['older-row', 'newer-row', 'newer-tombstone']) { test(`Atomic direct path fences ${scenario}`, async () => { const { replica, runtime } = await directProjectionRuntime(); diff --git a/js/tests/sveltekit-vite.test.mjs b/js/tests/sveltekit-vite.test.mjs index 9cd36afa9..fa09f1411 100644 --- a/js/tests/sveltekit-vite.test.mjs +++ b/js/tests/sveltekit-vite.test.mjs @@ -65,7 +65,7 @@ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); mkdirSync(out, { recursive: true }); writeFileSync( out + '/sveltekit.ts', - '/** GENERATED by dctl client. Do not edit. */\nexport const surface = ' + + '/** GENERATED by distributed client. Do not edit. */\nexport const surface = ' + JSON.stringify(surface) + ';\nexport const documents = ' + JSON.stringify(documents) + ';\n' ); @@ -78,7 +78,7 @@ process.stdout.write('generated fake client\n'); async function fixture(t) { const root = await mkdtemp(join(tmpdir(), 'distributed-vite-test-')); - const script = join(root, 'fake-dctl.mjs'); + const script = join(root, 'fake-distributed.mjs'); const log = join(root, 'commands.log'); await writeFile(script, fakeDctlSource, 'utf8'); await writeFile(log, '', 'utf8'); diff --git a/migrations/inventory.json b/migrations/inventory.json new file mode 100644 index 000000000..1580c1173 --- /dev/null +++ b/migrations/inventory.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "migrations": [ + { + "version": 1, + "description": "initial", + "sqlite": { + "path": "migrations/sqlite/0001_initial.sql", + "sha256": "9a3f07064df04b3d5ddc8e4895bd682d9afbc864cf9871a5957fc4a81c2d75a1" + }, + "postgres": { + "path": "migrations/postgres/0001_initial.sql", + "sha256": "fe5c1345b8d1fc672561cf66a6a916354be85d0e597a5b50b14f01c7a4895d39" + } + }, + { + "version": 2, + "description": "command ledger", + "sqlite": { + "path": "migrations/sqlite/0002_command_ledger.sql", + "sha256": "91f9267d5326fd0b8d51305b38bbc6a14a39fb22a28ef888d24d3dde1120348f" + }, + "postgres": { + "path": "migrations/postgres/0002_command_ledger.sql", + "sha256": "905e8c31b285544a4a0b5c357c55497774c59112d4cc52176f10d962514db883" + } + }, + { + "version": 3, + "description": "projection protocol", + "sqlite": { + "path": "migrations/sqlite/0003_projection_protocol.sql", + "sha256": "786de26e41b3809e74eb53f209f2d0a03ff1ec11e7de121b1a2cbd7093ffa307" + }, + "postgres": { + "path": "migrations/postgres/0003_projection_protocol.sql", + "sha256": "087bbab7045c598e54a995e612b8734c6fea87029b8951d15a820ae7ee0d3d72" + } + }, + { + "version": 4, + "description": "command ledger atomic state", + "sqlite": { + "path": "migrations/sqlite/0004_command_ledger_atomic_state.sql", + "sha256": "fb9729bbea27d6c450eee84931547dc1528b04bc275ec46441841fc01ca5643e" + }, + "postgres": { + "path": "migrations/postgres/0004_command_ledger_atomic_state.sql", + "sha256": "bc49ca9c58a294b7c5876c9fcde8a14a8b6110594a06c48b37720d320a22d97e" + } + } + ] +} diff --git a/scripts/graphql-skill-dry-run.sh b/scripts/graphql-skill-dry-run.sh index 8fbe43a2c..8ab891a52 100755 --- a/scripts/graphql-skill-dry-run.sh +++ b/scripts/graphql-skill-dry-run.sh @@ -28,10 +28,10 @@ grep -q 'src/query/' "$SKILL" grep -q 'with_graphql' "$SKILL" echo "OK: README + skill present and teach query layout" -# 2) Build dctl +# 2) Build distributed cd "$ROOT" cargo build -p distributed_cli --quiet -DCTL="$ROOT/target/debug/dctl" +DCTL="$ROOT/target/debug/distributed" test -x "$DCTL" # 3) Scaffold --query-api (as skill documents) diff --git a/scripts/pre-push-contracts-check.sh b/scripts/pre-push-contracts-check.sh new file mode 100755 index 000000000..b2d94a453 --- /dev/null +++ b/scripts/pre-push-contracts-check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Opt-in pre-push hook template (do not install automatically). +# Install with: +# ln -s ../../scripts/pre-push-contracts-check.sh .git/hooks/pre-push +set -eu +cd "$(git rev-parse --show-toplevel)" +make contracts-check diff --git a/src/application/capability.rs b/src/application/capability.rs new file mode 100644 index 000000000..2a8cad36a --- /dev/null +++ b/src/application/capability.rs @@ -0,0 +1,384 @@ +//! Explained capability closure for a validated deployment plan. +//! +//! Capabilities are logical requirements with originating reasons. Provider +//! selection and environment binding are intentionally deferred to runtime +//! and environment policy (tasks 12 and 14). + +use serde::{Deserialize, Serialize}; + +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; + +/// A named logical capability required by one or more mounts. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + EventStore, + LockManager, + CommandLedger, + TransactionalOutbox, + Publisher, + EventSubscription, + InboxCheckpoint, + ReadStore, + ChangeFeed, + IdentityMiddleware, + HttpTransport, + WebsocketTransport, + Metrics, + SchemaLifecycle, + LocalCommandDispatch, + RemoteCommandDispatch, + DirectProjectionTransaction, +} + +impl Capability { + pub const fn as_str(self) -> &'static str { + match self { + Self::EventStore => "event_store", + Self::LockManager => "lock_manager", + Self::CommandLedger => "command_ledger", + Self::TransactionalOutbox => "transactional_outbox", + Self::Publisher => "publisher", + Self::EventSubscription => "event_subscription", + Self::InboxCheckpoint => "inbox_checkpoint", + Self::ReadStore => "read_store", + Self::ChangeFeed => "change_feed", + Self::IdentityMiddleware => "identity_middleware", + Self::HttpTransport => "http_transport", + Self::WebsocketTransport => "websocket_transport", + Self::Metrics => "metrics", + Self::SchemaLifecycle => "schema_lifecycle", + Self::LocalCommandDispatch => "local_command_dispatch", + Self::RemoteCommandDispatch => "remote_command_dispatch", + Self::DirectProjectionTransaction => "direct_projection_transaction", + } + } +} + +/// Why a capability is required. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CapabilityReason { + pub capability: Capability, + /// Originating process id when the requirement is process-local. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_id: Option, + /// Originating mount kind when applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mount_kind: Option, + /// Originating mount id when applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mount_id: Option, + /// Human-readable but deterministic explanation. + pub reason: String, +} + +/// One required capability with all contributing reasons. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CapabilityRequirement { + pub capability: Capability, + pub reasons: Vec, +} + +/// Renderer-neutral schema/migration lifecycle requirement. +/// +/// Describes *that* schema lifecycle is needed and which logical owner +/// produced the requirement. It does not choose Job, operator, or mode. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SchemaLifecycleRequirement { + pub required: bool, + /// Single logical owner identity when schema lifecycle is required. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logical_owner: Option, + pub reasons: Vec, +} + +/// Derive explained capabilities for the mounts selected into one process. +pub fn derive_process_capabilities( + manifest: &ApplicationManifest, + process_id: &str, + mounts: &[MountSelector], + remote_commands: bool, +) -> ApplicationResult<(Vec, SchemaLifecycleRequirement)> { + let mut reasons = Vec::new(); + let mut schema_reasons = Vec::new(); + let mut schema_owner: Option = None; + + let push = |reasons: &mut Vec, + capability: Capability, + mount: Option<&MountSelector>, + reason: String| { + reasons.push(CapabilityReason { + capability, + process_id: Some(process_id.to_string()), + mount_kind: mount.map(|mount| mount.kind_label().to_string()), + mount_id: mount.map(|mount| mount.id().to_string()), + reason, + }); + }; + + for mount in mounts { + match mount { + MountSelector::Command { id } => { + let command = manifest + .commands + .iter() + .find(|command| command.id == *id) + .ok_or_else(|| ApplicationError::Missing { + kind: "command", + identity: id.clone(), + })?; + if remote_commands { + push( + &mut reasons, + Capability::RemoteCommandDispatch, + Some(mount), + format!("command `{id}` is dispatched remotely"), + ); + } else { + push( + &mut reasons, + Capability::LocalCommandDispatch, + Some(mount), + format!("command `{id}` is executed by a local mount"), + ); + push( + &mut reasons, + Capability::EventStore, + Some(mount), + format!("local command `{id}` requires an event store"), + ); + push( + &mut reasons, + Capability::LockManager, + Some(mount), + format!("local command `{id}` requires aggregate locks"), + ); + push( + &mut reasons, + Capability::CommandLedger, + Some(mount), + format!("local command `{id}` requires command ledger/dedup"), + ); + } + if !command.emits.is_empty() { + push( + &mut reasons, + Capability::TransactionalOutbox, + Some(mount), + format!("command `{id}` emits facts and needs an outbox"), + ); + push( + &mut reasons, + Capability::Publisher, + Some(mount), + format!("command `{id}` publishes outward facts"), + ); + } + if matches!(command.consistency, CommandConsistency::Atomic) { + push( + &mut reasons, + Capability::DirectProjectionTransaction, + Some(mount), + format!( + "atomic command `{id}` requires collocated direct projection" + ), + ); + push( + &mut reasons, + Capability::ReadStore, + Some(mount), + format!("atomic command `{id}` writes read models in-transaction"), + ); + } + schema_reasons.push(format!("command `{id}` implies schema lifecycle")); + if schema_owner.is_none() { + schema_owner = Some(manifest.name.clone()); + } + } + MountSelector::Projector { id } => { + let projection = manifest + .projections + .iter() + .find(|projection| projection.id == *id) + .ok_or_else(|| ApplicationError::Missing { + kind: "projection", + identity: id.clone(), + })?; + if projection.direct { + push( + &mut reasons, + Capability::DirectProjectionTransaction, + Some(mount), + format!("direct projector `{id}` seals in the writer transaction"), + ); + } else { + push( + &mut reasons, + Capability::EventSubscription, + Some(mount), + format!("eventual projector `{id}` subscribes to facts"), + ); + push( + &mut reasons, + Capability::InboxCheckpoint, + Some(mount), + format!("eventual projector `{id}` checkpoints progress"), + ); + } + push( + &mut reasons, + Capability::ReadStore, + Some(mount), + format!("projector `{id}` writes read models"), + ); + push( + &mut reasons, + Capability::ChangeFeed, + Some(mount), + format!("projector `{id}` publishes change notifications"), + ); + schema_reasons.push(format!("projector `{id}` implies schema lifecycle")); + if schema_owner.is_none() { + schema_owner = Some(manifest.name.clone()); + } + } + MountSelector::Surface { id } => { + push( + &mut reasons, + Capability::ReadStore, + Some(mount), + format!("surface `{id}` queries read models"), + ); + push( + &mut reasons, + Capability::IdentityMiddleware, + Some(mount), + format!("surface `{id}` requires principal identity"), + ); + push( + &mut reasons, + Capability::HttpTransport, + Some(mount), + format!("surface `{id}` is served over HTTP"), + ); + // Live-capable surfaces always advertise change-feed need at plan + // level; runtime may no-op when no @live fields are selected. + push( + &mut reasons, + Capability::ChangeFeed, + Some(mount), + format!("surface `{id}` may expose live queries"), + ); + push( + &mut reasons, + Capability::WebsocketTransport, + Some(mount), + format!("surface `{id}` may serve live subscriptions"), + ); + if remote_commands { + push( + &mut reasons, + Capability::RemoteCommandDispatch, + Some(mount), + format!("surface `{id}` dispatches commands remotely"), + ); + } else if mounts.iter().any(|m| matches!(m, MountSelector::Command { .. })) { + push( + &mut reasons, + Capability::LocalCommandDispatch, + Some(mount), + format!("surface `{id}` dispatches to local command mounts"), + ); + } else { + push( + &mut reasons, + Capability::RemoteCommandDispatch, + Some(mount), + format!( + "surface `{id}` has no local command mounts; remote dispatch required" + ), + ); + } + } + MountSelector::Extension { id } => { + push( + &mut reasons, + Capability::Metrics, + Some(mount), + format!("extension `{id}` may require observability hooks"), + ); + } + } + } + + // Always explain metrics when any process exists — readiness/observability. + if !mounts.is_empty() { + push( + &mut reasons, + Capability::Metrics, + None, + format!("process `{process_id}` exposes readiness and metrics"), + ); + } + + reasons.sort(); + reasons.dedup(); + + let mut by_capability = std::collections::BTreeMap::>::new(); + for reason in reasons { + by_capability + .entry(reason.capability) + .or_default() + .push(reason); + } + let requirements = by_capability + .into_iter() + .map(|(capability, mut reasons)| { + reasons.sort(); + CapabilityRequirement { + capability, + reasons, + } + }) + .collect::>(); + + schema_reasons.sort(); + schema_reasons.dedup(); + if schema_reasons.len() > 1 { + // Single logical owner: the application name. Multiple process reasons + // still share that owner. + if schema_owner.as_deref() != Some(manifest.name.as_str()) && schema_owner.is_some() { + return Err(ApplicationError::Collision { + kind: "schema lifecycle owner", + identity: schema_owner.clone().unwrap_or_default(), + reason: format!( + "expected single logical owner `{}`", + manifest.name + ), + }); + } + } + let schema = SchemaLifecycleRequirement { + required: !schema_reasons.is_empty(), + logical_owner: if schema_reasons.is_empty() { + None + } else { + Some(manifest.name.clone()) + }, + reasons: schema_reasons, + }; + + // Fingerprint stability helper (ensures reasons are canonical-serializable). + let _ = sha256_fingerprint(&serde_json::to_vec(&canonical_json( + &serde_json::to_value(&requirements)?, + ))?); + + Ok((requirements, schema)) +} diff --git a/src/application/command.rs b/src/application/command.rs new file mode 100644 index 000000000..55d684766 --- /dev/null +++ b/src/application/command.rs @@ -0,0 +1,801 @@ +use std::collections::BTreeSet; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +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::graphql::{GraphqlInputType, GraphqlTypeDef}; + +/// Serializable GraphQL type field used by a portable command contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandTypeField { + pub name: String, + pub type_name: String, + pub nullable: bool, + pub list: bool, + pub item_nullable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nested: Option>, +} + +/// Serializable GraphQL input/output type used by a portable command contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandTypeSpec { + pub name: String, + pub fields: Vec, +} + +pub type TypeSpec = CommandTypeSpec; + +impl From<&GraphqlTypeDef> for CommandTypeSpec { + fn from(definition: &GraphqlTypeDef) -> Self { + Self { + name: definition.name.clone(), + fields: definition + .fields + .iter() + .map(|field| CommandTypeField { + name: field.name.clone(), + type_name: field.type_name.clone(), + nullable: field.nullable, + list: field.list, + item_nullable: field.item_nullable, + nested: field.nested.as_deref().map(Self::from).map(Box::new), + }) + .collect(), + } + } +} + +/// One exact outward event identity referenced by a command declaration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EventSpec { + pub name: String, + pub version: u64, + pub body_type: String, + pub body_version: u64, + pub body_schema: String, + pub body_fingerprint: String, + pub body_codec: String, + pub body_codec_version: u16, +} + +/// The portable half of one typed command declaration. +/// +/// The effect, default, confirmation, and projection values are copied from +/// the existing typed-command IR. They remain explicit JSON-shaped data; no +/// handler symbol, Rust `TypeId`, closure, or machine path is retained. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandSpec { + pub id: String, + pub field_name: String, + pub roles: Vec, + pub input: CommandTypeSpec, + pub output: CommandTypeSpec, + pub consistency: CommandConsistency, + pub defaults: serde_json::Value, + pub effects: serde_json::Value, + pub emits: Vec, + pub applies: serde_json::Value, + pub projection_contract: serde_json::Value, + /// Declaration-owned projector/model/key confirmations. These are the + /// portable proof inputs; topology pointers and schemas are intentionally + /// reduced to their canonical identities by `canonical_value()`. + #[serde(default)] + pub confirmations: Vec, + /// Canonical direct-projection proof material, if the outcome is Atomic. + /// The erased Rust type identity is never copied into this value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direct_projection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projected_model: Option, + pub fingerprint: String, +} + +/// One review-visible declaration containing its portable spec and, when the +/// runtime feature is present, the derived executable mount. Keeping these +/// values together prevents parallel `commands`/`mounts` inventories. +pub struct CommandDefinition { + spec: CommandSpec, + /// The exact typed declaration that produced `spec`. This is retained for + /// contract-only composition so Surface authorization can bind from the + /// declaration-owned GraphQL shapes and effects without reconstructing a + /// lossy command from public JSON. + typed_contract: Option, + mount: Option, +} + +impl Clone for CommandDefinition { + fn clone(&self) -> Self { + Self { + spec: self.spec.clone(), + typed_contract: self.typed_contract.clone(), + mount: self.mount.clone(), + } + } +} + +impl std::fmt::Debug for CommandDefinition { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CommandDefinition") + .field("command", &self.spec.id) + .field("typed_contract", &self.typed_contract.is_some()) + .field("runtime_mount", &self.mount.is_some()) + .finish() + } +} + +impl CommandDefinition { + pub fn contract(spec: CommandSpec) -> Self { + Self { + spec, + typed_contract: None, + mount: None, + } + } + + pub fn with_mount(spec: CommandSpec, mount: CommandMount) -> ApplicationResult { + if spec.id != mount.spec().id || spec.fingerprint != mount.spec().fingerprint { + return Err(ApplicationError::Collision { + kind: "command", + identity: spec.id, + reason: "command definition and executable mount do not share one spec identity" + .into(), + }); + } + Ok(Self { + spec, + typed_contract: None, + mount: Some(mount), + }) + } + + /// Retain one exact typed declaration beside its portable spec and + /// optional executable mount. The typed contract is never serialized; it + /// exists so framework-owned Surface compilation can consume the original + /// declaration before role/application authorization selection. + pub fn from_typed_command( + command: TypedCommand, + mount: Option, + ) -> ApplicationResult + where + I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + K: CommandOutcome, + { + let (_, typed_contract) = command.into_parts(); + let spec = CommandSpec::from_contract(&typed_contract)?; + if let Some(mount) = &mount { + validate_mount_spec(&spec, mount)?; + } + Ok(Self { + spec, + typed_contract: Some(typed_contract), + mount, + }) + } + + pub fn spec(&self) -> &CommandSpec { + &self.spec + } + + pub fn mount(&self) -> Option<&CommandMount> { + self.mount.as_ref() + } + + pub(crate) fn typed_contract(&self) -> Option<&TypedCommandContract> { + self.typed_contract.as_ref() + } +} + +fn validate_mount_spec(spec: &CommandSpec, mount: &CommandMount) -> ApplicationResult<()> { + if spec.id != mount.spec().id || spec.fingerprint != mount.spec().fingerprint { + return Err(ApplicationError::Collision { + kind: "command", + identity: spec.id.clone(), + reason: "command definition and executable mount do not share one spec identity" + .into(), + }); + } + Ok(()) +} + +impl CommandSpec { + /// Construct a command spec from already-portable pieces. + pub fn try_new( + id: impl Into, + field_name: impl Into, + input: CommandTypeSpec, + output: CommandTypeSpec, + consistency: CommandConsistency, + ) -> ApplicationResult { + let id = LogicalId::try_new("command", id)?.into_string(); + let field_name = field_name.into(); + if field_name.is_empty() { + return Err(ApplicationError::InvalidSpec( + "command field name must not be empty".into(), + )); + } + let mut spec = Self { + id, + field_name, + roles: Vec::new(), + input, + output, + consistency, + defaults: serde_json::Value::Array(Vec::new()), + effects: serde_json::Value::Null, + emits: Vec::new(), + applies: serde_json::Value::Array(Vec::new()), + projection_contract: serde_json::Value::Null, + confirmations: Vec::new(), + direct_projection: None, + projected_model: None, + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + spec.validate()?; + Ok(spec) + } + + /// Attach Atomic direct-projection proof material and recompute the fingerprint. + pub fn with_direct_projection( + mut self, + projected_model: impl Into, + proof: serde_json::Value, + ) -> ApplicationResult { + self.consistency = CommandConsistency::Atomic; + self.projected_model = Some(LogicalId::try_new("projected model", projected_model)?.into_string()); + self.direct_projection = Some(proof); + self.refresh_fingerprint()?; + self.validate()?; + Ok(self) + } + + /// 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, + K: CommandOutcome, + { + let (_, contract) = command.clone().into_parts(); + Self::from_contract(&contract) + } + + pub(crate) fn from_contract( + contract: &crate::graphql::command_contract::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) + .collect(); + let direct_projection = contract + .direct_projection + .as_ref() + .map(crate::graphql::command_contract::CommandDirectProjectionTarget::canonical_value); + let mut emits: Vec = contract + .projections + .selectors + .iter() + .map(|selector| EventSpec { + name: selector.event_name().to_owned(), + version: selector.event_version(), + body_type: selector.body_type_name().to_owned(), + body_version: selector.body_version(), + body_schema: selector.body_schema().to_owned(), + body_fingerprint: selector.body_fingerprint().to_owned(), + body_codec: selector.body_codec().to_owned(), + body_codec_version: selector.body_codec_version(), + }) + .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(), + )) + }); + let mut roles = contract.roles.clone(); + roles.sort(); + roles.dedup(); + let mut spec = Self { + id: LogicalId::try_new("command", contract.name.clone())?.into_string(), + field_name: contract.field_name.clone(), + roles, + input: CommandTypeSpec::from(&contract.input), + output: CommandTypeSpec::from(&contract.output), + consistency: contract.consistency, + defaults: serde_json::to_value(&contract.input_defaults)?, + effects: serde_json::to_value(&contract.effects)?, + emits, + applies, + projection_contract, + confirmations, + direct_projection, + projected_model: contract + .projected_model + .as_ref() + .map(|projected| projected.model.clone()), + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + spec.validate()?; + Ok(spec) + } + + 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()), + ); + } + serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) + } + + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + pub fn identity(&self) -> &str { + &self.id + } + + /// Validate the portable identity and schema surface without touching a + /// handler or any runtime state. + pub fn validate(&self) -> ApplicationResult<()> { + LogicalId::try_new("command", self.id.clone())?; + validate_text("command field", &self.field_name)?; + validate_type("command input", &self.input)?; + validate_type("command output", &self.output)?; + for role in &self.roles { + LogicalId::try_new("command role", role.clone())?; + } + if self.roles.windows(2).any(|roles| roles[0] >= roles[1]) { + return Err(ApplicationError::NonCanonical("command role ordering")); + } + let mut event_names = BTreeSet::new(); + for event in &self.emits { + if !event_names.insert(event.name.clone()) { + return Err(ApplicationError::Duplicate { + kind: "command event", + identity: event.name.clone(), + }); + } + LogicalId::try_new("event", event.name.clone())?; + if event.version == 0 || event.body_version == 0 || event.body_codec_version == 0 { + return Err(ApplicationError::InvalidSpec(format!( + "event `{}` versions must be non-zero", + event.name + ))); + } + validate_text("event body type", &event.body_type)?; + validate_text("event body schema", &event.body_schema)?; + validate_sha256("event body fingerprint", &event.body_fingerprint)?; + validate_text("event body codec", &event.body_codec)?; + } + validate_json_contract("command defaults", &self.defaults)?; + validate_json_contract("command effects", &self.effects)?; + validate_json_contract("command applies", &self.applies)?; + validate_json_contract("command projection contract", &self.projection_contract)?; + for confirmation in &self.confirmations { + validate_json_contract("command confirmation", confirmation)?; + } + if let Some(direct) = &self.direct_projection { + validate_json_contract("command direct projection", direct)?; + } + if let Some(model) = &self.projected_model { + LogicalId::try_new("model", model.clone())?; + } + Ok(()) + } + + pub(crate) fn validate_fingerprint(&self) -> ApplicationResult<()> { + if self.fingerprint.is_empty() { + return Err(ApplicationError::NonCanonical("command fingerprint")); + } + if sha256_fingerprint(&self.canonical_bytes()?) != self.fingerprint { + return Err(ApplicationError::NonCanonical("command fingerprint")); + } + Ok(()) + } + + pub(crate) fn refresh_fingerprint(&mut self) -> ApplicationResult<()> { + let bytes = self.canonical_bytes()?; + self.fingerprint = sha256_fingerprint(&bytes); + Ok(()) + } +} + +fn validate_type(kind: &'static str, definition: &CommandTypeSpec) -> ApplicationResult<()> { + validate_type_at_depth(kind, definition, 0) +} + +fn validate_type_at_depth( + kind: &'static str, + definition: &CommandTypeSpec, + depth: usize, +) -> ApplicationResult<()> { + if depth > super::manifest::MAX_MANIFEST_JSON_DEPTH { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} nesting exceeds {}", + super::manifest::MAX_MANIFEST_JSON_DEPTH + ))); + } + if definition.fields.len() > super::manifest::MAX_MANIFEST_COLLECTION_ITEMS { + return Err(ApplicationError::InvalidSpec(format!( + "command type fields count exceeds {}", + super::manifest::MAX_MANIFEST_COLLECTION_ITEMS + ))); + } + validate_text(kind, &definition.name)?; + let mut field_names = BTreeSet::new(); + for field in &definition.fields { + if !field_names.insert(field.name.clone()) { + return Err(ApplicationError::Duplicate { + kind: "command type field", + identity: field.name.clone(), + }); + } + validate_text("command field", &field.name)?; + validate_text("command field type", &field.type_name)?; + if let Some(nested) = &field.nested { + validate_type_at_depth(kind, nested, depth + 1)?; + } + } + Ok(()) +} + +fn validate_sha256(kind: &'static str, value: &str) -> ApplicationResult<()> { + validate_text(kind, value)?; + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must use the sha256:<64 lowercase hex> form" + ))); + }; + if hex.len() != 64 + || !hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must use the sha256:<64 lowercase hex> form" + ))); + } + Ok(()) +} + +fn validate_text(kind: &'static str, value: &str) -> ApplicationResult<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > super::manifest::MAX_MANIFEST_STRING_BYTES + || value.contains('\0') + { + return Err(ApplicationError::InvalidIdentity { + kind, + value: value.into(), + reason: "must be a non-empty portable value", + }); + } + Ok(()) +} + +fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> ApplicationResult<()> { + let bytes = serde_json::to_vec(value)?; + if bytes.len() > super::manifest::MAX_MANIFEST_JSON_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} exceeds {} JSON bytes", + super::manifest::MAX_MANIFEST_JSON_BYTES + ))); + } + 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 {}", + super::manifest::MAX_MANIFEST_JSON_DEPTH + ))); + } + match value { + serde_json::Value::String(value) => { + if value.len() > super::manifest::MAX_MANIFEST_STRING_BYTES || value.contains('\0') { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains oversized or NUL string material" + ))); + } + } + serde_json::Value::Array(values) => { + if values.len() > super::manifest::MAX_MANIFEST_COLLECTION_ITEMS { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains too many values" + ))); + } + for value in values { + walk(kind, value, depth + 1)?; + } + } + serde_json::Value::Object(fields) => { + if fields.len() > super::manifest::MAX_MANIFEST_COLLECTION_ITEMS { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains too many object fields" + ))); + } + for (key, value) in fields { + 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" + ))); + } + walk(kind, value, depth + 1)?; + } + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + } + Ok(()) + } + walk(kind, value, 0) +} + +/// Executable command material retained only at the heterogeneous runtime +/// boundary. Its handler is intentionally absent from serialization. The +/// boundary is deliberately a callable request/response trait rather than an +/// `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>, +>; + +pub trait CommandMountHandler: Send + Sync { + fn call(&self, request: crate::microsvc::CommandRequest) -> CommandMountFuture<'_>; +} + +/// Explicit registration seam used by service/runtime adapters. A mount is +/// 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>; +} + +/// Invocation context for the heterogeneous runtime boundary. The ordinary +/// transport variant is deliberately unable to execute a typed causal mount; +/// only the authenticated variant carries the framework-issued principal and +/// bearer-scoped command identity needed by the existing causal ledger path. +#[cfg(feature = "graphql")] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountInvocation { + Transport, + Authenticated { + command_id: String, + session: crate::microsvc::Session, + principal: crate::graphql::identity::VerifiedPrincipal, + }, +} + +#[cfg(not(feature = "graphql"))] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountInvocation { + Transport, +} + +#[cfg(feature = "graphql")] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountExecutionResult { + Transport(crate::microsvc::CommandResponse), + Causal(crate::microsvc::CausalDispatchResult), +} + +#[cfg(not(feature = "graphql"))] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountExecutionResult { + Transport(crate::microsvc::CommandResponse), +} + +#[cfg(feature = "graphql")] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountExecutionError { + Handler(crate::microsvc::HandlerError), + Causal(crate::microsvc::CausalDispatchError), +} + +#[cfg(not(feature = "graphql"))] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountExecutionError { + Handler(crate::microsvc::HandlerError), +} + +#[allow(dead_code)] +pub(crate) type CommandMountExecutionFuture<'a> = Pin< + Box< + dyn Future< + Output = Result, + > + Send + + 'a, + >, +>; + +/// Runtime adapter for mounts whose authorization and causal commit protocol +/// lives in a service/router. Adapters receive the same mount spec and must +/// route through the existing `CommandRequest`/`CommandResponse` boundary; +/// authenticated typed mounts additionally enter the existing causal receipt +/// and projection-proof protocol. +#[allow(dead_code)] +pub(crate) trait CommandMountExecution: Send + Sync { + fn invoke_mount<'a>( + &'a self, + mount: &'a CommandMount, + request: crate::microsvc::CommandRequest, + invocation: CommandMountInvocation, + ) -> CommandMountExecutionFuture<'a>; +} + +struct RequestCommandMountHandler(H); + +impl CommandMountHandler for RequestCommandMountHandler +where + H: Fn(crate::microsvc::CommandRequest) -> F + Send + Sync, + F: Future> + + Send + + 'static, +{ + fn call(&self, request: crate::microsvc::CommandRequest) -> CommandMountFuture<'_> { + Box::pin((self.0)(request)) + } +} + +pub struct CommandMount { + spec: CommandSpec, + handler: Option>, + typed_route: Option, +} + +impl Clone for CommandMount { + fn clone(&self) -> Self { + Self { + spec: self.spec.clone(), + handler: self.handler.clone(), + typed_route: self.typed_route.clone(), + } + } +} + +impl std::fmt::Debug for CommandMount { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CommandMount") + .field("command", &self.spec.id) + .field("callable", &self.handler.is_some()) + .field("typed_route", &self.typed_route) + .finish() + } +} + +impl CommandMount { + /// Create a contract-only mount with no executable handler. + pub fn contract(spec: CommandSpec) -> Self { + Self { + spec, + handler: None, + typed_route: None, + } + } + + /// Erase one executable handler without changing the portable spec. + pub fn from_handler(spec: CommandSpec, handler: H) -> Self + where + H: CommandMountHandler + 'static, + { + Self { + spec, + handler: Some(Arc::new(handler)), + typed_route: None, + } + } + + /// Adapt an owned request handler to the type-erased mount boundary. + /// Request and response values remain the existing transport types and + /// failures retain the framework's typed [`HandlerError`]. + 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 + + 'static, + { + Self::from_handler(spec, RequestCommandMountHandler(handler)) + } + + /// Create the runtime-facing registration token for a typed causal route. + /// The token deliberately contains no fake handler closure: execution is + /// owned by the registered typed route and can only enter through the + /// authenticated causal protocol. + pub fn from_typed_route(spec: CommandSpec, route_name: impl Into) -> Self { + Self { + spec, + handler: None, + typed_route: Some(route_name.into()), + } + } + + pub fn spec(&self) -> &CommandSpec { + &self.spec + } + + pub(crate) fn typed_route_name(&self) -> Option<&str> { + self.typed_route.as_deref() + } + + /// Invoke the erased handler. Contract-only mounts fail closed with a + /// typed authorization error instead of pretending that a mount is + /// executable. + pub fn invoke(&self, request: &crate::microsvc::CommandRequest) -> CommandMountFuture<'_> { + match &self.handler { + Some(handler) => handler.call(request.clone()), + None => Box::pin(async { + Err(crate::microsvc::HandlerError::Unauthorized( + "command mount has no runtime handler".into(), + )) + }), + } + } + + /// Invoke through a registered runtime adapter. Typed mounts must receive + /// an authenticated invocation context; the adapter then enters the + /// existing causal route, preserving authorization, receipts, and + /// projection proofs. A transport-only invocation remains fail-closed. + #[allow(dead_code)] + pub(crate) fn invoke_with<'a, E: CommandMountExecution>( + &'a self, + executor: &'a E, + request: &crate::microsvc::CommandRequest, + invocation: CommandMountInvocation, + ) -> CommandMountExecutionFuture<'a> { + executor.invoke_mount(self, request.clone(), invocation) + } + + /// Register this mount with an explicit runtime adapter. + pub fn register_with( + &self, + registrar: &mut R, + ) -> Result<(), crate::microsvc::HandlerError> { + registrar.register_command_mount(self.clone()) + } +} + +impl TypedCommand +where + I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + K: CommandOutcome, +{ + /// Compile the exact declaration into its portable, serializable spec. + pub fn spec(&self) -> ApplicationResult { + CommandSpec::from_typed_command(self) + } +} diff --git a/src/application/error.rs b/src/application/error.rs new file mode 100644 index 000000000..13ce739e1 --- /dev/null +++ b/src/application/error.rs @@ -0,0 +1,78 @@ +use std::fmt; + +/// Errors raised while compiling portable application contracts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApplicationError { + InvalidIdentity { + kind: &'static str, + value: String, + reason: &'static str, + }, + Duplicate { + kind: &'static str, + identity: String, + }, + Collision { + kind: &'static str, + identity: String, + reason: String, + }, + Missing { + kind: &'static str, + identity: String, + }, + InvalidSpec(String), + UnsupportedVersion { + expected: u32, + actual: u32, + }, + NonCanonical(&'static str), + Canonical(String), +} + +pub type ApplicationResult = Result; + +impl fmt::Display for ApplicationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidIdentity { + kind, + value, + reason, + } => write!(formatter, "invalid {kind} identity `{value}`: {reason}"), + Self::Duplicate { kind, identity } => { + write!(formatter, "duplicate {kind} identity `{identity}`") + } + Self::Collision { + kind, + identity, + reason, + } => write!( + formatter, + "colliding {kind} identity `{identity}`: {reason}" + ), + Self::Missing { kind, identity } => { + write!(formatter, "missing {kind} identity `{identity}`") + } + Self::InvalidSpec(reason) => { + write!(formatter, "invalid application specification: {reason}") + } + Self::UnsupportedVersion { expected, actual } => write!( + formatter, + "unsupported application manifest schema version {actual}; expected {expected}" + ), + Self::NonCanonical(kind) => write!(formatter, "non-canonical {kind} bytes"), + Self::Canonical(reason) => { + write!(formatter, "canonical application artifact error: {reason}") + } + } + } +} + +impl std::error::Error for ApplicationError {} + +impl From for ApplicationError { + fn from(error: serde_json::Error) -> Self { + Self::Canonical(error.to_string()) + } +} diff --git a/src/application/identity.rs b/src/application/identity.rs new file mode 100644 index 000000000..de5662bc2 --- /dev/null +++ b/src/application/identity.rs @@ -0,0 +1,97 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::error::{ApplicationError, ApplicationResult}; + +/// A portable logical identity. +/// +/// Logical identities intentionally reject path separators, whitespace, +/// control characters, and environment-like syntax. They are names in a +/// manifest, never filesystem paths or runtime endpoints. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct LogicalId(String); + +impl LogicalId { + pub fn try_new(kind: &'static str, value: impl Into) -> ApplicationResult { + let value = value.into(); + if value.is_empty() { + return Err(ApplicationError::InvalidIdentity { + kind, + value, + reason: "must not be empty", + }); + } + if value.trim() != value { + return Err(ApplicationError::InvalidIdentity { + kind, + value, + reason: "must not have leading or trailing whitespace", + }); + } + if value.starts_with('.') || value.ends_with('.') || value.contains("..") { + return Err(ApplicationError::InvalidIdentity { + kind, + value, + reason: "must not start, end, or repeat a separator", + }); + } + if !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | ':') + }) { + return Err(ApplicationError::InvalidIdentity { + kind, + value, + reason: "may contain only ASCII letters, digits, '.', '_', '-', and ':'", + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl AsRef for LogicalId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for LogicalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +/// Recursively sort JSON objects so an IR value has one portable encoding. +pub fn canonical_json(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.iter().map(canonical_json).collect()) + } + serde_json::Value::Object(values) => { + let mut sorted = serde_json::Map::new(); + for (key, value) in values { + sorted.insert(key.clone(), canonical_json(value)); + } + serde_json::Value::Object(sorted) + } + other => other.clone(), + } +} + +/// Compute a domain-separated SHA-256 fingerprint for portable bytes. +pub fn sha256_fingerprint(bytes: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(b"distributed.application-artifact.v1\0"); + digest.update(bytes); + format!("sha256:{:x}", digest.finalize()) +} diff --git a/src/application/manifest.rs b/src/application/manifest.rs new file mode 100644 index 000000000..9e25a761c --- /dev/null +++ b/src/application/manifest.rs @@ -0,0 +1,1817 @@ +use std::collections::{BTreeSet, HashSet}; + +use serde::{Deserialize, Serialize}; + +use super::command::CommandSpec; +use super::error::{ApplicationError, ApplicationResult}; +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; + +/// Bounds applied before a portable application artifact is accepted. +pub const MAX_APPLICATION_MANIFEST_BYTES: usize = 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; +pub const MAX_MANIFEST_JSON_DEPTH: usize = 32; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestFingerprint { + pub logical: String, + pub canonical: String, +} + +/// Portable provenance carried by the artifact envelope. Provenance is part +/// of canonical artifact bytes, but volatile source metadata is deliberately +/// excluded from the logical fingerprint (see [`ApplicationManifest::logical_fingerprint`]). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestProvenance { + pub generator: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_revision: Option, + #[serde(default)] + pub sources: Vec, +} + +impl Default for ManifestProvenance { + fn default() -> Self { + Self { + generator: "distributed.application.compiler.v1".into(), + source_revision: None, + sources: Vec::new(), + } + } +} + +/// A named, versioned extension value in the explicit application +/// declaration. Extensions are data-only and are included in application +/// identity; executable/runtime configuration must use a deployment layer. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ApplicationExtension { + pub id: String, + pub version: u32, + pub value: serde_json::Value, +} + +impl ApplicationExtension { + pub fn try_new( + id: impl Into, + version: u32, + value: serde_json::Value, + ) -> ApplicationResult { + let id = LogicalId::try_new("application extension", id)?.into_string(); + if version == 0 { + return Err(ApplicationError::InvalidSpec( + "application extension version must be non-zero".into(), + )); + } + let extension = Self { id, version, value }; + validate_json_contract("application extension", &extension.value)?; + Ok(extension) + } +} + +/// The sole complete logical application manifest owner. +/// +/// Physical tables, service endpoints, transports, observability, and +/// 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)] +pub struct ApplicationManifest { + /// This field is required during decoding: canonical input may not omit + /// the explicit schema version and receive a legacy default. + pub schema_version: u32, + pub name: String, + #[serde(default)] + pub modules: Vec, + #[serde(default)] + pub commands: Vec, + #[serde(default)] + pub events: Vec, + #[serde(default)] + pub projections: Vec, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub surfaces: Vec, + #[serde(default)] + pub required_capabilities: Vec, + #[serde(default)] + pub extensions: Vec, + #[serde(default)] + pub fingerprints: ManifestFingerprint, + #[serde(default)] + pub provenance: ManifestProvenance, +} + +impl ApplicationManifest { + pub fn new(name: impl Into) -> Self { + Self { + schema_version: APPLICATION_MANIFEST_SCHEMA_VERSION, + name: name.into(), + modules: Vec::new(), + commands: Vec::new(), + events: Vec::new(), + projections: Vec::new(), + models: Vec::new(), + surfaces: Vec::new(), + required_capabilities: Vec::new(), + extensions: Vec::new(), + fingerprints: ManifestFingerprint::default(), + provenance: ManifestProvenance::default(), + } + } + + /// Compile explicit modules and logical selected surfaces into one + /// manifest owner. A concrete `Surface` must first be compiled into a + /// `SurfaceSpec` by the shared contract compiler. + pub fn try_from_modules( + name: impl Into, + modules: impl IntoIterator, + surfaces: impl IntoIterator, + ) -> ApplicationResult { + let modules = modules.into_iter().collect::>(); + let mut module_manifests = modules + .iter() + .map(|module| module.manifest().clone()) + .collect::>(); + module_manifests.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique_ids( + "module", + module_manifests.iter().map(|module| module.id.clone()), + )?; + + let mut commands = Vec::new(); + let mut events = Vec::new(); + let mut projections = Vec::new(); + let mut models = Vec::new(); + let mut required_capabilities = Vec::new(); + let mut module_surfaces = Vec::new(); + for module in &module_manifests { + commands.extend(module.commands.clone()); + events.extend(module.events.clone()); + projections.extend(module.projections.clone()); + models.extend(module.models.clone()); + required_capabilities.extend(module.required_capabilities.clone()); + module_surfaces.extend(module.surfaces.clone()); + } + let explicit_surfaces = surfaces.into_iter().collect::>(); + let mut surfaces = module_surfaces; + surfaces.extend(explicit_surfaces); + surfaces.sort_by(|left, right| left.id.cmp(&right.id)); + surfaces = dedup_surfaces(surfaces)?; + projections.extend( + surfaces + .iter() + .flat_map(|surface| surface.projections.iter().cloned()), + ); + models.extend( + surfaces + .iter() + .flat_map(|surface| surface.models.iter().cloned()), + ); + + let mut manifest = Self::new(name); + manifest.modules = module_manifests; + manifest.commands = dedup_commands(commands)?; + manifest.events = dedup_events(events)?; + manifest.projections = dedup_projections(projections)?; + manifest.models = dedup_models(models)?; + manifest.surfaces = surfaces; + manifest.required_capabilities = required_capabilities; + manifest.canonicalize_collections(); + manifest.validate()?; + manifest.refresh_fingerprints()?; + Ok(manifest) + } + + pub fn with_provenance(mut self, provenance: ManifestProvenance) -> Self { + self.provenance = provenance; + self.fingerprints = ManifestFingerprint::default(); + self + } + + pub fn with_source_revision(mut self, revision: impl Into) -> Self { + self.provenance.source_revision = Some(revision.into()); + self.fingerprints = ManifestFingerprint::default(); + self + } + + pub fn with_extension(mut self, extension: ApplicationExtension) -> Self { + self.extensions.push(extension); + self.fingerprints = ManifestFingerprint::default(); + self + } + + pub fn module_ids(&self) -> Vec<&str> { + self.modules.iter().map(|module| module.id.as_str()).collect() + } + + /// Return exact deterministic manifest bytes, including the explicit + /// schema version and complete fingerprint material. + pub fn canonical_bytes(&self) -> ApplicationResult> { + // Encoding is also an acceptance boundary: an in-memory attacker may + // not manufacture a byte artifact with empty nested fingerprints and + // rely on the outer manifest fingerprint to make it look complete. + self.validate_inner(true, false)?; + let mut canonical = self.clone(); + canonical.canonicalize_collections(); + canonical.fingerprints = ManifestFingerprint::default(); + let mut logical = canonical.clone(); + logical.provenance = ManifestProvenance::default(); + let logical_bytes = serde_json::to_vec(&canonical_json(&serde_json::to_value(&logical)?))?; + canonical.fingerprints.logical = sha256_fingerprint(&logical_bytes); + let canonical_without_canonical = + serde_json::to_vec(&canonical_json(&serde_json::to_value(&canonical)?))?; + canonical.fingerprints.canonical = sha256_fingerprint(&canonical_without_canonical); + let bytes = serde_json::to_vec(&canonical_json(&serde_json::to_value(&canonical)?))?; + if bytes.len() > MAX_APPLICATION_MANIFEST_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "application manifest exceeds {MAX_APPLICATION_MANIFEST_BYTES} bytes" + ))); + } + Ok(bytes) + } + + pub fn refresh_fingerprints(&mut self) -> ApplicationResult<()> { + self.fingerprints = ManifestFingerprint::default(); + let bytes = self.canonical_bytes()?; + let canonical: Self = serde_json::from_slice(&bytes) + .map_err(|error| ApplicationError::Canonical(error.to_string()))?; + self.fingerprints = canonical.fingerprints; + Ok(()) + } + + pub fn encode(&self) -> ApplicationResult> { + self.canonical_bytes() + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> ApplicationResult { + if bytes.is_empty() || bytes.len() > MAX_APPLICATION_MANIFEST_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "application manifest bytes must be between 1 and {MAX_APPLICATION_MANIFEST_BYTES}" + ))); + } + let manifest: Self = serde_json::from_slice(bytes) + .map_err(|error| ApplicationError::Canonical(error.to_string()))?; + if manifest.schema_version != APPLICATION_MANIFEST_SCHEMA_VERSION { + return Err(ApplicationError::UnsupportedVersion { + expected: APPLICATION_MANIFEST_SCHEMA_VERSION, + actual: manifest.schema_version, + }); + } + manifest.validate_inner(true, true)?; + if manifest.canonical_bytes()? != bytes { + return Err(ApplicationError::NonCanonical("application manifest")); + } + Ok(manifest) + } + + pub fn decode(bytes: &[u8]) -> ApplicationResult { + Self::from_canonical_bytes(bytes) + } + + pub fn fingerprint(&self) -> ApplicationResult { + let bytes = self.canonical_bytes()?; + let manifest = Self::from_canonical_bytes(&bytes)?; + Ok(manifest.fingerprints.canonical) + } + + pub fn validate(&self) -> ApplicationResult<()> { + self.validate_inner(true, false) + } + + /// Return the logical identity, excluding generator/source provenance. + /// The canonical artifact fingerprint still changes when those fields do. + pub fn logical_fingerprint(&self) -> ApplicationResult { + let bytes = self.canonical_bytes()?; + let manifest = Self::from_canonical_bytes(&bytes)?; + Ok(manifest.fingerprints.logical) + } + + fn validate_inner( + &self, + require_nested_fingerprints: bool, + require_manifest_fingerprints: bool, + ) -> ApplicationResult<()> { + if self.schema_version != APPLICATION_MANIFEST_SCHEMA_VERSION { + return Err(ApplicationError::UnsupportedVersion { + expected: APPLICATION_MANIFEST_SCHEMA_VERSION, + actual: self.schema_version, + }); + } + LogicalId::try_new("application", self.name.clone())?; + validate_collection_len("modules", self.modules.len())?; + validate_collection_len("commands", self.commands.len())?; + validate_collection_len("events", self.events.len())?; + validate_collection_len("projections", self.projections.len())?; + 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("event", self.events.iter().map(|event| event.name.clone()))?; + validate_unique_ids( + "projection", + 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()))?; + + let model_ids = self + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(); + let projection_ids = self + .projections + .iter() + .map(|projection| projection.id.as_str()) + .collect::>(); + for module in &self.modules { + validate_module(module, require_nested_fingerprints)?; + } + for command in &self.commands { + command.validate()?; + validate_fingerprint( + "command", + &command.id, + &command.fingerprint, + command, + require_nested_fingerprints, + )?; + if let Some(model) = &command.projected_model { + require_reference("model", model, &model_ids)?; + } + } + for event in &self.events { + validate_event(event)?; + } + for projection in &self.projections { + validate_projection( + projection, + &model_ids, + &projection_ids, + require_nested_fingerprints, + )?; + } + for model in &self.models { + validate_model(model, &model_ids, require_nested_fingerprints)?; + } + for surface in &self.surfaces { + validate_surface( + surface, + &model_ids, + &projection_ids, + require_nested_fingerprints, + )?; + } + for capability in &self.required_capabilities { + validate_portable_text("capability", capability)?; + } + let mut extension_ids = BTreeSet::new(); + for extension in &self.extensions { + if !extension_ids.insert(extension.id.clone()) { + return Err(ApplicationError::Duplicate { + kind: "application extension", + identity: extension.id.clone(), + }); + } + LogicalId::try_new("application extension", extension.id.clone())?; + if extension.version == 0 { + return Err(ApplicationError::InvalidSpec( + "application extension version must be non-zero".into(), + )); + } + validate_json_contract("application extension", &extension.value)?; + } + validate_portable_text("manifest generator", &self.provenance.generator)?; + if let Some(revision) = &self.provenance.source_revision { + validate_artifact_text("source revision", revision)?; + } + for source in &self.provenance.sources { + validate_artifact_text("manifest source", source)?; + } + + let expected = expected_fingerprints(self)?; + if require_manifest_fingerprints + && (self.fingerprints.logical.is_empty() || self.fingerprints.canonical.is_empty()) + { + return Err(ApplicationError::NonCanonical( + "application manifest fingerprint material", + )); + } + if (!self.fingerprints.logical.is_empty() && self.fingerprints.logical != expected.logical) + || (!self.fingerprints.canonical.is_empty() + && self.fingerprints.canonical != expected.canonical) + { + return Err(ApplicationError::NonCanonical( + "application manifest fingerprint material", + )); + } + validate_manifest_ownership(self)?; + Ok(()) + } + + fn canonicalize_collections(&mut self) { + 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(), + )) + }); + 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(); + self.required_capabilities.dedup(); + self.extensions.sort_by(|left, right| { + (left.id.as_str(), left.version).cmp(&(right.id.as_str(), right.version)) + }); + self.provenance.sources.sort(); + self.provenance.sources.dedup(); + } +} + +fn expected_fingerprints(manifest: &ApplicationManifest) -> ApplicationResult { + let mut canonical = manifest.clone(); + canonical.canonicalize_collections(); + canonical.fingerprints = ManifestFingerprint::default(); + let mut logical = canonical.clone(); + logical.provenance = ManifestProvenance::default(); + let logical_bytes = serde_json::to_vec(&canonical_json(&serde_json::to_value(&logical)?))?; + let logical = sha256_fingerprint(&logical_bytes); + canonical.fingerprints.logical = logical.clone(); + let with_logical = serde_json::to_vec(&canonical_json(&serde_json::to_value(&canonical)?))?; + let canonical_fingerprint = sha256_fingerprint(&with_logical); + Ok(ManifestFingerprint { + logical, + canonical: canonical_fingerprint, + }) +} + +fn validate_module( + module: &ModuleManifest, + require_nested_fingerprints: bool, +) -> ApplicationResult<()> { + LogicalId::try_new("module", module.id.clone())?; + validate_collection_len("module commands", module.commands.len())?; + validate_collection_len("module events", module.events.len())?; + validate_collection_len("module projections", module.projections.len())?; + validate_collection_len("module models", module.models.len())?; + validate_collection_len("module surfaces", module.surfaces.len())?; + validate_collection_len( + "module required capabilities", + module.required_capabilities.len(), + )?; + validate_fingerprint( + "module", + &module.id, + &module.fingerprint, + module, + require_nested_fingerprints, + )?; + 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_sorted_unique( + "module commands", + &module.commands.iter().map(|item| item.id.clone()).collect::>(), + )?; + validate_sorted_unique( + "module events", + &module.events.iter().map(|item| item.name.clone()).collect::>(), + )?; + validate_sorted_unique( + "module projections", + &module + .projections + .iter() + .map(|item| item.id.clone()) + .collect::>(), + )?; + validate_sorted_unique( + "module models", + &module.models.iter().map(|item| item.id.clone()).collect::>(), + )?; + validate_sorted_unique( + "module surfaces", + &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()) + })) + .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()) + })) + .collect::>(); + for command in &module.commands { + command.validate()?; + validate_fingerprint( + "command", + &command.id, + &command.fingerprint, + command, + require_nested_fingerprints, + )?; + } + let emitted_events = dedup_events( + module + .commands + .iter() + .flat_map(|command| command.emits.iter().cloned()) + .collect(), + )?; + if emitted_events != module.events { + return Err(ApplicationError::Collision { + kind: "event", + identity: module.id.clone(), + reason: "module event inventory is not closed over command declarations".into(), + }); + } + for event in &module.events { + validate_event(event)?; + } + for projection in &module.projections { + validate_projection( + projection, + &module_model_ids, + &module_projection_ids, + require_nested_fingerprints, + )?; + } + for surface in &module.surfaces { + validate_surface( + surface, + &module_model_ids, + &module_projection_ids, + require_nested_fingerprints, + )?; + } + for model in &module.models { + validate_model(model, &module_model_ids, require_nested_fingerprints)?; + } + for capability in &module.required_capabilities { + validate_portable_text("module capability", capability)?; + } + Ok(()) +} + +fn validate_event(event: &super::command::EventSpec) -> ApplicationResult<()> { + LogicalId::try_new("event", event.name.clone())?; + if event.version == 0 || event.body_version == 0 || event.body_codec_version == 0 { + return Err(ApplicationError::InvalidSpec(format!( + "event `{}` versions must be non-zero", + event.name + ))); + } + validate_portable_text("event body type", &event.body_type)?; + validate_portable_text("event body schema", &event.body_schema)?; + validate_sha256_text("event body fingerprint", &event.body_fingerprint)?; + validate_portable_text("event body codec", &event.body_codec)?; + Ok(()) +} + +fn validate_projection( + projection: &ProjectionSpec, + model_ids: &HashSet<&str>, + projection_ids: &HashSet<&str>, + require_nested_fingerprints: bool, +) -> ApplicationResult<()> { + LogicalId::try_new("projection", projection.id.clone())?; + validate_fingerprint( + "projection", + &projection.id, + &projection.fingerprint, + projection, + require_nested_fingerprints, + )?; + validate_collection_len("projection facts", projection.facts.len())?; + validate_collection_len("projection models", projection.models.len())?; + validate_collection_len("projection dependencies", projection.dependencies.len())?; + validate_collection_len("modeled projections", projection.modeled.len())?; + 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)?; + for fact in &projection.facts { + validate_portable_text("projection fact", fact)?; + } + for model in &projection.models { + validate_portable_text("projection model", model)?; + require_reference("model", model, model_ids)?; + } + validate_json_contract("projection partition", &projection.partition)?; + let mut modeled_ids = Vec::with_capacity(projection.modeled.len()); + for modeled in &projection.modeled { + validate_json_contract("modeled projection", modeled)?; + let fields = modeled.as_object().ok_or_else(|| { + ApplicationError::InvalidSpec("modeled projection must be an object".into()) + })?; + let program_id = fields + .get("program_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + ApplicationError::InvalidSpec( + "modeled projection must retain its program identity".into(), + ) + })?; + validate_portable_text("modeled projection program ID", program_id)?; + modeled_ids.push(program_id.to_owned()); + let output_models = fields + .get("output_models") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + ApplicationError::InvalidSpec( + "modeled projection must retain its output model inventory".into(), + ) + })?; + for output_model in output_models { + let output_model = output_model.as_str().ok_or_else(|| { + ApplicationError::InvalidSpec( + "modeled projection output model identities must be strings".into(), + ) + })?; + require_reference("model", output_model, model_ids)?; + } + } + modeled_ids.sort(); + if modeled_ids != projection.modeled_programs { + return Err(ApplicationError::Collision { + kind: "projection", + identity: projection.id.clone(), + reason: "modeled projection program identities are stale or incomplete".into(), + }); + } + if let Some(catalog) = &projection.catalog_fingerprint { + validate_sha256_text("projection catalog fingerprint", catalog)?; + } + for dependency in &projection.dependencies { + if let Some(identity) = dependency.strip_prefix("projection:") { + LogicalId::try_new("projection", identity.to_owned())?; + require_reference("projection", identity, projection_ids)?; + } else { + validate_portable_text("projection dependency", dependency)?; + } + } + Ok(()) +} + +fn validate_model( + model: &ModelSpec, + model_ids: &HashSet<&str>, + require_nested_fingerprints: bool, +) -> ApplicationResult<()> { + LogicalId::try_new("model", model.id.clone())?; + validate_fingerprint( + "model", + &model.id, + &model.fingerprint, + model, + require_nested_fingerprints, + )?; + validate_portable_text("model table", &model.table)?; + validate_portable_text("model object", &model.object)?; + validate_collection_len("model fields", model.fields.len())?; + validate_collection_len("model relationships", model.relationships.len())?; + if model.role_limit.is_some_and(|limit| limit == 0) { + return Err(ApplicationError::InvalidSpec(format!( + "model `{}` has a zero role limit", + model.id + ))); + } + validate_row_policy(&model.row_policy)?; + let field_names = model + .fields + .iter() + .map(|field| field.name.clone()) + .collect::>(); + validate_sorted_unique("model fields", &field_names)?; + let relationship_names = model + .relationships + .iter() + .map(|relationship| relationship.name.clone()) + .collect::>(); + validate_sorted_unique("model relationships", &relationship_names)?; + if model.primary_key.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "model `{}` must declare a primary key", + model.id + ))); + } + validate_sorted_unique("model primary key", &model.primary_key)?; + 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)?; + } + for key in &model.primary_key { + validate_portable_text("model primary key field", key)?; + if !field_names.contains(key.as_str()) { + return Err(ApplicationError::Missing { + kind: "model field", + identity: key.clone(), + }); + } + } + for relationship in &model.relationships { + validate_portable_text("relationship name", &relationship.name)?; + validate_portable_text("relationship target model", &relationship.target_model)?; + validate_portable_text("relationship target object", &relationship.target_object)?; + if !matches!( + relationship.kind.as_str(), + "hasmany" | "belongsto" | "manytomany" + ) { + return Err(ApplicationError::InvalidSpec(format!( + "relationship `{}` has unknown kind `{}`", + relationship.name, relationship.kind + ))); + } + require_reference("model", &relationship.target_model, model_ids)?; + validate_collection_len("relationship arguments", relationship.arguments.len())?; + let argument_names = relationship + .arguments + .iter() + .map(|argument| argument.name.clone()) + .collect::>(); + validate_unique("relationship arguments", &argument_names)?; + for argument in &relationship.arguments { + validate_surface_argument(argument)?; + } + for dependency in &relationship.dependencies { + validate_portable_text("relationship dependency", dependency)?; + } + validate_sorted_unique("relationship dependencies", &relationship.dependencies)?; + validate_json_contract("relationship keys", &relationship.keys)?; + validate_relationship_keys(&relationship.keys)?; + if let Some(aggregate) = &relationship.aggregate { + validate_portable_text("relationship aggregate", &aggregate.name)?; + validate_portable_text("relationship aggregate type", &aggregate.type_name)?; + validate_collection_len( + "relationship aggregate arguments", + aggregate.arguments.len(), + )?; + let argument_names = aggregate + .arguments + .iter() + .map(|argument| argument.name.clone()) + .collect::>(); + validate_unique("relationship aggregate arguments", &argument_names)?; + for argument in &aggregate.arguments { + validate_surface_argument(argument)?; + } + for dependency in &aggregate.dependencies { + validate_portable_text("relationship aggregate dependency", dependency)?; + } + validate_sorted_unique( + "relationship aggregate dependencies", + &aggregate.dependencies, + )?; + } + } + Ok(()) +} + +fn validate_surface( + surface: &SurfaceSpec, + model_ids: &HashSet<&str>, + projection_ids: &HashSet<&str>, + require_nested_fingerprints: bool, +) -> ApplicationResult<()> { + LogicalId::try_new("surface", surface.id.clone())?; + validate_fingerprint( + "surface", + &surface.id, + &surface.fingerprint, + surface, + require_nested_fingerprints, + )?; + validate_collection_len("surface models", surface.models.len())?; + validate_collection_len("surface roots", surface.roots.len())?; + validate_collection_len("surface commands", surface.commands.len())?; + validate_collection_len("surface projections", surface.projections.len())?; + validate_sorted_unique( + "surface models", + &surface + .models + .iter() + .map(|model| model.id.clone()) + .collect::>(), + )?; + let root_ids = surface + .roots + .iter() + .map(|root| format!("{}:{}", root.operation, root.name)) + .collect::>(); + validate_sorted_unique("surface roots", &root_ids)?; + validate_sorted_unique( + "surface commands", + &surface + .commands + .iter() + .map(|command| command.id.clone()) + .collect::>(), + )?; + validate_sorted_unique( + "surface projections", + &surface + .projections + .iter() + .map(|projection| projection.id.clone()) + .collect::>(), + )?; + validate_json_contract("surface canonical contract", &surface.contract)?; + validate_surface_selection(surface)?; + validate_portable_text("surface dialect", &surface.dialect)?; + if surface.max_limit == 0 || surface.default_limit > surface.max_limit { + return Err(ApplicationError::InvalidSpec(format!( + "surface `{}` has invalid pagination bounds", + surface.id + ))); + } + let surface_models = surface + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(); + let model_scope = if model_ids.is_empty() { + &surface_models + } else { + model_ids + }; + for model in &surface.models { + validate_model(model, model_scope, require_nested_fingerprints)?; + } + for root in &surface.roots { + if !matches!(root.operation.as_str(), "query" | "subscription") { + return Err(ApplicationError::InvalidSpec(format!( + "surface root `{}` has unsupported operation `{}`", + root.name, root.operation + ))); + } + validate_portable_text("surface root operation", &root.operation)?; + validate_portable_text("surface root name", &root.name)?; + validate_portable_text("surface root object", &root.object)?; + if !matches!(root.kind.as_str(), "list" | "by_pk" | "aggregate") { + return Err(ApplicationError::InvalidSpec(format!( + "surface root `{}` has unknown kind `{}`", + root.name, root.kind + ))); + } + LogicalId::try_new("surface root model", root.model.clone())?; + require_reference("model", &root.model, model_scope)?; + validate_collection_len("surface root arguments", root.arguments.len())?; + let argument_names = root + .arguments + .iter() + .map(|argument| argument.name.clone()) + .collect::>(); + validate_unique("surface root arguments", &argument_names)?; + for argument in &root.arguments { + validate_surface_argument(argument)?; + } + for dependency in &root.dependencies { + validate_portable_text("surface root dependency", dependency)?; + } + if let Some(max) = root.max_limit { + if max == 0 || root.default_limit.is_some_and(|default| default > max) { + return Err(ApplicationError::InvalidSpec(format!( + "surface root `{}` has invalid pagination bounds", + root.name + ))); + } + } + } + for command in &surface.commands { + LogicalId::try_new("command", command.id.clone())?; + validate_portable_text("surface command field", &command.field_name)?; + validate_roles("surface command role", &command.roles)?; + if let Some(input) = &command.input { + validate_command_type_spec(input)?; + } + if let Some(output) = &command.output { + validate_command_type_spec(output)?; + } + 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 applies", &command.applies)?; + if let Some(model) = &command.projected_model { + require_reference("model", model, model_scope)?; + } + if let Some(direct_projection) = &command.direct_projection { + validate_json_contract("surface command direct projection", direct_projection)?; + } + } + for projection in &surface.projections { + validate_projection( + projection, + model_scope, + projection_ids, + require_nested_fingerprints, + )?; + } + validate_surface_contract(surface)?; + Ok(()) +} + +fn validate_surface_selection(surface: &SurfaceSpec) -> ApplicationResult<()> { + match surface.selection.as_str() { + "catalog" => { + if !surface.eligible_roles.is_empty() || !surface.schema_roles.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "catalog surface `{}` cannot expose roles", + surface.id + ))); + } + } + "role" => { + if surface.eligible_roles.len() != 1 + || surface.schema_roles.len() != 1 + || surface.eligible_roles != surface.schema_roles + { + return Err(ApplicationError::InvalidSpec(format!( + "role surface `{}` must name exactly one identical eligible and schema role", + surface.id + ))); + } + } + 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() { + return Err(ApplicationError::InvalidSpec(format!( + "application surface `{}` must expose at least one eligible role", + surface.id + ))); + } + if surface.schema_roles.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "application surface `{}` must expose at least one schema role", + surface.id + ))); + } + 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 + ))); + } + } + _ => { + return Err(ApplicationError::InvalidSpec(format!( + "surface `{}` has an invalid selection identity", + surface.id + ))) + } + } + validate_roles("surface eligible role", &surface.eligible_roles)?; + validate_roles("surface schema role", &surface.schema_roles) +} + +fn validate_roles(kind: &'static str, roles: &[String]) -> ApplicationResult<()> { + let mut previous: Option<&str> = None; + for role in roles { + LogicalId::try_new(kind, role.clone())?; + if previous.is_some_and(|previous| previous >= role.as_str()) { + return Err(ApplicationError::NonCanonical("role ordering")); + } + previous = Some(role); + } + Ok(()) +} + +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)?; + if !matches!( + argument.kind.as_str(), + "filter" | "order" | "limit" | "offset" | "primary_key" + ) { + return Err(ApplicationError::InvalidSpec(format!( + "surface argument `{}` has unknown kind `{}`", + argument.name, argument.kind + ))); + } + Ok(()) +} + +fn validate_row_policy(value: &serde_json::Value) -> ApplicationResult<()> { + let Some(fields) = value.as_object() else { + return Err(ApplicationError::InvalidSpec( + "row policy must be a tagged object".into(), + )); + }; + let Some(kind) = fields.get("kind").and_then(serde_json::Value::as_str) else { + return Err(ApplicationError::InvalidSpec( + "row policy must declare its kind".into(), + )); + }; + match kind { + "unrestricted" | "server_only" if fields.len() == 1 => Ok(()), + "predicate" if fields.len() == 2 => { + let expression = fields.get("expression").ok_or_else(|| { + ApplicationError::InvalidSpec( + "predicate row policy must retain its expression".into(), + ) + })?; + validate_json_contract("row policy expression", expression) + } + _ => Err(ApplicationError::InvalidSpec( + "row policy contains unknown or redundant material".into(), + )), + } +} + +fn validate_relationship_keys(value: &serde_json::Value) -> ApplicationResult<()> { + let Some(fields) = value.as_object() else { + return Err(ApplicationError::InvalidSpec( + "relationship keys must be a tagged object".into(), + )); + }; + let Some(kind) = fields.get("kind").and_then(serde_json::Value::as_str) else { + return Err(ApplicationError::InvalidSpec( + "relationship keys must declare their kind".into(), + )); + }; + if kind == "embedded" { + if fields.len() != 1 { + return Err(ApplicationError::InvalidSpec( + "embedded relationship keys contain redundant material".into(), + )); + } + return Ok(()); + } + if !matches!(kind, "direct" | "through" | "through_opaque") { + return Err(ApplicationError::InvalidSpec(format!( + "relationship keys have unknown kind `{kind}`" + ))); + } + let local = fields + .get("local") + .and_then(serde_json::Value::as_array) + .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()))?; + if local.is_empty() || local.len() != remote.len() { + return Err(ApplicationError::InvalidSpec( + "relationship key columns must be non-empty and paired".into(), + )); + } + for column in local.iter().chain(remote) { + let name = column.as_str().ok_or_else(|| { + ApplicationError::InvalidSpec("relationship key columns must be strings".into()) + })?; + validate_portable_text("relationship key column", name)?; + } + let expected_len = match kind { + "direct" => 3, + "through" => 6, + "through_opaque" => 4, + _ => unreachable!(), + }; + if fields.len() != expected_len { + return Err(ApplicationError::InvalidSpec( + "relationship keys contain missing or redundant material".into(), + )); + } + for field in ["table", "source_foreign_key", "target_foreign_key"] { + if kind == "through" && !fields.contains_key(field) { + return Err(ApplicationError::InvalidSpec(format!( + "through relationship keys need `{field}`" + ))); + } + } + if kind == "through_opaque" && !fields.contains_key("dependency") { + return Err(ApplicationError::InvalidSpec( + "opaque relationship keys need a dependency identity".into(), + )); + } + let identity_fields: &[&str] = match kind { + "through" => &["table", "source_foreign_key", "target_foreign_key"], + "through_opaque" => &["dependency"], + "direct" => &[], + _ => unreachable!(), + }; + for field in identity_fields { + let value = fields.get(*field).and_then(serde_json::Value::as_str).ok_or_else(|| { + ApplicationError::InvalidSpec(format!( + "relationship key `{field}` must be a string" + )) + })?; + validate_portable_text("relationship key identity", value)?; + } + Ok(()) +} + +fn validate_command_type_spec( + definition: &super::command::CommandTypeSpec, +) -> ApplicationResult<()> { + validate_command_type_spec_at_depth(definition, 0) +} + +fn validate_command_type_spec_at_depth( + definition: &super::command::CommandTypeSpec, + depth: usize, +) -> ApplicationResult<()> { + if depth > MAX_MANIFEST_JSON_DEPTH { + return Err(ApplicationError::InvalidSpec(format!( + "surface command type exceeds nesting depth {MAX_MANIFEST_JSON_DEPTH}" + ))); + } + validate_portable_text("surface command type", &definition.name)?; + validate_collection_len("surface command type fields", definition.fields.len())?; + let field_names = definition + .fields + .iter() + .map(|field| field.name.clone()) + .collect::>(); + validate_unique("surface command type fields", &field_names)?; + for field in &definition.fields { + validate_portable_text("surface command field", &field.name)?; + validate_portable_text("surface command field type", &field.type_name)?; + if let Some(nested) = &field.nested { + validate_command_type_spec_at_depth(nested, depth + 1)?; + } + } + Ok(()) +} + +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", + )); + } + Ok(()) +} + +fn validate_manifest_ownership(manifest: &ApplicationManifest) -> ApplicationResult<()> { + let mut module_commands = manifest + .modules + .iter() + .flat_map(|module| module.commands.iter().cloned()) + .collect::>(); + module_commands.sort_by(|left, right| left.id.cmp(&right.id)); + let module_commands = dedup_commands(module_commands)?; + if module_commands != manifest.commands { + return Err(ApplicationError::Collision { + kind: "command", + identity: manifest.name.clone(), + reason: "application command inventory does not equal explicit module ownership" + .into(), + }); + } + + let mut module_events = manifest + .modules + .iter() + .flat_map(|module| module.events.iter().cloned()) + .collect::>(); + let module_events = dedup_events(std::mem::take(&mut module_events))?; + if module_events != manifest.events { + return Err(ApplicationError::Collision { + kind: "event", + identity: manifest.name.clone(), + reason: "application event inventory does not equal explicit module ownership" + .into(), + }); + } + + let mut owned_projections = manifest + .modules + .iter() + .flat_map(|module| module.projections.iter().cloned()) + .chain( + manifest + .surfaces + .iter() + .flat_map(|surface| surface.projections.iter().cloned()), + ) + .collect::>(); + let owned_projections = dedup_projections(std::mem::take(&mut owned_projections))?; + if owned_projections != manifest.projections { + return Err(ApplicationError::Collision { + kind: "projection", + identity: manifest.name.clone(), + reason: "application projection inventory is not closed over modules and surfaces" + .into(), + }); + } + + let mut owned_models = manifest + .modules + .iter() + .flat_map(|module| module.models.iter().cloned()) + .chain( + manifest + .surfaces + .iter() + .flat_map(|surface| surface.models.iter().cloned()), + ) + .collect::>(); + let owned_models = dedup_models(std::mem::take(&mut owned_models))?; + if owned_models != manifest.models { + return Err(ApplicationError::Collision { + kind: "model", + identity: manifest.name.clone(), + reason: "application model inventory is not closed over modules and surfaces".into(), + }); + } + + for module in &manifest.modules { + for owned_surface in &module.surfaces { + let Some(surface) = manifest.surfaces.iter().find(|surface| surface.id == owned_surface.id) + else { + return Err(ApplicationError::Missing { + kind: "surface", + identity: owned_surface.id.clone(), + }); + }; + if surface != owned_surface { + return Err(ApplicationError::Collision { + kind: "surface", + identity: surface.id.clone(), + reason: "surface ownership has incompatible canonical material".into(), + }); + } + } + } + + let commands = manifest + .commands + .iter() + .map(|command| (command.id.as_str(), command)) + .collect::>(); + let models = manifest + .models + .iter() + .map(|model| (model.id.as_str(), model)) + .collect::>(); + let projections = manifest + .projections + .iter() + .map(|projection| (projection.id.as_str(), projection)) + .collect::>(); + for surface in &manifest.surfaces { + for exposed in &surface.models { + let Some(authoritative) = models.get(exposed.id.as_str()) else { + return Err(ApplicationError::Missing { + kind: "model", + identity: exposed.id.clone(), + }); + }; + if *authoritative != exposed { + return Err(ApplicationError::Collision { + kind: "model", + identity: exposed.id.clone(), + reason: "surface model differs from the application model declaration".into(), + }); + } + } + for exposed in &surface.projections { + let Some(authoritative) = projections.get(exposed.id.as_str()) else { + return Err(ApplicationError::Missing { + kind: "projection", + identity: exposed.id.clone(), + }); + }; + if *authoritative != exposed { + return Err(ApplicationError::Collision { + kind: "projection", + identity: exposed.id.clone(), + reason: "surface projection differs from the application projection declaration" + .into(), + }); + } + } + let expected_command_ids = surface_command_closure(surface, &manifest.commands)?; + let actual_command_ids = surface + .commands + .iter() + .map(|command| command.id.clone()) + .collect::>(); + if actual_command_ids != expected_command_ids { + return Err(ApplicationError::Collision { + kind: "command", + identity: surface.id.clone(), + reason: "surface command inventory is not the exact authorized command closure" + .into(), + }); + } + for exposed in &surface.commands { + let Some(authoritative) = commands.get(exposed.id.as_str()) else { + return Err(ApplicationError::Missing { + kind: "command", + identity: exposed.id.clone(), + }); + }; + let expected_roles = surface_command_roles(surface, authoritative); + validate_surface_command_ownership(exposed, authoritative, &expected_roles)?; + } + for root in &surface.roots { + if !models.contains_key(root.model.as_str()) { + return Err(ApplicationError::Missing { + kind: "model", + identity: root.model.clone(), + }); + } + } + } + Ok(()) +} + +fn validate_surface_command_ownership( + exposed: &super::module::SurfaceCommandSpec, + authoritative: &super::command::CommandSpec, + expected_roles: &[String], +) -> ApplicationResult<()> { + if exposed.field_name != authoritative.field_name + || exposed.roles != expected_roles + || exposed.input.as_ref() != Some(&authoritative.input) + || exposed.output.as_ref() != Some(&authoritative.output) + || exposed.consistency != authoritative.consistency + || canonical_json(&exposed.defaults) != canonical_json(&authoritative.defaults) + || canonical_json(&exposed.effects) != canonical_json(&authoritative.effects) + || canonical_json(&exposed.applies) != canonical_json(&authoritative.applies) + || canonical_json(&exposed.projection_contract) + != canonical_json(&authoritative.projection_contract) + || exposed.direct_projection != authoritative.direct_projection + || exposed.projected_model != authoritative.projected_model + { + return Err(ApplicationError::Collision { + kind: "command", + identity: authoritative.id.clone(), + reason: "surface command is not compatible with its application declaration".into(), + }); + } + let authoritative_confirmations = serde_json::to_value(&authoritative.confirmations)?; + if canonical_json(&exposed.confirmations) != canonical_json(&authoritative_confirmations) { + return Err(ApplicationError::Collision { + kind: "command", + identity: authoritative.id.clone(), + reason: "surface command confirmation material is stale".into(), + }); + } + Ok(()) +} + +fn surface_command_closure( + surface: &SurfaceSpec, + commands: &[super::command::CommandSpec], +) -> ApplicationResult> { + let mut expected = commands + .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) + }), + value if value.starts_with("application:") => { + command.roles.is_empty() + || surface.schema_roles.iter().all(|role| { + command.roles.iter().any(|allowed| allowed == role) + }) + } + _ => false, + }) + .map(|command| command.id.clone()) + .collect::>(); + expected.sort(); + expected.dedup(); + Ok(expected) +} + +fn surface_command_roles( + surface: &SurfaceSpec, + authoritative: &super::command::CommandSpec, +) -> Vec { + match surface.selection.as_str() { + "catalog" => authoritative.roles.clone(), + "role" => surface.eligible_roles.clone(), + value if value.starts_with("application:") => surface.eligible_roles.clone(), + _ => Vec::new(), + } +} + +fn surface_contract_from_spec(surface: &SurfaceSpec) -> ApplicationResult { + let selection = match surface.selection.as_str() { + "catalog" => serde_json::json!({"kind": "catalog"}), + "role" => serde_json::json!({ + "kind": "role", + "name": surface.eligible_roles.first().ok_or_else(|| { + ApplicationError::InvalidSpec("role surface has no role identity".into()) + })?, + }), + value => serde_json::json!({ + "kind": "application", + "name": value.strip_prefix("application:").ok_or_else(|| { + ApplicationError::InvalidSpec("surface selection is not canonical".into()) + })?, + "eligible_roles": surface.eligible_roles, + "schema_roles": surface.schema_roles, + }), + }; + let models = surface + .models + .iter() + .map(|model| { + serde_json::json!({ + "model_name": model.id, + "table_name": model.table, + "object_name": model.object, + "columns": model.fields, + "relationships": model.relationships, + "primary_key": model.primary_key, + "row_policy": model.row_policy, + "role_limit": model.role_limit, + "aggregations": model.aggregations, + }) + }) + .collect::>(); + let roots = surface + .roots + .iter() + .map(|root| { + serde_json::json!({ + "operation": root.operation, + "name": root.name, + "kind": root.kind, + "object": root.object, + "model_name": root.model, + "arguments": root.arguments, + "dependencies": root.dependencies, + "default_limit": root.default_limit, + "max_limit": root.max_limit, + }) + }) + .collect::>(); + let commands = surface + .commands + .iter() + .map(|command| { + serde_json::json!({ + "command_name": command.id, + "field_name": command.field_name, + "roles": command.roles, + "input": surface_command_type_value(command.input.as_ref()), + "output": surface_command_type_value(command.output.as_ref()), + "consistency": command.consistency, + "input_defaults": command.defaults, + "effects": command.effects, + "confirmations": command.confirmations, + "projected_model": command.projected_model, + "direct_projection": command.direct_projection, + "projections": command.projection_contract, + "confirmation_unavailable": command.confirmation_unavailable, + }) + }) + .collect::>(); + let projectors = surface + .projections + .iter() + .map(|projection| { + serde_json::json!({ + "name": projection.id, + "facts": projection.facts, + "models": projection.models, + "dependencies": projection.dependencies, + "change_epoch": projection.change_epoch, + "partition": projection.partition, + "kind": if projection.direct { "direct" } else { "async" }, + "modeled": projection.modeled, + }) + }) + .collect::>(); + Ok(canonical_json(&serde_json::json!({ + "version": 1, + "selection": selection, + "dialect": surface.dialect, + "aggregates": surface.aggregates, + "subscriptions": surface.subscriptions, + "default_limit": surface.default_limit, + "max_limit": surface.max_limit, + "models": models, + "roots": roots, + "comparison_ops": surface.comparison_ops, + "commands": commands, + "commands_attached": surface.commands_attached, + "projectors": projectors, + "projectors_attached": surface.projectors_attached, + }))) +} + +fn surface_command_type_value( + definition: Option<&super::command::CommandTypeSpec>, +) -> serde_json::Value { + let Some(definition) = definition else { + return serde_json::Value::Null; + }; + serde_json::json!({ + "name": definition.name, + "fields": definition.fields.iter().map(|field| { + serde_json::json!({ + "name": field.name, + "type_name": field.type_name, + "nullable": field.nullable, + "list": field.list, + "item_nullable": field.item_nullable, + "nested": field.nested.as_deref().map(|nested| surface_command_type_value(Some(nested))), + }) + }).collect::>(), + }) +} + +fn validate_fingerprint( + kind: &'static str, + identity: &str, + fingerprint: &str, + value: &T, + required: bool, +) -> ApplicationResult<()> { + if fingerprint.is_empty() { + if required { + return Err(ApplicationError::NonCanonical(match kind { + "module" => "module fingerprint", + "surface" => "surface fingerprint", + "projection" => "projection fingerprint", + "model" => "model fingerprint", + "command" => "command fingerprint", + _ => "nested fingerprint", + })); + } + return Ok(()); + } + 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())); + } + let expected = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); + if expected != fingerprint { + return Err(ApplicationError::NonCanonical(match kind { + "module" => "module fingerprint", + "surface" => "surface fingerprint", + "projection" => "projection fingerprint", + "model" => "model fingerprint", + "command" => "command fingerprint", + _ => "nested fingerprint", + })); + } + let _ = identity; + Ok(()) +} + +fn require_reference( + kind: &'static str, + identity: &str, + references: &HashSet<&str, T>, +) -> ApplicationResult<()> { + if !references.contains(identity) { + return Err(ApplicationError::Missing { + kind, + identity: identity.into(), + }); + } + Ok(()) +} + +fn validate_collection_len(kind: &'static str, length: usize) -> ApplicationResult<()> { + if length > MAX_MANIFEST_COLLECTION_ITEMS { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} count exceeds {MAX_MANIFEST_COLLECTION_ITEMS}" + ))); + } + Ok(()) +} + +fn validate_portable_text(kind: &'static str, value: &str) -> ApplicationResult<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > MAX_MANIFEST_STRING_BYTES + || value.contains('\0') + { + return Err(ApplicationError::InvalidIdentity { + kind, + value: value.into(), + reason: "must be a bounded portable logical value", + }); + } + Ok(()) +} + +fn validate_artifact_text(kind: &'static str, value: &str) -> ApplicationResult<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > MAX_MANIFEST_STRING_BYTES + || value.contains('\0') + { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must be a bounded artifact provenance value" + ))); + } + Ok(()) +} + +fn validate_sha256_text(kind: &'static str, value: &str) -> ApplicationResult<()> { + validate_portable_text(kind, value)?; + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must use the sha256:<64 lowercase hex> form" + ))); + }; + if hex.len() != 64 + || !hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must use the sha256:<64 lowercase hex> form" + ))); + } + Ok(()) +} + +fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> ApplicationResult<()> { + let bytes = serde_json::to_vec(value)?; + if bytes.len() > MAX_MANIFEST_JSON_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} exceeds {MAX_MANIFEST_JSON_BYTES} JSON bytes" + ))); + } + 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}" + ))); + } + match value { + serde_json::Value::String(value) => { + if value.len() > MAX_MANIFEST_STRING_BYTES || value.contains('\0') { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains oversized or NUL string material" + ))); + } + } + serde_json::Value::Array(values) => { + validate_collection_len(kind, values.len())?; + for value in values { + walk(kind, value, depth + 1)?; + } + } + serde_json::Value::Object(fields) => { + validate_collection_len(kind, fields.len())?; + for (key, value) in fields { + if key.len() > MAX_MANIFEST_STRING_BYTES || key.contains('\0') { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains oversized or NUL object-key material" + ))); + } + walk(kind, value, depth + 1)?; + } + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + } + Ok(()) + } + walk(kind, value, 0) +} + +fn dedup_commands(mut values: Vec) -> ApplicationResult> { + values.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique_ids("command", values.iter().map(|value| value.id.clone()))?; + Ok(values) +} + +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(), + )) + }); + 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 *existing != value { + return Err(ApplicationError::Collision { + kind: "event", + identity: value.name, + reason: "same identity has incompatible event schemas".into(), + }); + } + } else { + out.push(value); + } + } + Ok(out) +} + +fn dedup_projections(mut values: Vec) -> ApplicationResult> { + values.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique_ids("projection", values.iter().map(|value| value.id.clone()))?; + Ok(values) +} + +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 *existing != value { + return Err(ApplicationError::Collision { + kind: "model", + identity: value.id, + reason: "same identity has incompatible model schemas".into(), + }); + } + } else { + out.push(value); + } + } + Ok(out) +} + +fn dedup_surfaces(mut values: Vec) -> ApplicationResult> { + values.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique_ids("surface", values.iter().map(|value| value.id.clone()))?; + Ok(values) +} + +fn validate_unique_ids( + kind: &'static str, + ids: impl IntoIterator, +) -> ApplicationResult<()> { + let mut seen = BTreeSet::new(); + for identity in ids { + if !seen.insert(identity.clone()) { + return Err(ApplicationError::Duplicate { kind, identity }); + } + } + Ok(()) +} + +fn validate_unique(kind: &'static str, identities: &[String]) -> ApplicationResult<()> { + let mut seen = BTreeSet::new(); + for identity in identities { + if !seen.insert(identity) { + return Err(ApplicationError::Duplicate { + kind, + identity: identity.clone(), + }); + } + } + Ok(()) +} + +fn validate_sorted_unique(kind: &'static str, identities: &[String]) -> ApplicationResult<()> { + validate_unique(kind, identities)?; + if identities.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(ApplicationError::NonCanonical(kind)); + } + Ok(()) +} diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 000000000..c048e1098 --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1,86 @@ +//! Placement-independent application composition. +//! +//! The application module is deliberately feature-light. It contains the +//! portable contract values used by contract-only packages as well as the +//! erased executable mounts used at a heterogeneous runtime boundary. SQL, +//! HTTP, GraphQL execution, brokers, and deployment clients are not required +//! to construct these values. + +mod capability; +mod command; +mod error; +mod identity; +mod manifest; +mod module; +mod mount; +mod plan; +mod registration; +mod runtime_host; +mod topology; + +pub use capability::{ + Capability, CapabilityReason, CapabilityRequirement, SchemaLifecycleRequirement, +}; +pub use command::{ + CommandDefinition, CommandMount, CommandMountFuture, CommandMountHandler, CommandMountRegistrar, + CommandSpec, CommandTypeField, CommandTypeSpec, EventSpec, TypeSpec, +}; +pub(crate) use command::{ + CommandMountExecution, CommandMountExecutionError, CommandMountExecutionFuture, + CommandMountExecutionResult, CommandMountInvocation, +}; +pub use error::{ApplicationError, ApplicationResult}; +pub use identity::{canonical_json, sha256_fingerprint, LogicalId}; +pub use manifest::{ + ApplicationExtension, ApplicationManifest, ManifestFingerprint, ManifestProvenance, + APPLICATION_MANIFEST_SCHEMA_VERSION, MAX_APPLICATION_MANIFEST_BYTES, + MAX_MANIFEST_COLLECTION_ITEMS, MAX_MANIFEST_JSON_BYTES, MAX_MANIFEST_JSON_DEPTH, + MAX_MANIFEST_STRING_BYTES, +}; +pub use module::{ + ModelFieldSpec, ModelRelationshipSpec, ModelSpec, Module, ModuleBuilder, ModuleManifest, + ProjectionSpec, SurfaceAggregateSpec, SurfaceArgumentSpec, SurfaceCommandSpec, SurfaceRootSpec, + SurfaceSpec, +}; +pub use mount::{MountSelector, ProcessPreset}; +pub use plan::{ + compile_deployment_plan, DeploymentPlan, PlanFingerprint, ProcessIntent, ProcessPlan, + DEPLOYMENT_PLAN_SCHEMA_VERSION, MAX_DEPLOYMENT_PLAN_BYTES, +}; +pub use registration::{Application, ApplicationBuilder, ContractCompiler}; +pub use runtime_host::{bind_single_process, CapabilityProviders, RuntimeHost}; +pub use topology::TopologyIntent; + +/// Compile-time duplicate check emitted by the module macro for generated +/// command definition IDs. It compares the complete IDs, not a truncated or +/// hashed surrogate, so a hash collision cannot reject a valid module or hide +/// a duplicate declaration. +pub const fn assert_unique_command_ids(ids: &[&str]) { + let mut index = 0; + while index < ids.len() { + let mut other = index + 1; + while other < ids.len() { + if same_const_str(ids[index], ids[other]) { + panic!("duplicate command identity in module declaration"); + } + other += 1; + } + index += 1; + } +} + +const fn same_const_str(left: &str, right: &str) -> bool { + let left = left.as_bytes(); + let right = right.as_bytes(); + if left.len() != right.len() { + return false; + } + let mut index = 0; + while index < left.len() { + if left[index] != right[index] { + return false; + } + index += 1; + } + true +} diff --git a/src/application/module.rs b/src/application/module.rs new file mode 100644 index 000000000..44993f33c --- /dev/null +++ b/src/application/module.rs @@ -0,0 +1,1053 @@ +use std::collections::{BTreeMap, BTreeSet}; + +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::graphql::surface::{ + RootKind, Surface, SurfaceArgument, SurfaceCommand, SurfaceCommandShape, + SurfaceProjectionOwner, SurfaceRelationshipKeys, SurfaceSelection, SurfaceTypeDef, +}; + +/// One exposed model field in a portable surface/module artifact. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelFieldSpec { + pub name: String, + pub scalar: String, + pub nullable: bool, +} + +/// Stable model identity and authorized field inventory. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelSpec { + pub id: String, + pub table: String, + pub object: String, + pub fields: Vec, + pub primary_key: Vec, + pub relationships: Vec, + pub row_policy: serde_json::Value, + pub role_limit: Option, + pub aggregations: bool, + pub fingerprint: String, +} + +/// Complete authorized relationship material retained by a model contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelRelationshipSpec { + pub name: String, + pub target_model: String, + pub target_object: String, + pub kind: String, + pub list: bool, + pub nullable: bool, + pub arguments: Vec, + pub keys: serde_json::Value, + pub dependencies: Vec, + pub aggregate: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceArgumentSpec { + pub name: String, + pub kind: String, + pub type_name: String, + pub nullable: bool, + pub list: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceAggregateSpec { + pub name: String, + pub type_name: String, + pub arguments: Vec, + pub dependencies: Vec, +} + +impl ModelSpec { + /// Construct a minimal portable model contract for composition tests and + /// plan fixtures. Surfaces may still re-author richer model material. + pub fn try_new( + id: impl Into, + table: impl Into, + fields: impl IntoIterator, + primary_key: impl IntoIterator>, + ) -> ApplicationResult { + let id = LogicalId::try_new("model", id)?.into_string(); + 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::>(); + primary_key.sort(); + primary_key.dedup(); + if fields.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "model `{id}` must declare at least one field" + ))); + } + if primary_key.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "model `{id}` must declare a primary key" + ))); + } + let mut spec = Self { + id: id.clone(), + table, + object: id, + fields, + primary_key, + relationships: Vec::new(), + row_policy: serde_json::json!({ "kind": "unrestricted" }), + role_limit: None, + aggregations: false, + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + Ok(spec) + } + + fn refresh_fingerprint(&mut 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()), + ); + } + self.fingerprint = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); + Ok(()) + } + +} + +/// Portable projection-owner identity aggregated into a module. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectionSpec { + pub id: String, + pub facts: Vec, + pub models: Vec, + pub dependencies: Vec, + pub direct: bool, + pub change_epoch: Option, + pub modeled_programs: Vec, + /// Canonical behavior-affecting program/binding material selected from the + /// authoritative Surface IR. Runtime routes and physical service config + /// are intentionally absent. + #[serde(default)] + pub modeled: Vec, + #[serde(default)] + pub partition: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_fingerprint: Option, + pub fingerprint: String, +} + +impl ProjectionSpec { + pub fn try_new( + id: impl Into, + facts: impl IntoIterator>, + models: impl IntoIterator>, + ) -> ApplicationResult { + let id = LogicalId::try_new("projection", id)?.into_string(); + let mut spec = Self { + id, + facts: facts.into_iter().map(Into::into).collect(), + models: models.into_iter().map(Into::into).collect(), + dependencies: Vec::new(), + direct: false, + change_epoch: None, + modeled_programs: Vec::new(), + modeled: Vec::new(), + partition: serde_json::Value::Null, + catalog_fingerprint: None, + fingerprint: String::new(), + }; + spec.canonicalize(); + spec.refresh_fingerprint()?; + Ok(spec) + } + + /// Mark the projection as direct (Atomic seal) and refresh its fingerprint. + pub fn with_direct(mut self, direct: bool) -> ApplicationResult { + self.direct = direct; + self.canonicalize(); + self.refresh_fingerprint()?; + Ok(self) + } + + fn from_owner(owner: &SurfaceProjectionOwner) -> ApplicationResult { + let mut spec = Self { + id: LogicalId::try_new("projection", owner.name.clone())?.into_string(), + facts: owner.facts.clone(), + models: owner.models.clone(), + dependencies: owner.dependencies.clone(), + direct: owner.is_direct(), + change_epoch: owner.change_epoch.clone(), + modeled_programs: owner + .modeled + .iter() + .map(|modeled| modeled.program_id().to_string()) + .collect(), + modeled: owner + .modeled + .iter() + .map(|modeled| modeled.canonical_contract_value()) + .collect::, _>>() + .map_err(ApplicationError::InvalidSpec)?, + partition: serde_json::to_value(&owner.partition)?, + catalog_fingerprint: None, + fingerprint: String::new(), + }; + spec.canonicalize(); + spec.refresh_fingerprint()?; + Ok(spec) + } + + /// Reference an existing canonical projection catalog without copying + /// executable routes or handler values into the module artifact. + pub fn from_catalog( + id: impl Into, + catalog: &crate::projection::catalog::ProjectionCatalog, + ) -> ApplicationResult { + let bytes = catalog + .canonical_bytes() + .map_err(|error| ApplicationError::Canonical(error.to_string()))?; + let mut spec = Self::try_new(id, Vec::::new(), Vec::::new())?; + spec.catalog_fingerprint = Some(sha256_fingerprint(&bytes)); + spec.refresh_fingerprint()?; + Ok(spec) + } + + fn canonicalize(&mut self) { + self.facts.sort(); + self.facts.dedup(); + self.models.sort(); + self.models.dedup(); + self.dependencies.sort(); + self.dependencies.dedup(); + self.modeled_programs.sort(); + self.modeled_programs.dedup(); + } + + fn refresh_fingerprint(&mut 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()), + ); + } + self.fingerprint = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); + Ok(()) + } + + /// Canonical projection identity material with the derived fingerprint + /// removed. This is the same byte source used by generated projection + /// owners and by manifest validation. + 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())); + } + serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) + } + +} + +/// A root field identity retained by a surface contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceRootSpec { + pub operation: String, + pub name: String, + pub kind: String, + pub object: String, + pub model: String, + pub dependencies: Vec, + pub arguments: Vec, + pub default_limit: Option, + pub max_limit: Option, +} + +/// A command shape as exposed by a selected surface. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceCommandSpec { + pub id: String, + pub field_name: String, + pub roles: Vec, + pub input: Option, + pub output: Option, + pub consistency: crate::graphql::CommandConsistency, + pub defaults: serde_json::Value, + pub effects: serde_json::Value, + pub confirmations: serde_json::Value, + pub projection_contract: serde_json::Value, + pub applies: serde_json::Value, + pub direct_projection: Option, + pub projected_model: Option, + pub confirmation_unavailable: bool, +} + +/// Authorized, placement-independent surface identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceSpec { + pub id: String, + pub selection: String, + pub eligible_roles: Vec, + pub schema_roles: Vec, + pub models: Vec, + pub roots: Vec, + pub commands: Vec, + pub projections: Vec, + pub dialect: String, + pub aggregates: bool, + pub subscriptions: bool, + pub default_limit: u64, + pub max_limit: u64, + pub comparison_ops: BTreeMap>, + pub commands_attached: bool, + pub projectors_attached: bool, + /// Canonical lossless Surface IR snapshot. It is the identity authority + /// for SDL/client compilation and must never be reconstructed from tables. + pub contract: serde_json::Value, + pub fingerprint: String, +} + +impl SurfaceSpec { + pub fn try_new(id: impl Into) -> ApplicationResult { + let id = LogicalId::try_new("surface", id)?.into_string(); + let mut spec = Self { + id, + selection: "catalog".into(), + eligible_roles: Vec::new(), + schema_roles: Vec::new(), + models: Vec::new(), + roots: Vec::new(), + commands: Vec::new(), + projections: Vec::new(), + dialect: "sqlite".into(), + aggregates: true, + subscriptions: true, + default_limit: 100, + max_limit: 1000, + comparison_ops: BTreeMap::new(), + commands_attached: false, + projectors_attached: false, + contract: serde_json::json!({ + "version": 1, + "selection": {"kind": "catalog"}, + "dialect": "sqlite", + "aggregates": true, + "subscriptions": true, + "default_limit": 100, + "max_limit": 1000, + "models": [], + "roots": [], + "comparison_ops": {}, + "commands": [], + "commands_attached": false, + "projectors": [], + "projectors_attached": false, + }), + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + Ok(spec) + } + + /// Compile the already-authorized Surface IR without constructing a + /// repository, service, lock manager, or handler mount. + pub fn from_surface(id: impl Into, surface: &Surface) -> ApplicationResult { + let id = LogicalId::try_new("surface", id)?.into_string(); + let (selection, mut eligible_roles, mut schema_roles) = match &surface.selection { + SurfaceSelection::Catalog => ("catalog".to_owned(), Vec::new(), Vec::new()), + SurfaceSelection::Role { name } => { + ("role".to_owned(), vec![name.clone()], vec![name.clone()]) + } + SurfaceSelection::Application { + name, + eligible_roles, + schema_roles, + } => ( + format!("application:{name}"), + eligible_roles.clone(), + schema_roles.clone(), + ), + }; + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); + + let mut models = surface + .models + .values() + .map(|model| { + let mut spec = ModelSpec { + id: LogicalId::try_new("model", model.model_name.clone())?.into_string(), + table: model.table_name.clone(), + object: model.object_name.clone(), + fields: model + .columns + .iter() + .map(|field| ModelFieldSpec { + name: field.name.clone(), + scalar: field.scalar.clone(), + nullable: field.nullable, + }) + .collect(), + primary_key: model.primary_key.clone(), + relationships: model + .relationships + .iter() + .map(surface_relationship_spec) + .collect::>>()?, + row_policy: surface_row_policy(&model.row_policy)?, + role_limit: model.role_limit, + aggregations: model.aggregations, + fingerprint: String::new(), + }; + 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.refresh_fingerprint()?; + Ok(spec) + }) + .collect::>>()?; + models.sort_by(|left, right| left.id.cmp(&right.id)); + + let mut roots = surface + .query_fields + .iter() + .map(|root| ("query", root)) + .chain(surface.subscription_fields.iter().map(|root| ("subscription", root))) + .map(|(operation, root)| SurfaceRootSpec { + operation: operation.into(), + name: root.name.clone(), + kind: match root.kind { + RootKind::List => "list", + RootKind::ByPk => "by_pk", + RootKind::Aggregate => "aggregate", + } + .into(), + object: root.object.clone(), + model: root.model_name.clone(), + dependencies: root.dependencies.clone(), + arguments: root.arguments.iter().map(surface_argument_spec).collect(), + default_limit: root.default_limit, + max_limit: root.max_limit, + }) + .collect::>(); + roots.sort_by(|left, right| { + (left.operation.as_str(), left.name.as_str()) + .cmp(&(right.operation.as_str(), right.name.as_str())) + }); + + let mut commands = surface + .commands + .iter() + .map(surface_command_spec) + .collect::>>()?; + commands.sort_by(|left, right| left.id.cmp(&right.id)); + + let mut projections = surface + .projectors + .iter() + .map(ProjectionSpec::from_owner) + .collect::>>()?; + projections.sort_by(|left, right| left.id.cmp(&right.id)); + + let mut spec = Self { + id, + selection, + eligible_roles, + schema_roles, + models, + roots, + commands, + projections, + dialect: format!("{:?}", surface.dialect).to_ascii_lowercase(), + aggregates: surface.aggregates, + subscriptions: surface.subscriptions, + default_limit: surface.default_limit, + max_limit: surface.max_limit, + comparison_ops: surface.comparison_ops.clone(), + commands_attached: surface.commands_attached, + projectors_attached: surface.projectors_attached, + contract: surface + .canonical_contract_value() + .map_err(ApplicationError::InvalidSpec)?, + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + Ok(spec) + } + + fn refresh_fingerprint(&mut 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()), + ); + } + self.fingerprint = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); + Ok(()) + } + + 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())); + } + serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) + } +} + +fn surface_command_spec(command: &SurfaceCommand) -> ApplicationResult { + Ok(SurfaceCommandSpec { + id: LogicalId::try_new("command", command.command_name.clone())?.into_string(), + field_name: command.field_name.clone(), + roles: command.roles.clone(), + input: surface_shape_spec(&command.input), + output: surface_shape_spec(&command.output), + consistency: command.consistency, + defaults: serde_json::to_value(&command.input_defaults)?, + effects: serde_json::to_value(&command.effects)?, + confirmations: serde_json::to_value(&command.confirmations)?, + projection_contract: serde_json::to_value(&command.projections)?, + applies: serde_json::to_value(&command.projections.previews)?, + direct_projection: command + .direct_projection + .as_ref() + .map(crate::graphql::command_contract::CommandDirectProjectionTarget::canonical_value), + projected_model: command + .projected_model + .as_ref() + .map(|model| model.model.clone()), + confirmation_unavailable: command.confirmation_unavailable, + }) +} + +fn surface_argument_spec(argument: &SurfaceArgument) -> SurfaceArgumentSpec { + SurfaceArgumentSpec { + name: argument.name.clone(), + kind: match argument.kind { + crate::graphql::surface::SurfaceArgumentKind::Filter => "filter", + crate::graphql::surface::SurfaceArgumentKind::Order => "order", + crate::graphql::surface::SurfaceArgumentKind::Limit => "limit", + crate::graphql::surface::SurfaceArgumentKind::Offset => "offset", + crate::graphql::surface::SurfaceArgumentKind::PrimaryKey => "primary_key", + } + .into(), + type_name: argument.type_name.clone(), + nullable: argument.nullable, + list: argument.list, + } +} + +fn surface_relationship_spec( + relationship: &crate::graphql::surface::RelField, +) -> ApplicationResult { + Ok(ModelRelationshipSpec { + name: relationship.name.clone(), + target_model: relationship.target_model.clone(), + target_object: relationship.target_object.clone(), + kind: format!("{:?}", relationship.kind).to_ascii_lowercase(), + list: relationship.list, + nullable: relationship.nullable, + arguments: relationship + .arguments + .iter() + .map(surface_argument_spec) + .collect(), + keys: surface_relationship_keys(&relationship.keys), + dependencies: relationship.dependencies.clone(), + aggregate: relationship.aggregate.as_ref().map(|aggregate| SurfaceAggregateSpec { + name: aggregate.name.clone(), + type_name: aggregate.type_name.clone(), + arguments: aggregate + .arguments + .iter() + .map(surface_argument_spec) + .collect(), + dependencies: aggregate.dependencies.clone(), + }), + }) +} + +fn surface_relationship_keys(keys: &SurfaceRelationshipKeys) -> serde_json::Value { + match keys { + SurfaceRelationshipKeys::Direct { local, remote } => { + serde_json::json!({"kind": "direct", "local": local, "remote": remote}) + } + SurfaceRelationshipKeys::Through { + local, + remote, + table, + source_foreign_key, + target_foreign_key, + } => serde_json::json!({ + "kind": "through", + "local": local, + "remote": remote, + "table": table, + "source_foreign_key": source_foreign_key, + "target_foreign_key": target_foreign_key, + }), + SurfaceRelationshipKeys::ThroughOpaque { + local, + remote, + dependency, + } => serde_json::json!({ + "kind": "through_opaque", + "local": local, + "remote": remote, + "dependency": dependency, + }), + SurfaceRelationshipKeys::Embedded => serde_json::json!({"kind": "embedded"}), + } +} + +fn surface_row_policy( + policy: &crate::graphql::surface::SurfaceRowPolicy, +) -> ApplicationResult { + Ok(match policy { + crate::graphql::surface::SurfaceRowPolicy::Unrestricted => { + serde_json::json!({"kind": "unrestricted"}) + } + crate::graphql::surface::SurfaceRowPolicy::Predicate(predicate) => serde_json::json!({ + "kind": "predicate", + "expression": predicate, + }), + crate::graphql::surface::SurfaceRowPolicy::ServerOnly => { + serde_json::json!({"kind": "server_only"}) + } + }) +} + +fn surface_shape_spec(shape: &SurfaceCommandShape) -> Option { + match shape { + SurfaceCommandShape::None => None, + SurfaceCommandShape::Typed(definition) => Some(surface_type_spec(definition)), + } +} + +fn surface_type_spec(definition: &SurfaceTypeDef) -> CommandTypeSpec { + CommandTypeSpec { + name: definition.name.clone(), + fields: definition + .fields + .iter() + .map(|field| super::command::CommandTypeField { + name: field.name.clone(), + type_name: field.type_name.clone(), + nullable: field.nullable, + list: field.list, + item_nullable: field.item_nullable, + nested: field.nested.as_deref().map(surface_type_spec).map(Box::new), + }) + .collect(), + } +} + +/// The serializable logical portion of a module. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModuleManifest { + pub id: String, + pub commands: Vec, + pub events: Vec, + pub projections: Vec, + pub models: Vec, + pub surfaces: Vec, + pub required_capabilities: Vec, + pub fingerprint: String, +} + +impl ModuleManifest { + 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()), + ); + } + serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) + } +} + +/// One explicit logical composition unit with optional executable mounts. +pub struct Module { + manifest: ModuleManifest, + mounts: Vec, + definitions: Vec, +} + +impl Clone for Module { + fn clone(&self) -> Self { + Self { + manifest: self.manifest.clone(), + mounts: self.mounts.clone(), + definitions: self.definitions.clone(), + } + } +} + +impl std::fmt::Debug for Module { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Module") + .field("id", &self.manifest.id) + .field("commands", &self.manifest.commands.len()) + .field("mounts", &self.mounts.len()) + .finish() + } +} + +impl serde::Serialize for Module { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.manifest.serialize(serializer) + } +} + +impl Module { + pub fn new(id: impl Into) -> ModuleBuilder { + ModuleBuilder::new(id) + } + + pub fn builder(id: impl Into) -> ModuleBuilder { + ModuleBuilder::new(id) + } + + pub fn manifest(&self) -> &ModuleManifest { + &self.manifest + } + + pub fn id(&self) -> &str { + &self.manifest.id + } + + pub fn commands(&self) -> &[CommandSpec] { + &self.manifest.commands + } + + pub fn surfaces(&self) -> &[SurfaceSpec] { + &self.manifest.surfaces + } + + pub fn mounts(&self) -> &[CommandMount] { + &self.mounts + } + + pub fn definitions(&self) -> &[CommandDefinition] { + &self.definitions + } + + /// Return the exact typed command inventory retained by generated command + /// definitions. A manually assembled `CommandSpec` has no typed source + /// contract and therefore cannot be used to bind commands to a Surface. + pub(crate) fn typed_command_contracts(&self) -> Result, String> { + let mut contracts = Vec::with_capacity(self.definitions.len()); + for definition in &self.definitions { + let Some(contract) = definition.typed_contract() else { + return Err(format!( + "module `{}` command `{}` has no retained typed command contract", + self.id(), + definition.spec().id + )); + }; + let expected = CommandSpec::from_contract(contract) + .map_err(|error| format!("cannot compile typed command contract: {error}"))?; + if expected != *definition.spec() { + return Err(format!( + "module `{}` command `{}` has a stale portable spec beside its typed command contract", + self.id(), + definition.spec().id + )); + } + contracts.push(contract.clone()); + } + Ok(contracts) + } + + pub fn canonical_bytes(&self) -> ApplicationResult> { + self.manifest.canonical_bytes() + } +} + +/// Fluent explicit module authoring API. +pub struct ModuleBuilder { + id: String, + definitions: Vec, + projections: Vec, + models: Vec, + surfaces: Vec, + required_capabilities: Vec, +} + +impl ModuleBuilder { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + definitions: Vec::new(), + projections: Vec::new(), + models: Vec::new(), + surfaces: Vec::new(), + required_capabilities: Vec::new(), + } + } + + /// Add one declaration whose optional runtime mount is derived from the + /// same command spec. + pub fn command_definition(mut self, definition: CommandDefinition) -> Self { + self.definitions.push(definition); + self + } + + pub fn command_definitions( + mut self, + definitions: impl IntoIterator, + ) -> Self { + self.definitions.extend(definitions); + self + } + + pub fn projection(mut self, projection: ProjectionSpec) -> Self { + self.projections.push(projection); + self + } + + pub fn projections(mut self, projections: impl IntoIterator) -> Self { + self.projections.extend(projections); + self + } + + pub fn model(mut self, model: ModelSpec) -> Self { + self.models.push(model); + self + } + + pub fn models(mut self, models: impl IntoIterator) -> Self { + self.models.extend(models); + self + } + + pub fn surface(mut self, surface: SurfaceSpec) -> Self { + self.surfaces.push(surface); + self + } + + pub fn surfaces(mut self, surfaces: impl IntoIterator) -> Self { + self.surfaces.extend(surfaces); + self + } + + pub fn required_capability(mut self, capability: impl Into) -> Self { + self.required_capabilities.push(capability.into()); + self + } + + pub fn required_capabilities( + mut self, + capabilities: impl IntoIterator>, + ) -> Self { + self.required_capabilities + .extend(capabilities.into_iter().map(Into::into)); + self + } + + pub fn build(self) -> ApplicationResult { + let id = LogicalId::try_new("module", self.id)?.into_string(); + let mut definitions = self.definitions; + definitions.sort_by(|left, right| left.spec().id.cmp(&right.spec().id)); + let mut commands = definitions + .iter() + .map(|definition| definition.spec().clone()) + .collect::>(); + commands.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique("command", commands.iter().map(|command| command.id.clone()))?; + for command in &commands { + command.validate()?; + command.validate_fingerprint()?; + } + + for definition in &definitions { + if let Some(contract) = definition.typed_contract() { + let expected = CommandSpec::from_contract(contract)?; + if expected != *definition.spec() { + return Err(ApplicationError::Collision { + kind: "command", + identity: definition.spec().id.clone(), + reason: "typed command contract and portable spec disagree".into(), + }); + } + } + } + + let mut mounts = definitions + .iter() + .filter_map(|definition| definition.mount().cloned()) + .collect::>(); + let command_ids = commands + .iter() + .map(|command| command.id.as_str()) + .collect::>(); + for mount in &mounts { + if !command_ids.contains(mount.spec().id.as_str()) { + return Err(ApplicationError::Missing { + kind: "command", + identity: mount.spec().id.clone(), + }); + } + let Some(command) = commands + .iter() + .find(|command| command.id == mount.spec().id) + else { + continue; + }; + if command.fingerprint != mount.spec().fingerprint { + return Err(ApplicationError::Collision { + kind: "command", + identity: command.id.clone(), + reason: "executable mount spec differs from module command spec".into(), + }); + } + } + + mounts.sort_by(|left, right| left.spec().id.cmp(&right.spec().id)); + validate_unique( + "command mount", + mounts.iter().map(|mount| mount.spec().id.clone()), + )?; + + let mut surfaces = self.surfaces; + surfaces.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique("surface", surfaces.iter().map(|surface| surface.id.clone()))?; + + let mut projections = self.projections; + projections.extend( + surfaces + .iter() + .flat_map(|surface| surface.projections.iter().cloned()), + ); + projections.sort_by(|left, right| left.id.cmp(&right.id)); + projections = dedup_identical("projection", projections, |projection| projection.id.clone())?; + + let mut events = commands + .iter() + .flat_map(|command| command.emits.iter().cloned()) + .collect::>(); + 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(), + )) + }); + let mut event_by_name = BTreeMap::new(); + for event in events { + if let Some(existing) = event_by_name.insert(event.name.clone(), event.clone()) { + if existing != event { + return Err(ApplicationError::Collision { + kind: "event", + identity: event.name, + reason: "event selectors with one name disagree".into(), + }); + } + } + } + let events = event_by_name.into_values().collect(); + + let mut models = self.models; + models.extend( + surfaces + .iter() + .flat_map(|surface| surface.models.iter().cloned()), + ); + models.sort_by(|left, right| left.id.cmp(&right.id)); + models = dedup_identical("model", models, |model| model.id.clone())?; + + let mut required_capabilities = self.required_capabilities; + required_capabilities.sort(); + required_capabilities.dedup(); + + let mut manifest = ModuleManifest { + id, + commands, + events, + projections, + models, + surfaces, + required_capabilities, + fingerprint: String::new(), + }; + manifest.fingerprint = sha256_fingerprint(&manifest.canonical_bytes()?); + Ok(Module { + manifest, + mounts, + definitions, + }) + } +} + +fn validate_unique( + kind: &'static str, + identities: impl IntoIterator, +) -> ApplicationResult<()> { + let mut seen = BTreeSet::new(); + for identity in identities { + if !seen.insert(identity.clone()) { + return Err(ApplicationError::Duplicate { kind, identity }); + } + } + Ok(()) +} + +fn dedup_identical( + kind: &'static str, + values: Vec, + identity: impl Fn(&T) -> String, +) -> ApplicationResult> { + let mut out = Vec::new(); + let mut seen = BTreeMap::::new(); + for value in values { + let id = identity(&value); + if let Some(existing) = seen.get(&id) { + if existing != &value { + return Err(ApplicationError::Collision { + kind, + identity: id, + reason: "same identity has incompatible portable definitions".into(), + }); + } + continue; + } + seen.insert(id, value); + } + out.extend(seen.into_values()); + Ok(out) +} diff --git a/src/application/mount.rs b/src/application/mount.rs new file mode 100644 index 000000000..05c7afe67 --- /dev/null +++ b/src/application/mount.rs @@ -0,0 +1,184 @@ +//! Composable mount algebra and process-role presets. +//! +//! Named presets expand to ordinary mount selectors. They are never a closed +//! capability enum — arbitrary mixes remain expressible through the same +//! algebra. + +use serde::{Deserialize, Serialize}; + +use super::error::{ApplicationError, ApplicationResult}; +use super::identity::LogicalId; +use super::manifest::ApplicationManifest; + +/// One logical mount selected into a process. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum MountSelector { + /// Local command handler mount for a portable command identity. + Command { id: String }, + /// Projection program mount (direct or eventual) for a projection id. + Projector { id: String }, + /// Query/live surface mount for a surface identity. + Surface { id: String }, + /// Explicit application extension mount. + Extension { id: String }, +} + +impl MountSelector { + pub fn command(id: impl Into) -> ApplicationResult { + Ok(Self::Command { + id: LogicalId::try_new("command mount", id)?.into_string(), + }) + } + + pub fn projector(id: impl Into) -> ApplicationResult { + Ok(Self::Projector { + id: LogicalId::try_new("projector mount", id)?.into_string(), + }) + } + + pub fn surface(id: impl Into) -> ApplicationResult { + Ok(Self::Surface { + id: LogicalId::try_new("surface mount", id)?.into_string(), + }) + } + + pub fn extension(id: impl Into) -> ApplicationResult { + Ok(Self::Extension { + id: LogicalId::try_new("extension mount", id)?.into_string(), + }) + } + + pub fn kind_label(&self) -> &'static str { + match self { + Self::Command { .. } => "command", + Self::Projector { .. } => "projector", + Self::Surface { .. } => "surface", + Self::Extension { .. } => "extension", + } + } + + pub fn id(&self) -> &str { + match self { + Self::Command { id } + | Self::Projector { id } + | Self::Surface { id } + | Self::Extension { id } => id.as_str(), + } + } +} + +/// Named convenience presets that lower to ordinary mounts. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProcessPreset { + /// Every command, projector, surface, and extension in the manifest. + Full, + /// Every command mount (writers only). + Writer, + /// Every projection mount. + Projector, + /// Every query/live surface. + QueryApi, +} + +impl ProcessPreset { + /// Expand a preset into explicit mounts against one application manifest. + pub fn expand(self, manifest: &ApplicationManifest) -> ApplicationResult> { + let mut mounts = match self { + Self::Full => { + let mut mounts = Vec::new(); + for command in &manifest.commands { + mounts.push(MountSelector::command(command.id.clone())?); + } + for projection in &manifest.projections { + mounts.push(MountSelector::projector(projection.id.clone())?); + } + for surface in &manifest.surfaces { + mounts.push(MountSelector::surface(surface.id.clone())?); + } + for extension in &manifest.extensions { + mounts.push(MountSelector::extension(extension.id.clone())?); + } + mounts + } + Self::Writer => manifest + .commands + .iter() + .map(|command| MountSelector::command(command.id.clone())) + .collect::>>()?, + Self::Projector => manifest + .projections + .iter() + .map(|projection| MountSelector::projector(projection.id.clone())) + .collect::>>()?, + Self::QueryApi => manifest + .surfaces + .iter() + .map(|surface| MountSelector::surface(surface.id.clone())) + .collect::>>()?, + }; + mounts.sort(); + mounts.dedup(); + Ok(mounts) + } +} + +/// Validate that every selector references an identity present in the manifest. +pub fn validate_mounts_against_manifest( + manifest: &ApplicationManifest, + mounts: &[MountSelector], +) -> ApplicationResult<()> { + let mut seen = std::collections::BTreeSet::new(); + for mount in mounts { + if !seen.insert(mount.clone()) { + return Err(ApplicationError::Duplicate { + kind: "mount selector", + identity: format!("{}:{}", mount.kind_label(), mount.id()), + }); + } + match mount { + MountSelector::Command { id } => { + if !manifest.commands.iter().any(|command| command.id == *id) { + return Err(ApplicationError::Missing { + kind: "command mount", + identity: id.clone(), + }); + } + } + MountSelector::Projector { id } => { + if !manifest + .projections + .iter() + .any(|projection| projection.id == *id) + { + return Err(ApplicationError::Missing { + kind: "projector mount", + identity: id.clone(), + }); + } + } + MountSelector::Surface { id } => { + if !manifest.surfaces.iter().any(|surface| surface.id == *id) { + return Err(ApplicationError::Missing { + kind: "surface mount", + identity: id.clone(), + }); + } + } + MountSelector::Extension { id } => { + if !manifest + .extensions + .iter() + .any(|extension| extension.id == *id) + { + return Err(ApplicationError::Missing { + kind: "extension mount", + identity: id.clone(), + }); + } + } + } + } + Ok(()) +} diff --git a/src/application/plan.rs b/src/application/plan.rs new file mode 100644 index 000000000..07f4af6cd --- /dev/null +++ b/src/application/plan.rs @@ -0,0 +1,480 @@ +//! Validated deployment plan compiler. +//! +//! Compiles an [`ApplicationManifest`] plus explicit process placement into a +//! deterministic, serializable [`DeploymentPlan`]. No I/O, process startup, or +//! cluster types are introduced here. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use super::capability::{ + derive_process_capabilities, Capability, CapabilityRequirement, SchemaLifecycleRequirement, +}; +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::topology::{derive_topology, TopologyIntent}; +use crate::graphql::command_contract::CommandConsistency; + +/// Wire schema version for deployment plans. +pub const DEPLOYMENT_PLAN_SCHEMA_VERSION: u32 = 1; +pub const MAX_DEPLOYMENT_PLAN_BYTES: usize = 1024 * 1024; +pub const MAX_PLAN_PROCESSES: usize = 256; +pub const MAX_PLAN_MOUNTS_PER_PROCESS: usize = 4096; + +/// One process entry in a deployment plan. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProcessPlan { + pub id: String, + /// Mounts selected into this process (sorted, unique). + pub mounts: Vec, + /// When true, command execution is remote for surfaces that need dispatch. + /// Local command mounts still imply local execution when present. + #[serde(default)] + pub remote_commands: bool, + pub capabilities: Vec, + pub topology: Vec, +} + +/// Complete validated deployment plan linked to an application manifest. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DeploymentPlan { + pub schema_version: u32, + pub name: String, + /// Application name from the source manifest. + pub application: String, + /// Logical fingerprint of the predecessor application manifest. + pub application_manifest_logical: String, + /// Canonical fingerprint of the predecessor application manifest. + pub application_manifest_canonical: String, + pub processes: Vec, + /// Union of process capabilities with reasons preserved. + pub capabilities: Vec, + pub schema_lifecycle: SchemaLifecycleRequirement, + pub fingerprints: PlanFingerprint, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PlanFingerprint { + pub logical: String, + pub canonical: String, +} + +/// Builder for one process before plan compilation. +#[derive(Clone, Debug)] +pub struct ProcessIntent { + pub id: String, + pub mounts: Vec, + pub remote_commands: bool, +} + +impl ProcessIntent { + pub fn new(id: impl Into) -> ApplicationResult { + Ok(Self { + id: LogicalId::try_new("process", id)?.into_string(), + mounts: Vec::new(), + remote_commands: false, + }) + } + + pub fn with_preset( + id: impl Into, + manifest: &ApplicationManifest, + preset: ProcessPreset, + ) -> ApplicationResult { + let mut process = Self::new(id)?; + process.mounts = preset.expand(manifest)?; + Ok(process) + } + + pub fn mounts(mut self, mounts: impl IntoIterator) -> Self { + self.mounts.extend(mounts); + self + } + + pub fn remote_commands(mut self, remote: bool) -> Self { + self.remote_commands = remote; + self + } +} + +/// Compile explicit process intents against one application manifest. +pub fn compile_deployment_plan( + name: impl Into, + manifest: &ApplicationManifest, + processes: impl IntoIterator, +) -> ApplicationResult { + manifest.validate()?; + let name = LogicalId::try_new("deployment plan", name)?.into_string(); + let mut processes = processes.into_iter().collect::>(); + if processes.is_empty() { + // Zero-config default: one full local process. + processes.push(ProcessIntent::with_preset( + "full", + manifest, + ProcessPreset::Full, + )?); + } + if processes.len() > MAX_PLAN_PROCESSES { + return Err(ApplicationError::InvalidSpec(format!( + "deployment plan exceeds max process count {MAX_PLAN_PROCESSES}" + ))); + } + + let mut process_ids = BTreeSet::new(); + let mut compiled = Vec::new(); + let mut global_capabilities: BTreeMap> = + BTreeMap::new(); + let mut schema_reasons = BTreeSet::new(); + let mut schema_owner: Option = None; + + // Track command and direct-projection placement for Atomic collocation. + let mut command_hosts: BTreeMap> = BTreeMap::new(); + let mut direct_projection_hosts: BTreeMap> = BTreeMap::new(); + let mut command_to_direct: BTreeMap> = BTreeMap::new(); + + for command in &manifest.commands { + if matches!(command.consistency, CommandConsistency::Atomic) { + // Direct projection targets from projected_model / direct_projection proof. + if let Some(model) = &command.projected_model { + command_to_direct + .entry(command.id.clone()) + .or_default() + .insert(model.clone()); + } + // Also match direct projection specs that share the command's projected model. + for projection in &manifest.projections { + if projection.direct { + if let Some(model) = &command.projected_model { + if projection.models.iter().any(|m| m == model) { + command_to_direct + .entry(command.id.clone()) + .or_default() + .insert(projection.id.clone()); + } + } + // If the command lists direct_projection material, treat all + // direct projectors as potential collocation partners when + // the projected_model is unset but atomic. + if command.projected_model.is_none() && command.direct_projection.is_some() { + command_to_direct + .entry(command.id.clone()) + .or_default() + .insert(projection.id.clone()); + } + } + } + } + } + + for mut process in processes { + if !process_ids.insert(process.id.clone()) { + return Err(ApplicationError::Duplicate { + kind: "process", + identity: process.id, + }); + } + process.mounts.sort(); + process.mounts.dedup(); + if process.mounts.len() > MAX_PLAN_MOUNTS_PER_PROCESS { + return Err(ApplicationError::InvalidSpec(format!( + "process `{}` exceeds max mount count {MAX_PLAN_MOUNTS_PER_PROCESS}", + process.id + ))); + } + validate_mounts_against_manifest(manifest, &process.mounts)?; + + // Local command mounts force local execution regardless of remote flag. + let has_local_commands = process + .mounts + .iter() + .any(|mount| matches!(mount, MountSelector::Command { .. })); + let remote_commands = process.remote_commands && !has_local_commands; + + for mount in &process.mounts { + match mount { + MountSelector::Command { id } => { + command_hosts + .entry(id.clone()) + .or_default() + .insert(process.id.clone()); + } + MountSelector::Projector { id } => { + if let Some(projection) = + manifest.projections.iter().find(|projection| projection.id == *id) + { + if projection.direct { + direct_projection_hosts + .entry(id.clone()) + .or_default() + .insert(process.id.clone()); + } + } + } + _ => {} + } + } + + let (capabilities, schema) = + derive_process_capabilities(manifest, &process.id, &process.mounts, remote_commands)?; + for requirement in &capabilities { + global_capabilities + .entry(requirement.capability) + .or_default() + .extend(requirement.reasons.iter().cloned()); + } + for reason in schema.reasons { + schema_reasons.insert(reason); + } + if let Some(owner) = schema.logical_owner { + match &schema_owner { + Some(existing) if existing != &owner => { + return Err(ApplicationError::Collision { + kind: "schema lifecycle owner", + identity: owner, + reason: format!("conflicts with existing owner `{existing}`"), + }); + } + None => schema_owner = Some(owner), + _ => {} + } + } + + let topology = derive_topology(manifest, &process.id, &process.mounts, remote_commands)?; + compiled.push(ProcessPlan { + id: process.id, + mounts: process.mounts, + remote_commands, + capabilities, + topology, + }); + } + + // Atomic collocation: each Atomic command and its direct projection mounts + // must share at least one process. Eventual splits are always allowed. + for (command_id, direct_ids) in &command_to_direct { + let Some(command_processes) = command_hosts.get(command_id) else { + // Atomic command not mounted anywhere is fine for pure API plans. + continue; + }; + for direct_id in direct_ids { + // direct_id may be a model name or projection id — match projection hosts. + let projection_hosts = direct_projection_hosts.get(direct_id); + let model_hosts: Option> = { + // If direct_id is a model, find direct projectors targeting it. + let mut hosts = BTreeSet::new(); + for projection in &manifest.projections { + if projection.direct && projection.models.iter().any(|m| m == direct_id) { + if let Some(process_ids) = direct_projection_hosts.get(&projection.id) { + hosts.extend(process_ids.iter().cloned()); + } + } + } + if hosts.is_empty() { + None + } else { + Some(hosts) + } + }; + let hosts = match (projection_hosts, model_hosts) { + (Some(a), Some(b)) => a.union(&b).cloned().collect::>(), + (Some(a), None) => a.clone(), + (None, Some(b)) => b, + (None, None) => { + // No direct projector mounted for this atomic command — fail. + return Err(ApplicationError::InvalidSpec(format!( + "atomic command `{command_id}` is mounted without collocated direct projection `{direct_id}`" + ))); + } + }; + let shared = command_processes + .intersection(&hosts) + .cloned() + .collect::>(); + if shared.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "atomic command `{command_id}` must be collocated with direct projection `{direct_id}`; command processes {:?}, projection processes {:?}", + command_processes, hosts + ))); + } + } + } + + // Reject duplicate command ownership across processes when both claim local execution. + for (command_id, hosts) in &command_hosts { + if hosts.len() > 1 { + return Err(ApplicationError::Collision { + kind: "command mount", + identity: command_id.clone(), + reason: format!( + "selected in multiple processes: {}", + hosts.iter().cloned().collect::>().join(", ") + ), + }); + } + } + + compiled.sort_by(|left, right| left.id.cmp(&right.id)); + + let capabilities = global_capabilities + .into_iter() + .map(|(capability, mut reasons)| { + reasons.sort(); + reasons.dedup(); + CapabilityRequirement { + capability, + reasons, + } + }) + .collect::>(); + + let schema_lifecycle = SchemaLifecycleRequirement { + required: !schema_reasons.is_empty(), + logical_owner: schema_owner, + reasons: schema_reasons.into_iter().collect(), + }; + + let mut plan = DeploymentPlan { + schema_version: DEPLOYMENT_PLAN_SCHEMA_VERSION, + name, + application: manifest.name.clone(), + application_manifest_logical: manifest.fingerprints.logical.clone(), + application_manifest_canonical: manifest.fingerprints.canonical.clone(), + processes: compiled, + capabilities, + schema_lifecycle, + fingerprints: PlanFingerprint::default(), + }; + plan.refresh_fingerprints()?; + plan.validate()?; + Ok(plan) +} + +impl DeploymentPlan { + pub fn refresh_fingerprints(&mut self) -> ApplicationResult<()> { + self.fingerprints = expected_fingerprints(self)?; + Ok(()) + } + + pub fn canonical_bytes(&self) -> ApplicationResult> { + let mut value = serde_json::to_value(self)?; + if let serde_json::Value::Object(fields) = &mut value { + // Fingerprints are derived; zero them for logical identity then re-encode full. + fields.insert( + "fingerprints".into(), + serde_json::json!({ "logical": "", "canonical": "" }), + ); + } + let logical = canonical_json(&value); + Ok(serde_json::to_vec(&logical)?) + } + + pub fn encode(&self) -> ApplicationResult> { + self.validate()?; + let bytes = serde_json::to_vec(&canonical_json(&serde_json::to_value(self)?))?; + if bytes.len() > MAX_DEPLOYMENT_PLAN_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "deployment plan exceeds max bytes {MAX_DEPLOYMENT_PLAN_BYTES}" + ))); + } + Ok(bytes) + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> ApplicationResult { + if bytes.len() > MAX_DEPLOYMENT_PLAN_BYTES { + return Err(ApplicationError::InvalidSpec( + "deployment plan bytes exceed maximum".into(), + )); + } + let plan: Self = serde_json::from_slice(bytes)?; + plan.validate()?; + let reencoded = plan.encode()?; + if reencoded != bytes { + return Err(ApplicationError::NonCanonical("deployment plan")); + } + Ok(plan) + } + + pub fn validate(&self) -> ApplicationResult<()> { + if self.schema_version != DEPLOYMENT_PLAN_SCHEMA_VERSION { + return Err(ApplicationError::UnsupportedVersion { + expected: DEPLOYMENT_PLAN_SCHEMA_VERSION, + actual: self.schema_version, + }); + } + LogicalId::try_new("deployment plan", self.name.clone())?; + LogicalId::try_new("application", self.application.clone())?; + if self.application_manifest_logical.is_empty() + || self.application_manifest_canonical.is_empty() + { + return Err(ApplicationError::InvalidSpec( + "deployment plan requires application manifest predecessor fingerprints".into(), + )); + } + if self.processes.is_empty() { + return Err(ApplicationError::InvalidSpec( + "deployment plan must declare at least one process".into(), + )); + } + let expected = expected_fingerprints(self)?; + if self.fingerprints != expected { + return Err(ApplicationError::NonCanonical("deployment plan fingerprints")); + } + // Silence unused import when APPLICATION_MANIFEST_SCHEMA_VERSION is only + // for documentation linkage in validate paths. + let _ = APPLICATION_MANIFEST_SCHEMA_VERSION; + Ok(()) + } + + /// Pure inspection data for CLI/runtime consumers. + pub fn describe(&self) -> serde_json::Value { + serde_json::json!({ + "name": self.name, + "application": self.application, + "processes": self.processes.iter().map(|process| { + serde_json::json!({ + "id": process.id, + "mounts": process.mounts, + "remote_commands": process.remote_commands, + "capability_count": process.capabilities.len(), + "topology_count": process.topology.len(), + }) + }).collect::>(), + "capabilities": self.capabilities.iter().map(|cap| { + serde_json::json!({ + "capability": cap.capability.as_str(), + "reason_count": cap.reasons.len(), + }) + }).collect::>(), + "schema_lifecycle": self.schema_lifecycle, + "application_manifest_logical": self.application_manifest_logical, + "application_manifest_canonical": self.application_manifest_canonical, + "fingerprints": self.fingerprints, + }) + } +} + +fn expected_fingerprints(plan: &DeploymentPlan) -> ApplicationResult { + let mut for_logical = plan.clone(); + for_logical.fingerprints = PlanFingerprint::default(); + let logical_value = canonical_json(&serde_json::to_value(&for_logical)?); + let logical_bytes = serde_json::to_vec(&logical_value)?; + let logical = sha256_fingerprint(&logical_bytes); + + let mut for_canonical = plan.clone(); + for_canonical.fingerprints = PlanFingerprint { + logical: logical.clone(), + canonical: String::new(), + }; + let canonical_value = canonical_json(&serde_json::to_value(&for_canonical)?); + let canonical_bytes = serde_json::to_vec(&canonical_value)?; + let canonical = sha256_fingerprint(&canonical_bytes); + Ok(PlanFingerprint { logical, canonical }) +} diff --git a/src/application/registration.rs b/src/application/registration.rs new file mode 100644 index 000000000..afffdeebc --- /dev/null +++ b/src/application/registration.rs @@ -0,0 +1,321 @@ +use std::sync::Arc; + +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}; + +/// Explicit application registration. No linker inventory or source scan is +/// consulted; only the values supplied to this constructor participate. +pub struct Application { + name: String, + modules: Vec, + surfaces: Vec, + manifest: ApplicationManifest, +} + +impl Clone for Application { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + modules: self.modules.clone(), + surfaces: self.surfaces.clone(), + manifest: self.manifest.clone(), + } + } +} + +impl std::fmt::Debug for Application { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Application") + .field("name", &self.name) + .field( + "modules", + &self.modules.iter().map(Module::id).collect::>(), + ) + .field("surfaces", &self.surfaces.len()) + .finish() + } +} + +impl Application { + pub fn new(name: impl Into) -> ApplicationBuilder { + ApplicationBuilder::new(name) + } + + pub fn try_new( + name: impl Into, + modules: impl IntoIterator, + surfaces: impl IntoIterator, + ) -> ApplicationResult { + let modules = modules.into_iter().collect::>(); + let surfaces = surfaces.into_iter().collect::>(); + let manifest = + ApplicationManifest::try_from_modules(name, modules.clone(), surfaces.clone())?; + Ok(Self { + name: manifest.name.clone(), + modules, + surfaces, + manifest, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn modules(&self) -> &[Module] { + &self.modules + } + + pub fn surfaces(&self) -> &[SurfaceSpec] { + &self.surfaces + } + + pub fn manifest(&self) -> &ApplicationManifest { + &self.manifest + } + + pub fn canonical_bytes(&self) -> ApplicationResult> { + self.manifest.canonical_bytes() + } + + pub fn fingerprint(&self) -> ApplicationResult { + self.manifest.fingerprint() + } +} + +/// Fluent explicit application authoring API. +pub struct ApplicationBuilder { + name: String, + modules: Vec, + surfaces: Vec, + required_capabilities: Vec, + extensions: Vec, + provenance: Option, +} + +impl ApplicationBuilder { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + modules: Vec::new(), + surfaces: Vec::new(), + required_capabilities: Vec::new(), + extensions: Vec::new(), + provenance: None, + } + } + + pub fn module(mut self, module: Module) -> Self { + self.modules.push(module); + self + } + + pub fn modules(mut self, modules: impl IntoIterator) -> Self { + self.modules.extend(modules); + self + } + + pub fn surface(mut self, surface: SurfaceSpec) -> Self { + self.surfaces.push(surface); + self + } + + pub fn surfaces(mut self, surfaces: impl IntoIterator) -> Self { + self.surfaces.extend(surfaces); + self + } + + pub fn required_capability(mut self, capability: impl Into) -> Self { + self.required_capabilities.push(capability.into()); + self + } + + pub fn required_capabilities( + mut self, + capabilities: impl IntoIterator>, + ) -> Self { + self.required_capabilities + .extend(capabilities.into_iter().map(Into::into)); + self + } + + pub fn extension(mut self, extension: ApplicationExtension) -> Self { + self.extensions.push(extension); + self + } + + pub fn extensions( + mut self, + extensions: impl IntoIterator, + ) -> Self { + self.extensions.extend(extensions); + self + } + + pub fn provenance(mut self, provenance: ManifestProvenance) -> Self { + self.provenance = Some(provenance); + self + } + + pub fn build(self) -> ApplicationResult { + let mut application = Application::try_new(self.name, self.modules, self.surfaces)?; + application + .manifest + .required_capabilities + .extend(self.required_capabilities); + application.manifest.required_capabilities.sort(); + application.manifest.required_capabilities.dedup(); + application.manifest.extensions.extend(self.extensions); + if let Some(provenance) = self.provenance { + application.manifest = application.manifest.with_provenance(provenance); + } + application.manifest.refresh_fingerprints()?; + Ok(application) + } +} + +/// Pure compiler entrypoint for contract-only packages. +pub struct ContractCompiler { + name: String, + modules: Vec, + surface: Option>, + surface_spec: Option, +} + +impl ContractCompiler { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + modules: Vec::new(), + surface: None, + surface_spec: None, + } + } + + pub fn modules(mut self, modules: impl IntoIterator) -> Self { + self.modules.extend(modules); + self + } + + /// Bind the concrete authoritative Surface once. Every compiler output + /// uses this exact value and its compiled `SurfaceSpec`; no table inventory + /// or caller-supplied unrelated Surface is accepted beside the manifest. + pub fn with_surface( + mut self, + id: impl Into, + surface: impl Into>, + ) -> Result { + let surface = surface.into(); + let spec = SurfaceSpec::from_surface(id, &surface).map_err(|error| error.to_string())?; + if let Some(existing) = &self.surface_spec { + if existing.id != spec.id || existing.fingerprint != spec.fingerprint { + return Err(format!( + "ContractCompiler already has a different authoritative Surface contract" + )); + } + return Err("ContractCompiler accepts exactly one authoritative Surface contract".into()); + } + self.surface = Some(surface); + self.surface_spec = Some(spec); + Ok(self) + } + + /// Construct a compiler around one authoritative Surface contract. + pub fn from_surface( + name: impl Into, + surface_id: impl Into, + surface: impl Into>, + ) -> Result { + Self::new(name).with_surface(surface_id, surface) + } + + /// Return the already-bound shared Surface IR. + pub fn surface(&self) -> Result { + self.surface + .as_deref() + .cloned() + .ok_or_else(|| "ContractCompiler requires one bound Surface contract".into()) + } + + /// Render SDL directly from the contract-only Surface IR. + pub fn graphql_sdl(&self) -> Result { + crate::graphql::graphql_sdl_from_surface( + self.surface + .as_deref() + .ok_or_else(|| "ContractCompiler requires one bound Surface contract".to_owned())?, + ) + } + + /// Compile the selected client artifact from the same Surface identity. + pub fn client_manifest(&self) -> Result { + let surface = self.surface.clone().ok_or_else(|| { + ClientManifestError("ContractCompiler requires one bound Surface contract".into()) + })?; + let expected = self.bound_surface_spec().map_err(ClientManifestError)?; + let export = DistributedClientSurfaceExport::from_contract(self.name.clone(), surface)?; + let actual = SurfaceSpec::from_surface(expected.id.clone(), export.surface().as_ref()) + .map_err(|error| ClientManifestError(error.to_string()))?; + if actual.id != expected.id || actual.fingerprint != expected.fingerprint { + return Err(ClientManifestError( + "client manifest Surface identity diverges from the compiler contract".into(), + )); + } + export.manifest() + } + + /// Compile the logical manifest without mounting a handler. + pub fn manifest(&self) -> ApplicationResult { + let manifest = ApplicationManifest::try_from_modules( + self.name.clone(), + self.modules.clone(), + self.surface_spec.clone().into_iter(), + )?; + let expected = self + .bound_surface_spec() + .map_err(crate::application::ApplicationError::InvalidSpec)?; + if manifest + .surfaces + .iter() + .find(|surface| surface.id == expected.id) + .is_none_or(|surface| surface.fingerprint != expected.fingerprint) + { + return Err(crate::application::ApplicationError::NonCanonical( + "compiler Surface identity", + )); + } + Ok(manifest) + } + + pub fn compile(&self) -> ApplicationResult { + let application = Application::try_new( + self.name.clone(), + self.modules.clone(), + self.surface_spec.clone().into_iter(), + )?; + let expected = self + .bound_surface_spec() + .map_err(crate::application::ApplicationError::InvalidSpec)?; + if application + .manifest() + .surfaces + .iter() + .find(|surface| surface.id == expected.id) + .is_none_or(|surface| surface.fingerprint != expected.fingerprint) + { + return Err(crate::application::ApplicationError::NonCanonical( + "compiler Surface identity", + )); + } + Ok(application) + } + + fn bound_surface_spec(&self) -> Result { + self.surface_spec + .clone() + .ok_or_else(|| "ContractCompiler requires one bound Surface contract".into()) + } +} diff --git a/src/application/runtime_host.rs b/src/application/runtime_host.rs new file mode 100644 index 000000000..e0ad550b1 --- /dev/null +++ b/src/application/runtime_host.rs @@ -0,0 +1,225 @@ +//! Framework runtime host skeleton (task 12). +//! +//! Realizes one process entry from a validated [`DeploymentPlan`] by requiring +//! the explained capability set and an explicit [`CommandDispatcher`]. Full +//! adapter bootstrap (stores, workers, supervision) continues to grow here; +//! application code must not hand-pair dialect runners. + +use super::capability::Capability; +use super::error::{ApplicationError, ApplicationResult}; +use super::plan::{DeploymentPlan, ProcessPlan}; +use crate::command_dispatch::SharedCommandDispatcher; +use std::collections::BTreeSet; + +/// Provider-backed capabilities available to the host at bind time. +#[derive(Clone, Debug, Default)] +pub struct CapabilityProviders { + pub available: BTreeSet, +} + +impl CapabilityProviders { + pub fn with(mut self, capability: Capability) -> Self { + self.available.insert(capability); + self + } + + pub fn contains(&self, capability: Capability) -> bool { + self.available.contains(&capability) + } +} + +/// One bound process ready for supervision / serve (task 12 expansion point). +pub struct RuntimeHost { + pub plan_name: String, + pub process: ProcessPlan, + pub dispatcher: Option, + pub providers: CapabilityProviders, +} + +impl RuntimeHost { + /// Bind one process from a validated plan against available providers. + /// + /// Missing required capabilities fail closed before serve. + pub fn bind( + plan: &DeploymentPlan, + process_id: &str, + providers: CapabilityProviders, + dispatcher: Option, + ) -> ApplicationResult { + plan.validate()?; + let process = plan + .processes + .iter() + .find(|process| process.id == process_id) + .cloned() + .ok_or_else(|| ApplicationError::Missing { + kind: "process", + identity: process_id.to_string(), + })?; + + for requirement in &process.capabilities { + if !providers.contains(requirement.capability) { + return Err(ApplicationError::InvalidSpec(format!( + "process `{process_id}` requires capability `{}` but no provider is bound", + requirement.capability.as_str() + ))); + } + } + + let needs_dispatch = process.mounts.iter().any(|mount| { + matches!( + mount, + super::mount::MountSelector::Command { .. } + | super::mount::MountSelector::Surface { .. } + ) + }); + if needs_dispatch && dispatcher.is_none() { + return Err(ApplicationError::InvalidSpec(format!( + "process `{process_id}` requires a CommandDispatcher for command/surface mounts" + ))); + } + + Ok(Self { + plan_name: plan.name.clone(), + process, + dispatcher, + providers, + }) + } + + pub fn process_id(&self) -> &str { + &self.process.id + } + + pub fn dispatcher(&self) -> Option<&SharedCommandDispatcher> { + self.dispatcher.as_ref() + } +} + +/// Convenience: bind the sole process when a plan has exactly one entry. +pub fn bind_single_process( + plan: &DeploymentPlan, + providers: CapabilityProviders, + dispatcher: Option, +) -> ApplicationResult { + if plan.processes.len() != 1 { + return Err(ApplicationError::InvalidSpec( + "bind_single_process requires exactly one process in the plan".into(), + )); + } + RuntimeHost::bind(plan, &plan.processes[0].id, providers, dispatcher) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::{ + compile_deployment_plan, Application, CommandDefinition, CommandSpec, CommandTypeSpec, + ModelFieldSpec, ModelSpec, Module, ProcessIntent, ProcessPreset, ProjectionSpec, + }; + use crate::graphql::CommandConsistency; + use std::sync::Arc; + + fn manifest() -> crate::application::ApplicationManifest { + let model = ModelSpec::try_new( + "TodoView", + "todos", + [ModelFieldSpec { + name: "todo_id".into(), + scalar: "String".into(), + nullable: false, + }], + ["todo_id"], + ) + .unwrap(); + let command = CommandSpec::try_new( + "todo.create", + "todo_create", + CommandTypeSpec { + name: "In".into(), + fields: vec![], + }, + CommandTypeSpec { + name: "Out".into(), + fields: vec![], + }, + CommandConsistency::Eventual, + ) + .unwrap(); + let module = Module::new("todo") + .command_definitions([CommandDefinition::contract(command)]) + .models([model]) + .projections([ + ProjectionSpec::try_new("project_todos", ["todo.created"], ["TodoView"]).unwrap(), + ]) + .build() + .unwrap(); + Application::new("todo-app") + .module(module) + .build() + .unwrap() + .manifest() + .clone() + } + + #[test] + fn host_fails_closed_without_required_capability() { + let manifest = manifest(); + let plan = compile_deployment_plan( + "local", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Writer).unwrap()], + ) + .unwrap(); + let err = match RuntimeHost::bind(&plan, "full", CapabilityProviders::default(), None) { + Ok(_) => panic!("expected missing capability failure"), + Err(error) => error, + }; + assert!(err.to_string().contains("capability")); + } + + #[test] + fn host_binds_when_capabilities_and_dispatcher_present() { + use crate::command_dispatch::{CommandDispatchError, CommandDispatcher}; + use crate::microsvc::{CommandRequest, CommandResponse}; + use async_trait::async_trait; + + struct Stub; + #[async_trait] + impl CommandDispatcher for Stub { + async fn dispatch( + &self, + _request: &CommandRequest, + ) -> Result { + Ok(CommandResponse { + status: 200, + body: serde_json::json!({}), + }) + } + fn kind(&self) -> &'static str { + "stub" + } + } + + let manifest = manifest(); + let plan = compile_deployment_plan( + "local", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Writer).unwrap()], + ) + .unwrap(); + let mut providers = CapabilityProviders::default(); + for requirement in &plan.processes[0].capabilities { + providers = providers.with(requirement.capability); + } + let host = RuntimeHost::bind( + &plan, + "full", + providers, + Some(Arc::new(Stub) as SharedCommandDispatcher), + ) + .unwrap(); + assert_eq!(host.process_id(), "full"); + assert!(host.dispatcher().is_some()); + } +} diff --git a/src/application/topology.rs b/src/application/topology.rs new file mode 100644 index 000000000..efae10994 --- /dev/null +++ b/src/application/topology.rs @@ -0,0 +1,112 @@ +//! Derived logical topology intent from a process mount selection. +//! +//! Physical worker routes, epochs, and subscriptions are framework-owned later +//! outputs. This module records only the logical intent inventory that +//! downstream runtime and renderers consume without reinterpreting mounts. + +use serde::{Deserialize, Serialize}; + +use super::error::{ApplicationError, ApplicationResult}; +use super::manifest::ApplicationManifest; +use super::mount::MountSelector; + +/// One derived logical route or subscription intent. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum TopologyIntent { + CommandRoute { + command_id: String, + process_id: String, + remote: bool, + }, + ProjectionSubscription { + projection_id: String, + process_id: String, + direct: bool, + facts: Vec, + }, + SurfaceEndpoint { + surface_id: String, + process_id: String, + }, + ExtensionHook { + extension_id: String, + process_id: String, + }, +} + +/// Derive sorted topology intents for one process. +pub fn derive_topology( + manifest: &ApplicationManifest, + process_id: &str, + mounts: &[MountSelector], + remote_commands: bool, +) -> ApplicationResult> { + let mut intents = Vec::new(); + for mount in mounts { + match mount { + MountSelector::Command { id } => { + if !manifest.commands.iter().any(|command| command.id == *id) { + return Err(ApplicationError::Missing { + kind: "command", + identity: id.clone(), + }); + } + intents.push(TopologyIntent::CommandRoute { + command_id: id.clone(), + process_id: process_id.to_string(), + remote: remote_commands, + }); + } + MountSelector::Projector { id } => { + let projection = manifest + .projections + .iter() + .find(|projection| projection.id == *id) + .ok_or_else(|| ApplicationError::Missing { + kind: "projection", + identity: id.clone(), + })?; + let mut facts = projection.facts.clone(); + facts.sort(); + facts.dedup(); + intents.push(TopologyIntent::ProjectionSubscription { + projection_id: id.clone(), + process_id: process_id.to_string(), + direct: projection.direct, + facts, + }); + } + MountSelector::Surface { id } => { + if !manifest.surfaces.iter().any(|surface| surface.id == *id) { + return Err(ApplicationError::Missing { + kind: "surface", + identity: id.clone(), + }); + } + intents.push(TopologyIntent::SurfaceEndpoint { + surface_id: id.clone(), + process_id: process_id.to_string(), + }); + } + MountSelector::Extension { id } => { + if !manifest + .extensions + .iter() + .any(|extension| extension.id == *id) + { + return Err(ApplicationError::Missing { + kind: "extension", + identity: id.clone(), + }); + } + intents.push(TopologyIntent::ExtensionHook { + extension_id: id.clone(), + process_id: process_id.to_string(), + }); + } + } + } + intents.sort(); + Ok(intents) +} diff --git a/src/command_dispatch/envelope.rs b/src/command_dispatch/envelope.rs new file mode 100644 index 000000000..6b36e3f54 --- /dev/null +++ b/src/command_dispatch/envelope.rs @@ -0,0 +1,98 @@ +//! Versioned command dispatch envelope shared by local and remote adapters. +//! +//! Wire fields beyond the existing [`crate::microsvc::CommandRequest`] are +//! additive and match the task-20 approved remote profile. + +use crate::microsvc::{CommandRequest, CommandResponse}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Envelope schema version for local/remote semantic equality. +pub const COMMAND_DISPATCH_ENVELOPE_VERSION: u32 = 1; + +/// Versioned dispatch envelope. Local and remote adapters share this shape. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandDispatchEnvelope { + pub version: u32, + pub command: String, + pub input: serde_json::Value, + /// Verified identity claims reconstructed at the writer boundary. + #[serde(default)] + pub session_variables: BTreeMap, + /// Stable command contract fingerprint when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command_fingerprint: Option, + /// Client-supplied idempotency key for ledger dedup. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + /// Causation identifier linking related work. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub causation_id: Option, + /// Deadline as unix millis; remote adapters must fail closed when exceeded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deadline_unix_ms: Option, +} + +impl CommandDispatchEnvelope { + pub fn from_request(request: &CommandRequest) -> Self { + let mut session_variables = BTreeMap::new(); + for (key, value) in &request.session_variables { + session_variables.insert(key.clone(), value.clone()); + } + Self { + version: COMMAND_DISPATCH_ENVELOPE_VERSION, + command: request.command.clone(), + input: request.input.clone(), + session_variables, + command_fingerprint: None, + idempotency_key: None, + causation_id: None, + deadline_unix_ms: None, + } + } + + pub fn into_request(self) -> Result { + if self.version != COMMAND_DISPATCH_ENVELOPE_VERSION { + return Err(format!( + "unsupported command dispatch envelope version {}", + self.version + )); + } + Ok(CommandRequest { + command: self.command, + input: self.input, + session_variables: self.session_variables.into_iter().collect(), + }) + } + + pub fn canonical_bytes(&self) -> Result, serde_json::Error> { + serde_json::to_vec(self) + } +} + +/// Durable receipt returned alongside a successful or rejected dispatch. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandDispatchReceipt { + pub command: String, + pub status: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub causation_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ledger_id: Option, +} + +impl CommandDispatchReceipt { + pub fn from_response(command: &str, response: &CommandResponse) -> Self { + Self { + command: command.to_string(), + status: response.status, + causation_id: None, + idempotency_key: None, + ledger_id: None, + } + } +} diff --git a/src/command_dispatch/error.rs b/src/command_dispatch/error.rs new file mode 100644 index 000000000..73f17f0f5 --- /dev/null +++ b/src/command_dispatch/error.rs @@ -0,0 +1,30 @@ +use std::fmt; + +/// Fail-closed dispatch errors shared by local and remote adapters. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommandDispatchError { + /// Envelope version/codec/fingerprint rejected before execution. + Rejected(String), + /// Destination unknown or ambiguous for the selected process plan. + Unroutable(String), + /// Remote transport or trust failure (no ambiguous success). + Transport(String), + /// Deadline exceeded before a durable outcome was known. + DeadlineExceeded, + /// Handler/domain failure mapped through the approved contract. + Handler(String), +} + +impl fmt::Display for CommandDispatchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Rejected(reason) => write!(formatter, "command rejected: {reason}"), + Self::Unroutable(reason) => write!(formatter, "command unroutable: {reason}"), + Self::Transport(reason) => write!(formatter, "command transport error: {reason}"), + Self::DeadlineExceeded => write!(formatter, "command dispatch deadline exceeded"), + Self::Handler(reason) => write!(formatter, "command handler error: {reason}"), + } + } +} + +impl std::error::Error for CommandDispatchError {} diff --git a/src/command_dispatch/local.rs b/src/command_dispatch/local.rs new file mode 100644 index 000000000..57632024f --- /dev/null +++ b/src/command_dispatch/local.rs @@ -0,0 +1,35 @@ +//! Local dispatcher adapter over an in-process [`Service`]. + +use super::{CommandDispatchError, CommandDispatcher}; +use crate::microsvc::{CommandRequest, CommandResponse, Service}; +use async_trait::async_trait; +use std::sync::Arc; + +/// Dispatches commands to a local executable [`Service`]. +pub struct LocalCommandDispatcher { + service: Arc, +} + +impl LocalCommandDispatcher { + pub fn new(service: Arc) -> Self { + Self { service } + } + + pub fn service(&self) -> &Arc { + &self.service + } +} + +#[async_trait] +impl CommandDispatcher for LocalCommandDispatcher { + async fn dispatch( + &self, + request: &CommandRequest, + ) -> Result { + Ok(self.service.dispatch_request(request).await) + } + + fn kind(&self) -> &'static str { + "local" + } +} diff --git a/src/command_dispatch/mod.rs b/src/command_dispatch/mod.rs new file mode 100644 index 000000000..2fbffe3d3 --- /dev/null +++ b/src/command_dispatch/mod.rs @@ -0,0 +1,39 @@ +//! Versioned command dispatch boundary for local and remote execution. +//! +//! GraphQL mutation execution and process hosts depend on +//! [`CommandDispatcher`] rather than a concrete `Service`. Schema and client +//! compilation never construct a dispatcher. + +mod envelope; +mod error; +mod local; +mod remote; + +pub use envelope::{ + CommandDispatchEnvelope, CommandDispatchReceipt, COMMAND_DISPATCH_ENVELOPE_VERSION, +}; +pub use error::CommandDispatchError; +pub use local::LocalCommandDispatcher; +pub use remote::{ + RemoteCommandDispatcher, RemoteDispatchConfig, RemoteTrustMode, APPROVED_REMOTE_DISPATCH_PROFILE, +}; + +use crate::microsvc::{CommandRequest, CommandResponse}; +use async_trait::async_trait; +use std::sync::Arc; + +/// Object-safe async command dispatcher shared by GraphQL and process hosts. +#[async_trait] +pub trait CommandDispatcher: Send + Sync { + /// Dispatch one versioned command request and return the durable response. + async fn dispatch( + &self, + request: &CommandRequest, + ) -> Result; + + /// Human-stable dispatcher kind for inspection/metrics. + fn kind(&self) -> &'static str; +} + +/// Shared handle used by GraphQL engines and runtimes. +pub type SharedCommandDispatcher = Arc; diff --git a/src/command_dispatch/remote.rs b/src/command_dispatch/remote.rs new file mode 100644 index 000000000..89bdb68a6 --- /dev/null +++ b/src/command_dispatch/remote.rs @@ -0,0 +1,274 @@ +//! Remote command dispatch adapter (task 20 approved profile). +//! +//! Transport, trust, credential, and replay rules are fixed by +//! [`APPROVED_REMOTE_DISPATCH_PROFILE`]. This module implements only that +//! approved contract — it does not invent alternate security modes. + +use super::envelope::{CommandDispatchEnvelope, COMMAND_DISPATCH_ENVELOPE_VERSION}; +use super::{CommandDispatchError, CommandDispatcher}; +use crate::microsvc::{CommandRequest, CommandResponse}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Stable identifier for the single production remote profile approved by +/// task 20. Implementation and tests must cite this constant. +pub const APPROVED_REMOTE_DISPATCH_PROFILE: &str = + "distributed.command_dispatch.remote.v1.https_mtls_service_identity"; + +/// Trust mode fixed by the approved remote profile. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteTrustMode { + /// Mutual TLS with workload/service identity (approved production mode). + MutualTlsServiceIdentity, +} + +/// Configuration for the approved remote adapter. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RemoteDispatchConfig { + /// Logical destination id from the deployment plan topology. + pub destination: String, + /// Absolute HTTPS endpoint for the writer process. + pub endpoint: String, + /// Trust mode — only the approved production mode is accepted. + pub trust: RemoteTrustMode, + /// Maximum request body bytes. + pub max_body_bytes: usize, + /// Request timeout. + pub timeout_ms: u64, +} + +impl RemoteDispatchConfig { + pub fn validate(&self) -> Result<(), CommandDispatchError> { + if self.destination.trim().is_empty() { + return Err(CommandDispatchError::Unroutable( + "remote destination must not be empty".into(), + )); + } + if !self.endpoint.starts_with("https://") { + return Err(CommandDispatchError::Rejected( + "approved remote profile requires https endpoints".into(), + )); + } + if !matches!(self.trust, RemoteTrustMode::MutualTlsServiceIdentity) { + return Err(CommandDispatchError::Rejected( + "unsupported remote trust mode".into(), + )); + } + if self.max_body_bytes == 0 || self.timeout_ms == 0 { + return Err(CommandDispatchError::Rejected( + "remote max_body_bytes and timeout_ms must be positive".into(), + )); + } + Ok(()) + } +} + +/// HTTP client abstraction so tests can loopback without a network. +#[async_trait] +pub trait RemoteCommandTransport: Send + Sync { + async fn post_json( + &self, + endpoint: &str, + body: &[u8], + headers: BTreeMap, + ) -> Result<(u16, Vec), CommandDispatchError>; +} + +/// Remote dispatcher that encodes the approved envelope and posts it. +pub struct RemoteCommandDispatcher { + config: RemoteDispatchConfig, + transport: Arc, +} + +impl RemoteCommandDispatcher { + pub fn new( + config: RemoteDispatchConfig, + transport: Arc, + ) -> Result { + config.validate()?; + Ok(Self { config, transport }) + } + + pub fn config(&self) -> &RemoteDispatchConfig { + &self.config + } + + pub fn profile(&self) -> &'static str { + APPROVED_REMOTE_DISPATCH_PROFILE + } +} + +#[async_trait] +impl CommandDispatcher for RemoteCommandDispatcher { + async fn dispatch( + &self, + request: &CommandRequest, + ) -> Result { + let mut envelope = CommandDispatchEnvelope::from_request(request); + envelope.version = COMMAND_DISPATCH_ENVELOPE_VERSION; + // Never accept caller-supplied roles as trusted identity across the wire. + // The approved profile requires the writer to reconstruct identity from + // mTLS service identity + framework adapters. Session variables that + // look like forwarded roles are stripped here. + envelope.session_variables.retain(|key, _| { + let lower = key.to_ascii_lowercase(); + !(lower == "x-roles" || lower == "roles" || lower.ends_with("-roles")) + }); + + let body = envelope + .canonical_bytes() + .map_err(|error| CommandDispatchError::Rejected(error.to_string()))?; + if body.len() > self.config.max_body_bytes { + return Err(CommandDispatchError::Rejected(format!( + "remote command body exceeds max_body_bytes {}", + self.config.max_body_bytes + ))); + } + + if let Some(deadline) = envelope.deadline_unix_ms { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_millis() as u64; + if now > deadline { + return Err(CommandDispatchError::DeadlineExceeded); + } + } + + let mut headers = BTreeMap::new(); + headers.insert( + "content-type".into(), + "application/json".into(), + ); + headers.insert( + "x-distributed-dispatch-profile".into(), + APPROVED_REMOTE_DISPATCH_PROFILE.into(), + ); + headers.insert( + "x-distributed-destination".into(), + self.config.destination.clone(), + ); + + let (status, response_body) = self + .transport + .post_json(&self.config.endpoint, &body, headers) + .await?; + if !(200..300).contains(&status) { + return Err(CommandDispatchError::Transport(format!( + "remote writer returned status {status}" + ))); + } + let response: CommandResponse = serde_json::from_slice(&response_body).map_err(|error| { + CommandDispatchError::Transport(format!( + "remote writer returned invalid response: {error}" + )) + })?; + Ok(response) + } + + fn kind(&self) -> &'static str { + "remote" + } +} + +/// In-memory loopback transport for parity tests. +#[allow(dead_code)] +pub struct LoopbackRemoteTransport { + handler: Arc CommandResponse + Send + Sync>, +} + +#[allow(dead_code)] +impl LoopbackRemoteTransport { + pub fn new( + handler: impl Fn(CommandRequest) -> CommandResponse + Send + Sync + 'static, + ) -> Self { + Self { + handler: Arc::new(handler), + } + } +} + +#[async_trait] +impl RemoteCommandTransport for LoopbackRemoteTransport { + async fn post_json( + &self, + _endpoint: &str, + body: &[u8], + _headers: BTreeMap, + ) -> Result<(u16, Vec), CommandDispatchError> { + let envelope: CommandDispatchEnvelope = serde_json::from_slice(body).map_err(|error| { + CommandDispatchError::Rejected(format!("invalid remote envelope: {error}")) + })?; + let request = envelope + .into_request() + .map_err(CommandDispatchError::Rejected)?; + let response = (self.handler)(request); + let bytes = serde_json::to_vec(&response).map_err(|error| { + CommandDispatchError::Transport(format!("encode loopback response: {error}")) + })?; + Ok((200, bytes)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + #[tokio::test] + async fn remote_loopback_preserves_command_bytes_and_strips_forwarded_roles() { + let transport = Arc::new(LoopbackRemoteTransport::new(|request| { + assert!(!request.session_variables.contains_key("x-roles")); + CommandResponse { + status: 200, + body: json!({ "ok": true, "command": request.command }), + } + })); + let dispatcher = RemoteCommandDispatcher::new( + RemoteDispatchConfig { + destination: "todo-writer".into(), + endpoint: "https://writer.example/commands".into(), + trust: RemoteTrustMode::MutualTlsServiceIdentity, + max_body_bytes: 64 * 1024, + timeout_ms: 5_000, + }, + transport, + ) + .unwrap(); + assert_eq!(dispatcher.profile(), APPROVED_REMOTE_DISPATCH_PROFILE); + + let mut session = HashMap::new(); + session.insert("x-user-id".into(), "user-1".into()); + session.insert("x-roles".into(), "admin".into()); + let response = dispatcher + .dispatch(&CommandRequest { + command: "todo.create".into(), + input: json!({ "title": "hi" }), + session_variables: session, + }) + .await + .unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.body["command"], "todo.create"); + } + + #[test] + fn remote_config_requires_https_and_approved_trust() { + let err = RemoteDispatchConfig { + destination: "w".into(), + endpoint: "http://insecure".into(), + trust: RemoteTrustMode::MutualTlsServiceIdentity, + max_body_bytes: 1, + timeout_ms: 1, + } + .validate() + .unwrap_err(); + assert!(err.to_string().contains("https")); + } +} diff --git a/src/graphql/client_manifest/build.rs b/src/graphql/client_manifest/build.rs index 67543004d..8e9de235a 100644 --- a/src/graphql/client_manifest/build.rs +++ b/src/graphql/client_manifest/build.rs @@ -38,10 +38,17 @@ pub(super) fn client_manifest_from_surface_with_execution( ( SurfaceSelection::Application { name: selected_name, - roles: selected_roles, + eligible_roles: selected_eligible_roles, + schema_roles: selected_schema_roles, }, - ClientSurfaceIdentity::Application { name, roles }, - ) if selected_name == name && selected_roles == roles => {} + ClientSurfaceIdentity::Application { + name, + eligible_roles, + schema_roles, + }, + ) if selected_name == name + && selected_eligible_roles == eligible_roles + && selected_schema_roles == schema_roles => {} _ => { return Err(ClientManifestError( "client Surface identity does not match its authorization selection provenance" diff --git a/src/graphql/client_manifest/export.rs b/src/graphql/client_manifest/export.rs index 93638982f..8c2e30791 100644 --- a/src/graphql/client_manifest/export.rs +++ b/src/graphql/client_manifest/export.rs @@ -37,7 +37,7 @@ impl DistributedClientSurfaceExport { /// Safe, low-boilerplate export path: authorization identity is derived /// from the selected Surface and cannot be caller-asserted. - pub(crate) fn from_selected( + pub fn from_selected( service_id: impl Into, surface: impl Into>, ) -> Result { @@ -59,49 +59,64 @@ impl DistributedClientSurfaceExport { )); } SurfaceSelection::Role { name } => ClientSurfaceIdentity::role(name), - SurfaceSelection::Application { name, roles } => { - ClientSurfaceIdentity::application(name, roles.clone()) + SurfaceSelection::Application { + name, + eligible_roles, + schema_roles, + } => { + 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)) } - /// Build a portable export whose service identity comes from the same - /// project manifest that supplied its table inventory. - pub fn from_project( - project: &DistributedProjectManifest, + /// Build a selected client contract without executable service provenance. + /// + /// This is the contract-only compiler boundary. It accepts the same + /// already-selected Surface IR as the runtime export but never constructs + /// a repository, lock manager, Service, or handler mount. + pub fn from_contract( + service_id: impl Into, surface: impl Into>, ) -> Result { let surface = surface.into(); - for model in surface.models.values() { - let Some(original) = project.tables.iter().find(|schema| { - schema.model_name == model.model_name && schema.table_name == model.table_name - }) else { - return Err(ClientManifestError(format!( - "selected Surface model `{}` does not match the supplied project manifest inventory", - model.model_name - ))); - }; - let mut selected_schema = model.schema.clone(); - for column in &mut selected_schema.columns { - if let Some(original_column) = original - .columns - .iter() - .find(|candidate| candidate.column_name == column.column_name) - { - column.skipped = original_column.skipped; - } + let service_id = service_id.into(); + let identity = match &surface.selection { + SurfaceSelection::Catalog => { + return Err(ClientManifestError( + "client exports require an explicitly role- or application-selected Surface" + .into(), + )); } - selected_schema.relationships = original.relationships.clone(); - if &selected_schema != original { - return Err(ClientManifestError(format!( - "selected Surface model `{}` does not match the supplied project manifest inventory", - model.model_name - ))); + SurfaceSelection::Role { name } => ClientSurfaceIdentity::role(name), + SurfaceSelection::Application { + name, + eligible_roles, + schema_roles, + } => { + ClientSurfaceIdentity::application_with_schema_roles( + name, + eligible_roles.clone(), + schema_roles.clone(), + ) } + }; + if surface.service_binding.is_some() { + return Err(ClientManifestError( + "contract-only client export cannot carry executable Service provenance".into(), + )); } - Self::from_selected(project.name.clone(), surface) + Ok(Self::new( + service_id, + identity, + surface, + ClientExecutionLimits::default(), + )) } pub fn manifest(&self) -> Result { diff --git a/src/graphql/client_manifest/identity.rs b/src/graphql/client_manifest/identity.rs index 59b899c47..eb059670c 100644 --- a/src/graphql/client_manifest/identity.rs +++ b/src/graphql/client_manifest/identity.rs @@ -4,7 +4,14 @@ use super::*; #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ClientSurfaceIdentity { Role { name: String }, - Application { name: String, roles: Vec }, + /// `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. + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } impl ClientSurfaceIdentity { @@ -14,14 +21,27 @@ impl ClientSurfaceIdentity { pub fn application( name: impl Into, - roles: impl IntoIterator>, + eligible_roles: impl IntoIterator>, + schema_roles: impl IntoIterator>, ) -> Self { - let mut roles: Vec = roles.into_iter().map(Into::into).collect(); - roles.sort(); - roles.dedup(); + Self::application_with_schema_roles(name, eligible_roles, schema_roles) + } + + pub fn application_with_schema_roles( + name: impl Into, + eligible_roles: impl IntoIterator>, + schema_roles: impl IntoIterator>, + ) -> Self { + let mut eligible_roles: Vec = eligible_roles.into_iter().map(Into::into).collect(); + let mut schema_roles: Vec = schema_roles.into_iter().map(Into::into).collect(); + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); Self::Application { name: name.into(), - roles, + eligible_roles, + schema_roles, } } @@ -34,20 +54,48 @@ impl ClientSurfaceIdentity { Self::Application { name, .. } if name.trim().is_empty() => Err(ClientManifestError( "application surface name must not be empty".into(), )), - Self::Application { name, mut roles } => { - if roles.iter().any(|role| role.trim().is_empty()) { + Self::Application { + name, + mut eligible_roles, + mut schema_roles, + } => { + if eligible_roles.iter().any(|role| role.trim().is_empty()) { return Err(ClientManifestError(format!( "application surface `{name}` contains an empty role id" ))); } - roles.sort(); - roles.dedup(); - if roles.is_empty() { + if schema_roles.iter().any(|role| role.trim().is_empty()) { + return Err(ClientManifestError(format!( + "application surface `{name}` contains an empty schema role id" + ))); + } + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); + if eligible_roles.is_empty() { + return Err(ClientManifestError(format!( + "application surface `{name}` must declare at least one eligible role" + ))); + } + if schema_roles.is_empty() { + return Err(ClientManifestError(format!( + "application surface `{name}` must declare at least one schema role" + ))); + } + if schema_roles + .iter() + .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) + { return Err(ClientManifestError(format!( - "application surface `{name}` must declare at least one role" + "application surface `{name}` schema roles must be a subset of eligible roles" ))); } - Ok(Self::Application { name, roles }) + Ok(Self::Application { + name, + eligible_roles, + schema_roles, + }) } } } diff --git a/src/graphql/client_manifest/mod.rs b/src/graphql/client_manifest/mod.rs index 11546aec5..c8fb8eed1 100644 --- a/src/graphql/client_manifest/mod.rs +++ b/src/graphql/client_manifest/mod.rs @@ -36,7 +36,6 @@ use super::surface::{ SurfaceCommand, SurfaceCommandShape, SurfaceRelationshipKeys, SurfaceRowPolicy, SurfaceSelection, SurfaceTypeDef, }; -use crate::manifest::DistributedProjectManifest; use crate::table::RelationshipKind; use build::client_manifest_from_surface_with_execution; @@ -75,8 +74,9 @@ pub use types::{ ClientRootKind, ClientRootOperation, ClientRowPolicy, ClientTrustedPresetDescriptor, ClientTypeDef, ClientTypeField, CommandConfirmationsExtension, CommandConsistencyExtension, CommandDirectProjectionExtension, CommandEffectsExtension, CommandInputDefaultsExtension, - CommandProjectionArmRef, CommandProjectionExtension, CommandProjectionPreviewOccurrence, - CommandProjectionPreviewValue, DistributedClientManifest, ModelNormalization, + ClientCommandPureArg, ClientCommandPureReduce, 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 a37d66d93..4ef863fa0 100644 --- a/src/graphql/client_manifest/projections.rs +++ b/src/graphql/client_manifest/projections.rs @@ -156,79 +156,431 @@ pub(super) fn command_projection_extension( .collect(); let mut preview_occurrences = Vec::new(); - for preview in &command.projections.previews { - let preview_event = event_ref(&preview.selector); - if program_arms.iter().all(|arm| arm.event != preview_event) { - continue; + if command.projections.previews.is_empty() { + // Automatic optimism: input + defaults + row-policy claims + emits + + // projection arms. No third mapping document is required. Unresolved + // slots stay Unknown and fall back to revalidation. + let claim_presets = surface_row_policy_claim_presets(surface); + let mut seen_event_ids = BTreeSet::new(); + for selector in emitted { + let preview_event = event_ref(selector); + if !seen_event_ids.insert(preview_event.id.clone()) { + continue; + } + if program_arms.iter().all(|arm| arm.event != preview_event) { + continue; + } + let inferred = command + .projections + .inferred_values + .iter() + .find(|values| values.selector == *selector); + let mut values = slot_origins + .iter() + .filter(|origin| origin.event == *selector) + .map(|origin| { + let inferred_source = inferred + .and_then(|values| { + values.preview.fields.iter().find(|field| { + let target = match field.envelope { + Some(envelope) => SlotTarget::Envelope { field: envelope }, + None => SlotTarget::BodyPath { + path: field.body_path.clone(), + }, + }; + target == origin.target + }) + }) + .map(|field| { + client_preview_source( + &field.source, + matches!(origin.target, SlotTarget::BodyPath { .. }), + match origin.target { + SlotTarget::Envelope { field } => Some(field), + SlotTarget::BodyPath { .. } => None, + }, + field.body_type, + field.body_rust_type, + field.body_nullable, + field.body_always_present, + &origin.value_type, + command, + ) + }) + .transpose()?; + Ok(CommandProjectionPreviewValue { + slot: origin.slot.clone(), + source: inferred_source.unwrap_or_else(|| { + auto_preview_source(origin, command, &claim_presets) + }), + }) + }) + .collect::, ClientManifestError>>()?; + values.sort_by(|left, right| left.slot.cmp(&right.slot)); + values.dedup_by(|left, right| left.slot == right.slot); + let ordinal = u32::try_from(preview_occurrences.len()).map_err(|_| { + ClientManifestError(format!( + "command `{}` declares too many projection preview occurrences", + command.command_name + )) + })?; + preview_occurrences.push(CommandProjectionPreviewOccurrence { + ordinal, + event: preview_event, + values, + }); } - let occurrence_origins = slot_origins - .iter() - .filter(|origin| origin.event == preview.selector) - .collect::>(); - let mut values = occurrence_origins - .into_iter() - .map(|origin| { - let source = preview - .preview - .fields + } else { + for preview in &command.projections.previews { + let preview_event = event_ref(&preview.selector); + if program_arms.iter().all(|arm| arm.event != preview_event) { + continue; + } + let occurrence_origins = slot_origins + .iter() + .filter(|origin| origin.event == preview.selector) + .collect::>(); + let mut values = occurrence_origins + .into_iter() + .map(|origin| { + let source = preview + .preview + .fields + .iter() + .find(|field| { + let target = match field.envelope { + Some(envelope) => SlotTarget::Envelope { field: envelope }, + None => SlotTarget::BodyPath { + path: field.body_path.clone(), + }, + }; + target == origin.target + }) + .map(|field| { + client_preview_source( + &field.source, + matches!(origin.target, SlotTarget::BodyPath { .. }), + match origin.target { + SlotTarget::Envelope { field } => Some(field), + SlotTarget::BodyPath { .. } => None, + }, + field.body_type, + field.body_rust_type, + field.body_nullable, + field.body_always_present, + &origin.value_type, + command, + ) + }) + .transpose()? + .unwrap_or(ClientProjectionPreviewSource::Unknown); + Ok(CommandProjectionPreviewValue { + slot: origin.slot.clone(), + source, + }) + }) + .collect::, ClientManifestError>>()?; + values.sort_by(|left, right| left.slot.cmp(&right.slot)); + values.dedup_by(|left, right| left.slot == right.slot); + let ordinal = u32::try_from(preview_occurrences.len()).map_err(|_| { + ClientManifestError(format!( + "command `{}` declares too many projection preview occurrences", + command.command_name + )) + })?; + preview_occurrences.push(CommandProjectionPreviewOccurrence { + ordinal, + event: preview_event, + values, + }); + } + } + + let pure_reduces = command + .projections + .pure_reduces + .iter() + .map(|reduce| { + 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() }, + ServerSource::GeneratedDefaultPath { path } => { + ClientSource::GeneratedDefault { path: path.clone() } + } + ServerSource::TrustedPreset { name, codec } => ClientSource::TrustedPreset { + name: name.clone(), + codec: codec.clone(), + }, + other => { + return Err(ClientManifestError(format!( + "command `{}` pure reduce uses unsupported source {other:?}", + command.command_name + ))); + } + }) + }; + Ok(crate::graphql::client_manifest::ClientCommandPureReduce { + fn_name: reduce.fn_name.clone(), + client_module: reduce.client_module.clone(), + client_export: reduce.client_export.clone(), + wasm_package: reduce.wasm_package.clone(), + wasm_export: reduce.wasm_export.clone(), + model: reduce.model.clone(), + key: reduce + .key .iter() - .find(|field| { - let target = match field.envelope { - Some(envelope) => SlotTarget::Envelope { field: envelope }, - None => SlotTarget::BodyPath { - path: field.body_path.clone(), - }, - }; - target == origin.target + .map(|arg| { + Ok(crate::graphql::client_manifest::ClientCommandPureArg { + name: arg.name.clone(), + source: map_source(&arg.source)?, + }) }) - .map(|field| { - client_preview_source( - &field.source, - matches!(origin.target, SlotTarget::BodyPath { .. }), - match origin.target { - SlotTarget::Envelope { field } => Some(field), - SlotTarget::BodyPath { .. } => None, - }, - field.body_type, - field.body_rust_type, - field.body_nullable, - field.body_always_present, - &origin.value_type, - command, - ) + .collect::, ClientManifestError>>()?, + args: reduce + .args + .iter() + .map(|arg| { + Ok(crate::graphql::client_manifest::ClientCommandPureArg { + name: arg.name.clone(), + source: map_source(&arg.source)?, + }) }) - .transpose()? - .unwrap_or(ClientProjectionPreviewSource::Unknown); - Ok(CommandProjectionPreviewValue { - slot: origin.slot.clone(), - source, - }) + .collect::, ClientManifestError>>()?, + assign: reduce.assign.clone(), }) - .collect::, ClientManifestError>>()?; - values.sort_by(|left, right| left.slot.cmp(&right.slot)); - values.dedup_by(|left, right| left.slot == right.slot); - let ordinal = u32::try_from(preview_occurrences.len()).map_err(|_| { - ClientManifestError(format!( - "command `{}` declares too many projection preview occurrences", - command.command_name - )) - })?; - preview_occurrences.push(CommandProjectionPreviewOccurrence { - ordinal, - event: preview_event, - values, - }); - } + }) + .collect::, ClientManifestError>>()?; Ok(Some(CommandProjectionExtension { version: COMMAND_PROJECTION_EXTENSION_VERSION, event_set, program_arms, preview_occurrences, + pure_reduces, fallback: ClientProjectionFallback::Revalidate, })) } +/// Map row-policy `column == claim(header)` bindings to trusted-preset sources. +/// +/// Keyed by the model field / body-path leaf name so automatic optimism can +/// fill owner-like slots without a third mapping API. +fn surface_row_policy_claim_presets(surface: &Surface) -> BTreeMap { + let mut presets = BTreeMap::new(); + for model in surface.models.values() { + let SurfaceRowPolicy::Predicate(expression) = &model.row_policy else { + continue; + }; + collect_row_policy_claim_presets(expression, model, surface, &mut presets); + } + presets +} + +fn collect_row_policy_claim_presets( + expression: &FilterExpr, + model: &crate::graphql::surface::SurfaceModel, + surface: &Surface, + presets: &mut BTreeMap, +) { + match expression { + FilterExpr::And(expressions) | FilterExpr::Or(expressions) => { + for expression in expressions { + collect_row_policy_claim_presets(expression, model, surface, presets); + } + } + FilterExpr::Not(expression) => { + collect_row_policy_claim_presets(expression, model, surface, presets); + } + FilterExpr::Cmp { + column, + rhs: Operand::Claim(claim), + .. + } => { + insert_row_policy_claim_preset(model, column, &claim.header, presets); + } + FilterExpr::In { column, values, .. } => { + for value in values { + if let Operand::Claim(claim) = value { + insert_row_policy_claim_preset(model, column, &claim.header, presets); + } + } + } + FilterExpr::Rel { field, predicate } => { + let Some(relationship) = model + .relationships + .iter() + .find(|relationship| relationship.name == *field) + else { + return; + }; + let Some(target) = surface.models.get(&relationship.target_model) else { + return; + }; + collect_row_policy_claim_presets(predicate, target, surface, presets); + } + FilterExpr::Cmp { .. } | FilterExpr::IsNull { .. } => {} + } +} + +fn insert_row_policy_claim_preset( + model: &crate::graphql::surface::SurfaceModel, + column: &str, + claim: &str, + presets: &mut BTreeMap, +) { + let codec = resolve_row_policy_column_codec(model, column); + let Some(codec) = codec else { + return; + }; + if matches!(codec, "base64" | "json") { + return; + } + // Index by the policy column text and every alias (logical field name, + // physical column name) so body-path leaves and GraphQL field names both + // resolve when they describe the same column. + let mut keys = BTreeSet::from([column.to_owned()]); + if let Some(schema_column) = model.schema.columns.iter().find(|candidate| { + !candidate.skipped && (candidate.field_name == column || candidate.column_name == column) + }) { + keys.insert(schema_column.field_name.clone()); + keys.insert(schema_column.column_name.clone()); + } + for field in &model.columns { + if field.name == column || keys.contains(&field.name) { + keys.insert(field.name.clone()); + } + } + for key in keys { + match presets.entry(key) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert((claim.to_owned(), codec.to_owned())); + } + std::collections::btree_map::Entry::Occupied(entry) + if entry.get().0 == claim && entry.get().1 == codec => {} + // Conflicting claim/codec for the same column is not portable; leave + // the first binding and let remaining slots fall through to Unknown. + std::collections::btree_map::Entry::Occupied(_) => {} + } + } +} + +fn resolve_row_policy_column_codec( + model: &crate::graphql::surface::SurfaceModel, + column: &str, +) -> Option<&'static str> { + if let Some(field) = model.columns.iter().find(|field| field.name == column) { + return super::scalar_codec(&field.scalar); + } + let schema_column = model.schema.columns.iter().find(|candidate| { + !candidate.skipped && (candidate.field_name == column || candidate.column_name == column) + })?; + if let Some(field) = model + .columns + .iter() + .find(|field| field.name == schema_column.column_name) + { + return super::scalar_codec(&field.scalar); + } + match schema_column.column_type { + crate::table::ColumnType::Text | crate::table::ColumnType::Timestamp => Some("string"), + crate::table::ColumnType::Boolean => Some("boolean"), + crate::table::ColumnType::Integer => Some("int32"), + crate::table::ColumnType::UnsignedInteger => Some("json_number_precision_limited"), + crate::table::ColumnType::Float => Some("float64"), + crate::table::ColumnType::Json => Some("json"), + crate::table::ColumnType::Bytes => Some("base64"), + crate::table::ColumnType::Unsupported(_) => None, + } +} + +/// Derive one client preview source from command input / defaults / claims. +fn auto_preview_source( + origin: &SlotOrigin, + command: &SurfaceCommand, + claim_presets: &BTreeMap, +) -> ClientProjectionPreviewSource { + match &origin.target { + SlotTarget::Envelope { + field: ProjectionEnvelopeField::AggregateId, + } => { + if let Some(path) = auto_aggregate_id_path(command, &origin.value_type) { + if command + .input_defaults + .iter() + .any(|default| default.path == path) + { + ClientProjectionPreviewSource::GeneratedDefault { path } + } else { + ClientProjectionPreviewSource::Input { path } + } + } else { + ClientProjectionPreviewSource::Unknown + } + } + SlotTarget::Envelope { .. } => ClientProjectionPreviewSource::Unknown, + SlotTarget::BodyPath { path } => { + if command_input_field(&command.input, path) + .is_some_and(|field| input_field_compatible(field, &origin.value_type, None, None)) + { + if command + .input_defaults + .iter() + .any(|default| default.path == *path) + { + return ClientProjectionPreviewSource::GeneratedDefault { path: path.clone() }; + } + return ClientProjectionPreviewSource::Input { path: path.clone() }; + } + if let Some(leaf) = path.last() { + if let Some((name, codec)) = claim_presets.get(leaf) { + if codec_compatible(codec, &origin.value_type) { + return ClientProjectionPreviewSource::TrustedPreset { + name: name.clone(), + codec: codec.clone(), + }; + } + } + } + ClientProjectionPreviewSource::Unknown + } + } +} + +/// Pick the best aggregate-id input path: generated id-like fields first, then +/// explicit id-like fields (`id`, `*_id`, or GraphQL `ID`). +fn auto_aggregate_id_path( + command: &SurfaceCommand, + expected: &ProjectionValueType, +) -> Option> { + let SurfaceCommandShape::Typed(definition) = &command.input else { + return None; + }; + let mut generated = Vec::new(); + let mut explicit = Vec::new(); + for field in &definition.fields { + if field.list || !input_field_compatible(field, expected, None, None) { + continue; + } + let id_like = field.type_name == "ID" || field.name == "id" || field.name.ends_with("_id"); + if !id_like { + continue; + } + let path = vec![field.name.clone()]; + if command + .input_defaults + .iter() + .any(|default| default.path == path) + { + generated.push(path); + } else { + explicit.push(path); + } + } + generated.into_iter().chain(explicit).next() +} + fn lower_program( program_id: crate::ProjectionProgramId, program: &SurfaceSelectedProjectionProgram, @@ -1174,6 +1526,48 @@ mod tests { } } + fn selected_value_patch_program( + name: &str, + selector: crate::ProjectionEventSelector, + ) -> SurfaceSelectedProjectionProgram { + SurfaceSelectedProjectionProgram { + name: name.into(), + version: 1, + ir_version: crate::projection::PROJECTION_PROGRAM_IR_VERSION, + operation_semantics_version: crate::projection::PROJECTION_OPERATION_SEMANTICS_VERSION, + partition: ProjectionPartition::Unit, + arms: vec![SurfaceProjectionArm { + arm_id: format!("{name}-arm"), + selector, + operations: vec![SurfaceProjectionOperation { + operation_id: format!("{name}-patch"), + staging_ordinal: 0, + kind: ProjectionMutationKind::Patch, + model: "TodoView".into(), + storage: "todos".into(), + key: vec![ProjectionKeyField::try_new( + 0, + "todo_id", + ProjectionExpression::constant(ProjectionValue::string("todo-1")), + ) + .unwrap()], + fields: vec![crate::projection::ProjectionField::try_new( + 0, + "title", + crate::projection::ProjectionAssignment::Set( + ProjectionExpression::body_path(ProjectionValueType::String, ["value"]) + .unwrap(), + ), + ) + .unwrap()], + relationship_effects: Vec::new(), + invalidations: Vec::new(), + force_revalidate: false, + }], + }], + } + } + fn typed_body_preview(source: CommandProjectionPreviewSource) -> CommandProjectionPreview { let mut preview = CommandProjectionPreview::new() .events(crate::events![PreviewA]) @@ -1224,7 +1618,7 @@ mod tests { } #[test] - fn allowed_event_arms_do_not_invent_optimistic_occurrences() { + fn allowed_event_arms_auto_derive_optimistic_occurrences_when_previews_absent() { let selector_a = typed_selector::(); let selector_b = typed_selector::(); let surface = surface_with_modeled([modeled( @@ -1245,10 +1639,232 @@ mod tests { .expect("eligible allowed arms remain visible"); assert_eq!(projection.event_set.len(), 2); assert_eq!(projection.program_arms.len(), 2); - assert!( - projection.preview_occurrences.is_empty(), - "an allowed actual event is not an optimistic prediction" + assert_eq!( + projection.preview_occurrences.len(), + 2, + "emits + projection arms auto-derive one occurrence per event when .applies is absent" ); + // AggregateId envelope has no id-like input field named value → Unknown. + assert!(projection + .preview_occurrences + .iter() + .all(|occurrence| occurrence + .values + .iter() + .all(|value| { matches!(value.source, ClientProjectionPreviewSource::Unknown) }))); + } + + #[test] + fn auto_optimism_maps_input_defaults_and_row_policy_claims() { + use crate::graphql::command_contract::{CommandInputDefault, InputDefaultGenerator}; + use crate::graphql::{claim, col}; + + let selector = typed_selector::(); + let selected = SurfaceSelectedProjectionProgram { + name: "auto-optimism".into(), + version: 1, + ir_version: crate::projection::PROJECTION_PROGRAM_IR_VERSION, + operation_semantics_version: crate::projection::PROJECTION_OPERATION_SEMANTICS_VERSION, + partition: ProjectionPartition::Unit, + arms: vec![SurfaceProjectionArm { + arm_id: "auto-arm".into(), + selector, + operations: vec![SurfaceProjectionOperation { + operation_id: "auto-upsert".into(), + staging_ordinal: 0, + kind: ProjectionMutationKind::Upsert, + model: "TodoView".into(), + storage: "todos".into(), + key: vec![ProjectionKeyField::try_new( + 0, + "todo_id", + ProjectionExpression::body_path(ProjectionValueType::String, ["todo_id"]) + .unwrap(), + ) + .unwrap()], + fields: vec![ + crate::projection::ProjectionField::try_new( + 0, + "owner_id", + crate::projection::ProjectionAssignment::Set( + ProjectionExpression::body_path( + ProjectionValueType::String, + ["owner_id"], + ) + .unwrap(), + ), + ) + .unwrap(), + crate::projection::ProjectionField::try_new( + 1, + "title", + crate::projection::ProjectionAssignment::Set( + ProjectionExpression::body_path( + ProjectionValueType::String, + ["title"], + ) + .unwrap(), + ), + ) + .unwrap(), + ], + relationship_effects: Vec::new(), + invalidations: Vec::new(), + force_revalidate: false, + }], + }], + }; + let mut surface = + surface_with_modeled([modeled(30, ProjectionPlacement::Eventual, Some(selected))]); + surface.models.get_mut("TodoView").unwrap().row_policy = + crate::graphql::surface::SurfaceRowPolicy::Predicate( + col("owner_id").eq(claim("x-user-id")), + ); + + let mut command = SurfaceCommand { + command_name: "todo.create".into(), + field_name: "todos_create".into(), + roles: vec!["user".into()], + input: SurfaceCommandShape::Typed(SurfaceTypeDef { + name: "TodoCreateInput".into(), + fields: vec![ + SurfaceTypeField { + name: "todo_id".into(), + type_name: "ID".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }, + SurfaceTypeField { + name: "title".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }, + ], + }), + output: SurfaceCommandShape::None, + consistency: CommandConsistency::Succeeded, + input_defaults: vec![CommandInputDefault { + path: vec!["todo_id".into()], + generator: InputDefaultGenerator::UuidV7, + }], + effects: None, + confirmations: Vec::new(), + projected_model: None, + direct_projection: None, + projections: Default::default(), + confirmation_unavailable: false, + }; + command.projections.add_event_set(crate::events![PreviewA]); + + let projection = command_projection_extension(&command, &surface, &[]) + .unwrap() + .expect("auto optimism extension"); + assert_eq!(projection.preview_occurrences.len(), 1); + let sources: BTreeMap<_, _> = projection.preview_occurrences[0] + .values + .iter() + .map(|value| (value.slot.clone(), value.source.clone())) + .collect(); + // Resolve slots via lowered program so we match opaque ids. + let (programs, _) = projection_manifest(&surface).unwrap(); + let arm = &programs[0].arms[0]; + let todo_slot = match &arm.operations[0].key[0].expression { + ClientProjectionExpression::Slot { slot, .. } => slot.clone(), + other => panic!("expected key slot, got {other:?}"), + }; + let owner_slot = match &arm.operations[0] + .fields + .iter() + .find(|f| f.name == "owner_fk") + { + Some(field) => match &field.assignment { + ClientProjectionAssignment::Set { + expression: ClientProjectionExpression::Slot { slot, .. }, + } => slot.clone(), + other => panic!("expected owner slot set, got {other:?}"), + }, + None => panic!("owner field missing"), + }; + let title_slot = match &arm.operations[0] + .fields + .iter() + .find(|f| f.name == "todo_title") + { + Some(field) => match &field.assignment { + ClientProjectionAssignment::Set { + expression: ClientProjectionExpression::Slot { slot, .. }, + } => slot.clone(), + other => panic!("expected title slot set, got {other:?}"), + }, + None => panic!("title field missing"), + }; + assert!(matches!( + sources.get(&todo_slot), + Some(ClientProjectionPreviewSource::GeneratedDefault { path }) + if path == &["todo_id"] + )); + assert!(matches!( + sources.get(&owner_slot), + Some(ClientProjectionPreviewSource::TrustedPreset { name, codec }) + if name == "x-user-id" && codec == "string" + )); + assert!(matches!( + sources.get(&title_slot), + Some(ClientProjectionPreviewSource::Input { path }) if path == &["title"] + )); + } + + #[test] + fn inferred_transition_values_feed_eventual_and_atomic_projection_strategies() { + for (seed, placement, consistency) in [ + ( + 31, + ProjectionPlacement::Eventual, + CommandConsistency::Eventual, + ), + (32, ProjectionPlacement::Direct, CommandConsistency::Atomic), + ] { + let selector = typed_selector::(); + let surface = surface_with_modeled([modeled( + seed, + placement, + Some(selected_value_patch_program( + "inferred-transition-value", + selector, + )), + )]); + let mut command = command("String"); + command.consistency = consistency; + command.projections.add_event_set(crate::events![PreviewA]); + command.projections.add_inferred_values( + crate::graphql::__command_projection_event_preview::(vec![( + "value", + CommandProjectionPreviewSource::constant(ProjectionValue::string( + "transition-value", + )), + )]), + ); + + let projection = command_projection_extension(&command, &surface, &[]) + .unwrap() + .expect("projection strategy"); + assert!(command.projections.previews.is_empty()); + assert_eq!(projection.preview_occurrences.len(), 1); + assert!(projection.preview_occurrences[0] + .values + .iter() + .any(|value| matches!( + &value.source, + ClientProjectionPreviewSource::Constant { + value: ClientProjectionValue::String(value), + } if value == "transition-value" + ))); + } } #[test] diff --git a/src/graphql/client_manifest/tests.rs b/src/graphql/client_manifest/tests.rs index c5ddc0f36..6fbaf3859 100644 --- a/src/graphql/client_manifest/tests.rs +++ b/src/graphql/client_manifest/tests.rs @@ -908,12 +908,12 @@ fn role_and_application_partition_manifests_hide_raw_paths_and_denied_values() { ); let all_grants = grants(); let role = surface_for_role(&full, "user", &all_grants["user"]).unwrap(); - let application = surface_for_application(&full, "web", &["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), ( - ClientSurfaceIdentity::application("web", ["user"]), + ClientSurfaceIdentity::application("web", ["user"], ["user"]), application, ), ] { @@ -1833,11 +1833,17 @@ 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()], &all_grants) + 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"]), + ClientSurfaceIdentity::application("web", ["admin", "user"], ["admin", "user"]), &selected, ) .unwrap(); @@ -1938,6 +1944,7 @@ fn mixed_target_projectors_are_omitted_for_role_and_application_surfaces() { &full, "web", &["admin".into(), "restricted".into()], + &["admin".into(), "restricted".into()], &BTreeMap::from([("admin".into(), admin), ("restricted".into(), restricted)]), ) .unwrap(); @@ -2309,12 +2316,20 @@ fn relational_row_policy_is_server_only_when_relationship_key_is_hidden() { fn application_role_sets_are_canonical_before_fingerprinting() { let full = full_surface(); let selected = - surface_for_application(&full, "web", &["admin".into(), "user".into()], &grants()).unwrap(); + 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 { name: "web".into(), - roles: vec!["user".into(), "admin".into(), "user".into()], + eligible_roles: vec!["user".into(), "admin".into(), "user".into()], + schema_roles: vec!["user".into(), "admin".into(), "user".into()], }, &selected, ) @@ -2323,7 +2338,8 @@ fn application_role_sets_are_canonical_before_fingerprinting() { "todos-service", ClientSurfaceIdentity::Application { name: "web".into(), - roles: vec!["admin".into(), "user".into()], + eligible_roles: vec!["admin".into(), "user".into()], + schema_roles: vec!["admin".into(), "user".into()], }, &selected, ) @@ -2332,7 +2348,7 @@ fn application_role_sets_are_canonical_before_fingerprinting() { assert_eq!(first.schema_fingerprint, second.schema_fingerprint); assert_eq!( first.surface, - ClientSurfaceIdentity::application("web", ["admin", "user"]) + ClientSurfaceIdentity::application("web", ["admin", "user"], ["admin", "user"]) ); } @@ -2346,9 +2362,9 @@ fn catalog_or_mismatched_surface_cannot_be_labeled_as_authorized() { .contains("explicitly role- or application-selected")); let selected = surface_for_role(&full, "user", &grants()["user"]).unwrap(); - let wrong_project = DistributedProjectManifest::new("wrong-service").table_schema(users()); let inventory_error = - DistributedClientSurfaceExport::from_project(&wrong_project, selected.clone()).unwrap_err(); + DistributedClientSurfaceExport::from_selected("wrong-service", selected.clone()) + .unwrap_err(); assert!(inventory_error.to_string().contains("does not match")); let error = client_manifest_from_surface( diff --git a/src/graphql/client_manifest/types.rs b/src/graphql/client_manifest/types.rs index 348f0045b..d39b7d71c 100644 --- a/src/graphql/client_manifest/types.rs +++ b/src/graphql/client_manifest/types.rs @@ -836,16 +836,45 @@ pub struct CommandProjectionExtension { pub version: u32, pub event_set: Vec, pub program_arms: Vec, - /// Ordered, non-authoritative optimistic occurrences explicitly declared - /// by the command author. `event_set` and `program_arms` describe allowed - /// actual topology only and never imply an optimistic occurrence. + /// Ordered, non-authoritative optimistic occurrences derived by composing + /// command-known event values with the role-visible projection arms. /// - /// The client applies these in ordinal order as one overlay. The actual - /// ordered command delta reconciles and replaces that overlay. + /// The client applies these in ordinal order as one overlay. Eventual + /// projection deltas or atomic returned records reconcile that overlay. pub preview_occurrences: Vec, + /// Pure reducers over known cache rows (client auto-optimism). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pure_reduces: Vec, pub fallback: ClientProjectionFallback, } +/// Pure reduce declaration on the client manifest (server-exported). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientCommandPureReduce { + pub fn_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub client_module: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub client_export: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub wasm_package: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub wasm_export: String, + pub model: String, + pub key: Vec, + pub args: Vec, + pub assign: Vec, +} + +/// Pure reduce key/arg with preview-style source. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientCommandPureArg { + pub name: String, + pub source: ClientProjectionPreviewSource, +} + impl<'de> Deserialize<'de> for CommandProjectionExtension { fn deserialize(deserializer: D) -> Result where @@ -859,6 +888,8 @@ impl<'de> Deserialize<'de> for CommandProjectionExtension { event_set: Vec, program_arms: Vec, preview_occurrences: Vec, + #[serde(default)] + pure_reduces: Vec, fallback: ClientProjectionFallback, } @@ -919,6 +950,7 @@ impl<'de> Deserialize<'de> for CommandProjectionExtension { event_set: wire.event_set, program_arms: wire.program_arms, preview_occurrences: wire.preview_occurrences, + pure_reduces: wire.pure_reduces, fallback: wire.fallback, }) } diff --git a/src/graphql/command_contract/effect_wire.rs b/src/graphql/command_contract/effect_wire.rs index 4f244838b..361074824 100644 --- a/src/graphql/command_contract/effect_wire.rs +++ b/src/graphql/command_contract/effect_wire.rs @@ -298,7 +298,7 @@ where pub struct CompiledInputDefault(CommandInputDefault, PhantomData); /// Declaration-owned generated defaults for one exact command input type. -pub struct CompiledInputDefaults(pub(super) Vec, PhantomData); +pub struct CompiledInputDefaults(pub(crate) Vec, PhantomData); #[doc(hidden)] pub fn __input_default_uuid_v7() -> CompiledInputDefault diff --git a/src/graphql/command_contract/mod.rs b/src/graphql/command_contract/mod.rs index 5e28c303b..410c5dff1 100644 --- a/src/graphql/command_contract/mod.rs +++ b/src/graphql/command_contract/mod.rs @@ -47,6 +47,8 @@ pub use outcomes::{ Atomic, CommandConsistency, CommandOutcome, Eventual, PrepareCommandError, PreparedCommand, Succeeded, }; +#[cfg(test)] +pub(crate) use projection_obligations::InputDefaultGenerator; pub(crate) use projection_obligations::{ validate_projection_confirmation_count, CommandInputDefault, CommandProjectionConfirmation, }; @@ -54,8 +56,9 @@ pub(crate) use projections::CommandProjectionEvents; pub use projections::{ __command_projection_event_descriptor, __command_projection_event_preview, __command_projection_events, __command_projection_preview_constant, - __command_projection_state_preview, CommandProjectionEventSet, CommandProjectionPreview, - CommandProjectionPreviewSource, + __command_projection_state_known_values, __command_projection_state_preview, CommandEventSet, + CommandProjectionEventSet, CommandProjectionPreview, CommandProjectionPreviewSource, + CommandProjectionPureArg, CommandProjectionPureReduce, }; // Re-exported for unit tests that resolve obligations through this module path. #[cfg_attr(not(test), allow(unused_imports))] @@ -65,5 +68,5 @@ pub(crate) use projection_obligations::{ pub(crate) use projection_proof::{ validate_resolved_direct_plan, CommandCommitProofError, ProjectionCommitProof, }; -pub use typed_command::{typed_command, TypedCommand}; +pub use typed_command::{command_transition, typed_command, TypedCommand}; pub(crate) use typed_command::{TypedCommandContract, TypedServiceCommandBinding}; diff --git a/src/graphql/command_contract/projections.rs b/src/graphql/command_contract/projections.rs index d6075021e..654ae0056 100644 --- a/src/graphql/command_contract/projections.rs +++ b/src/graphql/command_contract/projections.rs @@ -154,11 +154,140 @@ pub(crate) struct CommandProjectionEventPreview { pub preview: CommandProjectionPreview, } +/// Pure reducer over a known cache row for client auto-optimism. +/// +/// Domain owns pure semantics. Client delivery is either: +/// - **WASM** ([`Self::wasm`]): gen-client emits a `createWasmJsonPure` host in +/// `pures.ts` — no app TypeScript pure file required. +/// - **Hand module** ([`Self::client_module`]): gen-client imports a named export +/// from `$lib/` (escape hatch). +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CommandProjectionPureReduce { + /// Stable pure id, e.g. `blob.simulate_move`. + pub fn_name: String, + /// Path under app `$lib` without extension for a hand-written pure (empty if WASM). + pub client_module: String, + /// Named export in that hand module (empty if WASM). + pub client_export: String, + /// wasm-pack package under `$lib` without extension, e.g. `blob/pkg/blob_wasm`. + pub wasm_package: String, + /// Named WASM export `(recordJson, argsJson) -> assignJson | undefined`. + pub wasm_export: String, + /// Projection model id (e.g. `BlobGames`). + pub model: String, + /// Record key fields: `name` is the model field; `source` is input/default/preset. + pub key: Vec, + /// Pure function arguments (resolved like preview values). + pub args: Vec, + /// Fields taken from the pure result and patched onto the known row. + pub assign: Vec, +} + +/// One named pure-reduce binding (key field or pure arg). +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CommandProjectionPureArg { + pub name: String, + pub source: CommandProjectionPreviewSource, +} + +impl CommandProjectionPureReduce { + /// Hand-written pure under `$lib/` exporting `client_export`. + pub fn client_module( + fn_name: impl Into, + client_module: impl Into, + client_export: impl Into, + model: impl Into, + ) -> Self { + Self { + fn_name: fn_name.into(), + client_module: client_module.into(), + client_export: client_export.into(), + wasm_package: String::new(), + wasm_export: String::new(), + model: model.into(), + key: Vec::new(), + args: Vec::new(), + assign: Vec::new(), + } + } + + /// Domain pure shipped as wasm-pack under `$lib/`; gen-client hosts it. + pub fn wasm( + fn_name: impl Into, + wasm_package: impl Into, + wasm_export: impl Into, + model: impl Into, + ) -> Self { + Self { + fn_name: fn_name.into(), + client_module: String::new(), + client_export: String::new(), + wasm_package: wasm_package.into(), + wasm_export: wasm_export.into(), + model: model.into(), + key: Vec::new(), + args: Vec::new(), + assign: Vec::new(), + } + } + + /// Deprecated alias for [`Self::client_module`]. + #[deprecated(note = "use client_module() or wasm()")] + pub fn new( + fn_name: impl Into, + client_module: impl Into, + client_export: impl Into, + model: impl Into, + ) -> Self { + Self::client_module(fn_name, client_module, client_export, model) + } + + #[must_use] + pub fn key_input( + mut self, + field: impl Into, + path: impl IntoIterator>, + ) -> Self { + self.key.push(CommandProjectionPureArg { + name: field.into(), + source: CommandProjectionPreviewSource::input(path), + }); + self + } + + #[must_use] + pub fn arg_input( + mut self, + name: impl Into, + path: impl IntoIterator>, + ) -> Self { + self.args.push(CommandProjectionPureArg { + name: name.into(), + source: CommandProjectionPreviewSource::input(path), + }); + self + } + + #[must_use] + pub fn assign(mut self, fields: impl IntoIterator>) -> Self { + self.assign.extend(fields.into_iter().map(Into::into)); + self.assign.sort(); + self.assign.dedup(); + self + } +} + /// Exact outward events a command may emit, independent of any projector. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] pub(crate) struct CommandProjectionEvents { pub selectors: Vec, pub previews: Vec, + /// Values proved by a generated domain transition. Projector arms decide + /// whether these event values have any client-visible consequence. + #[serde(skip)] + pub inferred_values: Vec, + /// Pure reducers over known cache rows (client auto-optimism). + pub pure_reduces: Vec, pub declaration_errors: Vec, } @@ -192,6 +321,54 @@ impl CommandProjectionEvents { })); } + pub(crate) fn add_inferred_values(&mut self, values: CommandProjectionPreview) { + self.declaration_errors + .extend(values.declaration_errors.clone()); + if values.selectors.len() != 1 { + self.declaration_errors.push( + "inferred transition values must bind exactly one emitted event variant".to_owned(), + ); + return; + } + let selector = values.selectors[0].clone(); + if let Some(existing) = self + .inferred_values + .iter_mut() + .find(|candidate| candidate.selector == selector) + { + existing.preview.fields.extend(values.fields); + existing + .preview + .declaration_errors + .extend(values.declaration_errors); + } else { + self.inferred_values.push(CommandProjectionEventPreview { + selector, + preview: values, + }); + } + } + + pub(crate) fn add_authenticated_user_field( + &mut self, + rust_field: &str, + values: CommandProjectionPreview, + ) { + if values.fields.len() != 1 { + self.declaration_errors.push( + format!( + "authenticated-user inference field `{rust_field}` is not one exact emitted-event body field" + ), + ); + return; + } + self.add_inferred_values(values); + } + + pub(crate) fn add_pure_reduce(&mut self, reduce: CommandProjectionPureReduce) { + self.pure_reduces.push(reduce); + } + pub(crate) fn canonicalize_and_validate(&mut self, command: &str) -> Result<(), String> { if let Some(error) = self.declaration_errors.first() { return Err(format!( @@ -221,56 +398,163 @@ impl CommandProjectionEvents { // the allowed event set. Preserve their declaration order so generated // clients can apply them deterministically until the authoritative // ordered command delta replaces the optimistic overlay. - for preview in &mut self.previews { - if self - .selectors - .binary_search_by(|selector| selector.canonical_cmp(&preview.selector)) - .is_err() + canonicalize_preview_declarations(command, &self.selectors, &mut self.previews, false)?; + canonicalize_preview_declarations( + command, + &self.selectors, + &mut self.inferred_values, + true, + )?; + for reduce in &mut self.pure_reduces { + if reduce.fn_name.trim().is_empty() || reduce.model.trim().is_empty() { + return Err(format!( + "typed command `{command}` pure reduce requires non-empty fn and model" + )); + } + let hand = + !reduce.client_module.trim().is_empty() || !reduce.client_export.trim().is_empty(); + let wasm = + !reduce.wasm_package.trim().is_empty() || !reduce.wasm_export.trim().is_empty(); + if hand == wasm { + return Err(format!( + "typed command `{command}` pure reduce `{}` must declare either client_module+client_export or wasm_package+wasm_export (not both, not neither)", + reduce.fn_name + )); + } + if hand + && (reduce.client_module.trim().is_empty() + || reduce.client_export.trim().is_empty()) { return Err(format!( - "typed command `{command}` declares preview provenance outside its exact emitted event set" + "typed command `{command}` pure reduce `{}` client module requires non-empty client_module and client_export", + reduce.fn_name )); } - preview.preview.fields.sort_by_key(preview_field_key); - for pair in preview.preview.fields.windows(2) { - if preview_field_key(&pair[0]) == preview_field_key(&pair[1]) { - return Err(format!( - "typed command `{command}` repeats preview provenance for one event value" - )); - } + if wasm + && (reduce.wasm_package.trim().is_empty() || reduce.wasm_export.trim().is_empty()) + { + return Err(format!( + "typed command `{command}` pure reduce `{}` wasm package requires non-empty wasm_package and wasm_export", + reduce.fn_name + )); } - for field in &preview.preview.fields { - if field.envelope.is_none() { - validate_path(command, "emitted body", &field.body_path)?; - } - match &field.source { + if reduce.key.is_empty() { + return Err(format!( + "typed command `{command}` pure reduce `{}` requires at least one key field", + reduce.fn_name + )); + } + if reduce.assign.is_empty() { + return Err(format!( + "typed command `{command}` pure reduce `{}` requires at least one assign field", + reduce.fn_name + )); + } + reduce.key.sort_by(|a, b| a.name.cmp(&b.name)); + reduce.args.sort_by(|a, b| a.name.cmp(&b.name)); + reduce.assign.sort(); + reduce.assign.dedup(); + for arg in reduce.key.iter().chain(reduce.args.iter()) { + match &arg.source { CommandProjectionPreviewSource::InputPath { path } | CommandProjectionPreviewSource::GeneratedDefaultPath { path } => { - validate_path(command, "preview input", path)?; + validate_path(command, "pure reduce", path)?; } CommandProjectionPreviewSource::TrustedPreset { name, codec } => { if name.trim().is_empty() || codec.trim().is_empty() { return Err(format!( - "typed command `{command}` preview trusted preset name and codec must not be empty" + "typed command `{command}` pure reduce trusted preset name and codec must not be empty" )); } } - CommandProjectionPreviewSource::ServerOnly => { + other => { return Err(format!( - "typed command `{command}` cannot expose server-only preview provenance" + "typed command `{command}` pure reduce `{}` arg `{}` uses unsupported source {other:?}", + reduce.fn_name, arg.name )); } - CommandProjectionPreviewSource::Constant { .. } - | CommandProjectionPreviewSource::Null - | CommandProjectionPreviewSource::Absent - | CommandProjectionPreviewSource::Unknown => {} } } } + self.pure_reduces + .sort_by(|left, right| left.fn_name.cmp(&right.fn_name)); + if self + .pure_reduces + .windows(2) + .any(|pair| pair[0].fn_name == pair[1].fn_name) + { + return Err(format!( + "typed command `{command}` repeats pure reduce fn name" + )); + } Ok(()) } } +fn canonicalize_preview_declarations( + command: &str, + selectors: &[ProjectionEventSelector], + previews: &mut [CommandProjectionEventPreview], + inferred: bool, +) -> Result<(), String> { + for preview in previews { + if selectors + .binary_search_by(|selector| selector.canonical_cmp(&preview.selector)) + .is_err() + { + let source = if inferred { + "infers transition values" + } else { + "declares preview provenance" + }; + return Err(format!( + "typed command `{command}` {source} outside its exact emitted event set" + )); + } + preview.preview.fields.sort_by_key(preview_field_key); + for pair in preview.preview.fields.windows(2) { + if preview_field_key(&pair[0]) == preview_field_key(&pair[1]) { + let source = if inferred { + "inferred transition value" + } else { + "preview provenance" + }; + return Err(format!( + "typed command `{command}` repeats {source} for one event value" + )); + } + } + for field in &preview.preview.fields { + if field.envelope.is_none() { + validate_path(command, "emitted body", &field.body_path)?; + } + match &field.source { + CommandProjectionPreviewSource::InputPath { path } + | CommandProjectionPreviewSource::GeneratedDefaultPath { path } => { + validate_path(command, "preview input", path)?; + } + CommandProjectionPreviewSource::TrustedPreset { name, codec } => { + if name.trim().is_empty() || codec.trim().is_empty() { + return Err(format!( + "typed command `{command}` preview trusted preset name and codec must not be empty" + )); + } + } + CommandProjectionPreviewSource::ServerOnly => { + return Err(format!( + "typed command `{command}` cannot expose server-only preview provenance" + )); + } + CommandProjectionPreviewSource::Constant { .. } + | CommandProjectionPreviewSource::Null + | CommandProjectionPreviewSource::Absent + | CommandProjectionPreviewSource::Unknown => {} + } + } + } + Ok(()) +} + /// Sealed exact event-set value produced by [`events!`](crate::events). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CommandProjectionEventSet { @@ -346,6 +630,53 @@ where structured_preview::(__command_projection_events([descriptor]), fields) } +/// Build compiler-inferred state values from a sourced transition. +/// +/// Unlike the application-authored preview helper, aggregate fields absent +/// from the outward `DomainState` are ignored. This lets the sourced macro +/// inspect recorder assignments without coupling private aggregate storage to +/// the public event schema. +#[doc(hidden)] +pub fn __command_projection_state_known_values( + fields: Vec<(&'static str, CommandProjectionPreviewSource)>, +) -> CommandProjectionPreview +where + E: DomainEventBodyContract, + S: DomainState + ProjectionBodyMetadata, +{ + let descriptor = __command_projection_event_descriptor::().and_then(|descriptor| { + let expected = DomainEventDescriptor::state::(E::EVENT_NAME, E::EVENT_VERSION); + if descriptor != expected || descriptor.body.kind != DomainEventBodyKind::State { + return Err(format!( + "inferred transition event contract `{}` does not exactly describe `{}` state", + E::EVENT_NAME, + std::any::type_name::() + )); + } + Ok(descriptor) + }); + let mut values = + CommandProjectionPreview::new().events(__command_projection_events([descriptor])); + for (rust_name, source) in fields { + let Some(field) = S::PROJECTION_FIELDS + .iter() + .find(|field| field.rust_name == rust_name && field.present) + else { + continue; + }; + values.fields.push(CommandProjectionPreviewField { + body_path: vec![field.wire_name.to_owned()], + envelope: None, + body_type: Some(field.portable_type), + body_rust_type: Some(field.rust_type), + body_nullable: Some(field.nullable), + body_always_present: Some(field.always_present), + source, + }); + } + values +} + /// Build a structured sparse-event preview from generated body metadata. #[doc(hidden)] pub fn __command_projection_event_preview( @@ -408,6 +739,57 @@ pub fn __command_projection_preview_constant( } } +/// Type-level source of a command's outward domain-event set. +/// +/// Implemented for: +/// - every [`DomainEventContract`] marker (for example `TodoCreatedDomainEvent`) +/// - tuples of those markers +/// - `#[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 +/// hand-maintaining a parallel event list when the domain already owns the +/// transition. +pub trait CommandEventSet { + /// Build the sealed event-set value used by command registration. + fn command_event_set() -> CommandProjectionEventSet; + + /// Return values proved by a generated domain transition. + /// + /// Ordinary event markers have none. `#[sourced]` transition witnesses + /// override this with portable unconditional recorder values; projection + /// arms remain the sole definition of their cache consequences. + fn command_event_known_values() -> Vec { + Vec::new() + } +} + +impl CommandEventSet for E { + fn command_event_set() -> CommandProjectionEventSet { + __command_projection_events([__command_projection_event_descriptor::()]) + } +} + +macro_rules! impl_command_event_set_tuple { + ($($E:ident),+) => { + impl<$($E: DomainEventContract),+> CommandEventSet for ($($E,)+) { + fn command_event_set() -> CommandProjectionEventSet { + __command_projection_events([ + $(__command_projection_event_descriptor::<$E>()),+ + ]) + } + } + }; +} + +impl_command_event_set_tuple!(E1, E2); +impl_command_event_set_tuple!(E1, E2, E3); +impl_command_event_set_tuple!(E1, E2, E3, E4); +impl_command_event_set_tuple!(E1, E2, E3, E4, E5); +impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6); +impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6, E7); +impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6, E7, E8); + /// Declare an exact, type-checked outward domain-event set. #[macro_export] macro_rules! events { diff --git a/src/graphql/command_contract/tests.rs b/src/graphql/command_contract/tests.rs index 055ba9967..4894268ae 100644 --- a/src/graphql/command_contract/tests.rs +++ b/src/graphql/command_contract/tests.rs @@ -84,6 +84,19 @@ struct TodoState { status: String, } +enum TodoStateChanged {} + +impl crate::domain_event::DomainEventContract for TodoStateChanged { + const EVENT_NAME: &'static str = "todo.state-changed"; + const EVENT_VERSION: u64 = 1; + + fn descriptor() -> crate::DomainEventDescriptor { + crate::DomainEventDescriptor::state::(Self::EVENT_NAME, Self::EVENT_VERSION) + } +} + +impl crate::domain_event::DomainEventBodyContract for TodoStateChanged {} + enum DishonestEventContract {} impl crate::domain_event::DomainEventContract for DishonestEventContract { @@ -203,6 +216,83 @@ fn command_events_are_exact_values_independent_of_projector_declarations() { ); } +#[test] +fn command_transition_fills_emits_from_domain_event_set() { + let from_transition = + super::command_transition::>("todo.complete") + .into_contract(); + let from_emits = typed_command::>("todo.complete") + .emits(crate::events![TodoCompleted]) + .into_contract(); + assert_eq!( + from_transition.projections.selectors, + from_emits.projections.selectors + ); + assert_eq!( + from_transition.projections.selectors[0].event_name(), + "todo.completed" + ); +} + +#[test] +fn authenticated_user_field_reuses_generated_event_schema_metadata() { + let contract = super::command_transition::>( + "todo.complete", + ) + .authenticated_user_field::("status") + .into_contract(); + let field = contract.projections.inferred_values[0] + .preview + .fields + .iter() + .find(|field| field.body_path == ["status"]) + .expect("generated transition field must remain present"); + + assert_eq!( + field.source, + CommandProjectionPreviewSource::trusted("x-user-id", "string") + ); + assert_eq!( + field.body_type, + Some(crate::projection::lower::ProjectionPortableType::String) + ); + assert_eq!(field.body_nullable, Some(false)); + assert_eq!(field.body_always_present, Some(true)); +} + +#[test] +fn authenticated_user_field_merges_with_known_values_and_rejects_unknown_fields() { + let mut projections = CommandProjectionEvents::default(); + projections.add_event_set(crate::events![TodoStateChanged]); + projections.add_inferred_values(__command_projection_state_known_values::< + TodoStateChanged, + TodoState, + >(vec![( + "todo_id", + CommandProjectionPreviewSource::constant(crate::ProjectionValue::string("todo-1")), + )])); + projections.add_authenticated_user_field( + "status", + __command_projection_state_known_values::(vec![( + "status", + CommandProjectionPreviewSource::trusted("x-user-id", "string"), + )]), + ); + projections + .canonicalize_and_validate("todo.complete") + .expect("distinct inferred fields for one event must merge"); + assert_eq!(projections.inferred_values.len(), 1); + assert_eq!(projections.inferred_values[0].preview.fields.len(), 2); + + let invalid = super::command_transition::>( + "todo.invalid-principal-field", + ) + .authenticated_user_field::("missing") + .into_contract(); + let error = TypedServiceCommandBinding::from_contracts("todos", &[invalid]).unwrap_err(); + assert!(error.contains("is not one exact emitted-event body field")); +} + #[test] fn command_event_registration_rejects_duplicates_and_conflicting_schemas() { let duplicate = typed_command::>("todo.duplicate") @@ -751,8 +841,8 @@ fn command_fingerprints_canonicalize_nested_json_object_keys() { reverse_binding.structural_fingerprint.as_str(), ), ( - "sha256:db980e553f62b65ee01600c23c0331e97b5083f5e9f3b65498cb4cde344a0204", - "sha256:ba291c9b13aafb68a135b5b8112e3e2ebf1fa1e23819eb210219c60b186c265f", + "sha256:316da00fdf3cfb8ebc2ffdc2b01ef2c39c2a424c06384cc3206984d37f050799", + "sha256:f12034f9fbb4390014593a372303ad4389397916ee66fca6288f8ccba3be9dce", ) ); } diff --git a/src/graphql/command_contract/typed_command.rs b/src/graphql/command_contract/typed_command.rs index 25dd0bfae..3f2ef88f1 100644 --- a/src/graphql/command_contract/typed_command.rs +++ b/src/graphql/command_contract/typed_command.rs @@ -20,7 +20,10 @@ use super::projection_obligations::{ CommandInputDefault, CommandProjectionConfirmation, ProjectionObligationResolutionError, }; use super::projection_proof::{canonical_json, CommandCommitProofError}; -use super::projections::{CommandProjectionEvents, CommandProjectionPreview}; +use super::projections::{ + CommandProjectionEvents, CommandProjectionPreview, CommandProjectionPreviewSource, + CommandProjectionPureReduce, +}; use crate::graphql::naming; use crate::graphql::types::{GraphqlInputType, GraphqlTypeDef}; use crate::microsvc::Session; @@ -600,6 +603,25 @@ where } } +/// Begin a typed command whose outward emit set is owned by a domain transition. +/// +/// `S` is typically a `#[sourced]` `domain_commands::*` witness (for example +/// `domain_commands::Create`) or a domain-event marker. The emit set is filled +/// automatically — callers do not also call [`.emits`](TypedCommand::emits) / +/// [`.emits_events`](TypedCommand::emits_events) unless they intentionally +/// extend the set. +/// +/// Prefer this over [`typed_command`] plus a hand-written event list when the +/// domain already defines the transition. +pub fn command_transition(name: &'static str) -> TypedCommand +where + S: super::CommandEventSet, + I: GraphqlInputType + DeserializeOwned + Send + 'static, + K: CommandOutcome, +{ + typed_command::(name).emits_events::() +} + impl TypedCommand { pub fn field_name(mut self, field_name: impl Into) -> Self { self.contract.field_name = field_name.into(); @@ -628,22 +650,43 @@ impl TypedCommand { /// /// This declaration is intentionally independent of projector ownership: /// one occurrence can fan out to zero, one, or many modeled programs. + /// + /// Prefer [`Self::emits_events`] when the set is a domain event marker or a + /// `#[sourced]` `domain_commands::*` transition witness. #[must_use] pub fn emits(mut self, events: super::CommandProjectionEventSet) -> Self { self.contract.projections.add_event_set(events); self } - /// Declare known mutation-input fields for client cache application. + /// Declare outward domain events from a type-level [`super::CommandEventSet`]. + /// + /// Equivalent to [`.emits`](Self::emits)`(events![...])` for the same + /// domain-event contracts. A generated `domain_commands` witness also + /// contributes recorder values the compiler can prove; role-visible + /// projection arms decide their optimistic cache consequences. + #[must_use] + pub fn emits_events(mut self) -> Self { + self.contract + .projections + .add_event_set(S::command_event_set()); + for values in S::command_event_known_values() { + self.contract.projections.add_inferred_values(values); + } + self + } + + /// Override known mutation-input fields for client cache application. /// /// Maps command-known values (and unknowns) onto the emitted domain-event /// body shape that projections bind into mutation IR. The client /// applies that mutation to the cache only; the server applies the same /// mutation for real (eventual projector or handler-owned projected row). /// - /// Prefer this over thinking of the declaration as "predicting an event - /// body" — the domain model defines events; this is known input for the - /// event→mutation binding used by optimism. + /// Generated domain transitions normally supply these values automatically. + /// This lower-level escape hatch exists for event producers the compiler + /// cannot inspect; it is not required for ordinary `command_transition` + /// registration. /// /// Service registration rejects a mapping whose exact event selector is /// not also present through [`Self::emits`]. @@ -653,6 +696,40 @@ impl TypedCommand { self } + /// Declare that one emitted state field is the authenticated user id. + /// + /// This supplies the missing trusted `x-user-id` provenance to automatic + /// event→mutation optimism when the projected model is not owner-filtered + /// (for example, a public-readable chat lobby). It does not authorize the + /// command or rewrite handler input: the mount must still require a user + /// and the handler must bind the same principal from its session. + #[must_use] + pub fn authenticated_user_field(mut self, rust_field: &'static str) -> Self + where + E: crate::domain_event::DomainEventBodyContract, + S: crate::DomainState + crate::projection::lower::ProjectionBodyMetadata, + { + let values = super::__command_projection_state_known_values::(vec![( + rust_field, + CommandProjectionPreviewSource::trusted("x-user-id", "string"), + )]); + self.contract + .projections + .add_authenticated_user_field(rust_field, values); + self + } + + /// Declare a pure reducer over a known cache row for client auto-optimism. + /// + /// The pure function is domain-owned (e.g. `blob_domain::simulate_move`); + /// `client_module` / `client_export` name the TypeScript twin shipped with + /// the generated client and registered as `pureFunctions[fn_name]`. + #[must_use] + pub fn preview_reduce_known_record(mut self, reduce: CommandProjectionPureReduce) -> Self { + self.contract.projections.add_pure_reduce(reduce); + self + } + pub fn name(&self) -> &str { &self.contract.name } diff --git a/src/graphql/engine/builder.rs b/src/graphql/engine/builder.rs index bb1376929..95cc81970 100644 --- a/src/graphql/engine/builder.rs +++ b/src/graphql/engine/builder.rs @@ -235,8 +235,8 @@ impl GraphqlEngineBuilder { } /// Set the stable service identity used by generated client manifests. /// - /// [`GraphqlEngine::from_manifest`] supplies this automatically from the - /// project manifest. This setter is intended for manually assembled + /// [`GraphqlEngine::from_schema_catalog`] supplies this automatically from the + /// read-model schema catalog. This setter is intended for manually assembled /// engines, which otherwise cannot export a client manifest safely. pub fn service_id(mut self, service_id: impl Into) -> Self { let service_id = service_id.into(); @@ -305,21 +305,17 @@ impl GraphqlEngineBuilder { } /// Register one exact named application surface for generated clients. - /// - /// `roles` is both the **eligible** principal set (who may open the - /// contract) and the **schema privilege** set (grant intersection for the - /// portable client schema). Prefer - /// [`Self::client_application_surface_with_schema_roles`] when elevated - /// principals must open a lower-privilege portable contract. + /// Both role sets are required: eligible principals may open the surface, + /// while schema roles determine its portable privilege intersection. /// /// The server still authorizes every request as its verified concrete role. pub fn client_application_surface( self, application: impl Into, - roles: impl IntoIterator>, + eligible_roles: impl IntoIterator>, + schema_roles: impl IntoIterator>, ) -> Self { - let roles = roles.into_iter().map(Into::into).collect::>(); - self.client_application_surface_with_schema_roles(application, roles.clone(), roles) + self.client_application_surface_with_schema_roles(application, eligible_roles, schema_roles) } /// Register an application surface with distinct eligible and schema roles. @@ -345,10 +341,18 @@ impl GraphqlEngineBuilder { .into_iter() .map(Into::into) .collect::>(); + let eligible_roles_were_unique = { + let mut sorted = eligible_roles.clone(); + sorted.sort(); + sorted.windows(2).all(|roles| roles[0] != roles[1]) + }; + let schema_roles_were_unique = { + let mut sorted = schema_roles.clone(); + sorted.sort(); + sorted.windows(2).all(|roles| roles[0] != roles[1]) + }; eligible_roles.sort(); - eligible_roles.dedup(); schema_roles.sort(); - schema_roles.dedup(); if application.is_empty() || application.len() > 128 || application.trim() != application @@ -372,12 +376,24 @@ impl GraphqlEngineBuilder { )); return self; } + if !eligible_roles_were_unique { + self.pending_errors.push(format!( + "GraphQL client application `{application}` eligible roles must be unique" + )); + return self; + } if schema_roles.is_empty() || schema_roles.iter().any(invalid_role) { self.pending_errors.push(format!( "GraphQL client application `{application}` must declare one or more bounded non-empty schema roles" )); return self; } + if !schema_roles_were_unique { + self.pending_errors.push(format!( + "GraphQL client application `{application}` schema roles must be unique" + )); + return self; + } if schema_roles .iter() .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) @@ -536,7 +552,7 @@ impl GraphqlEngineBuilder { } if self.protocol_token_key.is_some() && self.service_id.is_none() { return Err(GraphqlBuildError( - "GraphQL protocol tokens require a stable service ID; construct the engine with GraphqlEngine::from_manifest or GraphqlEngineBuilder::service_id" + "GraphQL protocol tokens require a stable service ID; construct the engine with GraphqlEngine::from_schema_catalog or GraphqlEngineBuilder::service_id" .into(), )); } @@ -902,7 +918,7 @@ impl GraphqlEngineBuilder { protocol_applications.insert( application.clone(), ProtocolApplicationInfo { - roles: registration.eligible_roles.clone(), + eligible_roles: registration.eligible_roles.clone(), schema_roles: registration.schema_roles.clone(), privilege_key, surface: ProtocolSurfaceInfo { diff --git a/src/graphql/engine/core.rs b/src/graphql/engine/core.rs index e11b71505..0ea7e0510 100644 --- a/src/graphql/engine/core.rs +++ b/src/graphql/engine/core.rs @@ -185,7 +185,7 @@ pub(crate) struct ProtocolRoleInfo { #[derive(Clone)] pub(crate) struct ProtocolApplicationInfo { /// Eligible wire roles (who may open). - pub(crate) roles: Vec, + pub(crate) eligible_roles: Vec, /// Privilege packs for portable schema + server execution grants. #[allow(dead_code)] // inspected by tests / future multi-privilege diagnostics pub(crate) schema_roles: Vec, diff --git a/src/graphql/engine/mod.rs b/src/graphql/engine/mod.rs index 84e6cd8bf..0c6dbb370 100644 --- a/src/graphql/engine/mod.rs +++ b/src/graphql/engine/mod.rs @@ -12,7 +12,7 @@ use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::manifest::DistributedProjectManifest; +use crate::table::ReadModelCatalog; use crate::microsvc::{Service, Session, ROLE_KEY, USER_ID_KEY}; use crate::read_model::{ReadModelChange, RelationalReadModelIncludes}; use crate::table::{ diff --git a/src/graphql/engine/protocol.rs b/src/graphql/engine/protocol.rs index a009e6855..180577dee 100644 --- a/src/graphql/engine/protocol.rs +++ b/src/graphql/engine/protocol.rs @@ -119,12 +119,17 @@ pub(crate) fn resolve_execution_authority( surface: ClientSurfaceIdentity::role(name), }) } - ClientSurfaceIdentity::Application { name, roles } => { + ClientSurfaceIdentity::Application { + name, + eligible_roles, + schema_roles, + } => { let runtime = inner.protocol.as_ref().ok_or(())?; let application = runtime.applications.get(&name).ok_or(())?; // Wire roles must equal the registered eligible set (canonical). - if roles != application.roles - || !principal_may_open_application(&asserted, &application.roles) + if eligible_roles != application.eligible_roles + || schema_roles != application.schema_roles + || !principal_may_open_application(&asserted, &application.eligible_roles) || requested.schema_hash != application.surface.schema_fingerprint { return Err(()); @@ -132,7 +137,11 @@ pub(crate) fn resolve_execution_authority( Ok(ExecutionAuthority { privilege_role: application.privilege_key.clone(), asserted_roles: asserted, - surface: ClientSurfaceIdentity::application(name, roles), + surface: ClientSurfaceIdentity::application_with_schema_roles( + name, + eligible_roles, + schema_roles, + ), }) } } @@ -161,13 +170,23 @@ pub(crate) fn select_protocol_surface<'a>( info.claim_keys.as_slice(), )) } - ClientSurfaceIdentity::Application { name, roles } => { + ClientSurfaceIdentity::Application { + name, + eligible_roles, + schema_roles, + } => { let application = runtime.applications.get(name).ok_or(())?; - if roles != &application.roles { + if eligible_roles != &application.eligible_roles + || schema_roles != &application.schema_roles + { return Err(()); } Ok(( - ClientSurfaceIdentity::application(name.clone(), roles.clone()), + ClientSurfaceIdentity::application_with_schema_roles( + name.clone(), + eligible_roles.clone(), + schema_roles.clone(), + ), &application.surface, application.authorization_fingerprint.as_str(), application.claim_keys.as_slice(), diff --git a/src/graphql/engine/public_api.rs b/src/graphql/engine/public_api.rs index e78164c8d..05e5889c0 100644 --- a/src/graphql/engine/public_api.rs +++ b/src/graphql/engine/public_api.rs @@ -5,8 +5,8 @@ impl GraphqlEngine { GraphqlEngineBuilder::new(pool.into()) } - pub fn from_manifest( - m: &DistributedProjectManifest, + pub fn from_schema_catalog( + m: &ReadModelCatalog, pool: impl Into, ) -> Result { let mut builder = Self::builder(pool).service_id(m.name.clone()); @@ -49,7 +49,7 @@ impl GraphqlEngine { self.inner.role_surfaces.get(role).cloned() } - /// Stable service identity retained from [`DistributedProjectManifest::name`]. + /// Stable service identity retained from [`ReadModelCatalog::name`]. /// /// Engines built manually return `None` unless the builder opted in with /// [`GraphqlEngineBuilder::service_id`]. @@ -107,7 +107,8 @@ impl GraphqlEngine { pub fn client_surface_for_application( &self, application: &str, - roles: &[&str], + eligible_roles: &[&str], + schema_roles: &[&str], ) -> Result { let service_id = self.client_export_service_id()?; let surface = self @@ -120,14 +121,19 @@ impl GraphqlEngine { "application surface `{application}` is not registered" )) })?; - let mut requested_roles = roles + let mut requested_eligible_roles = eligible_roles .iter() .map(|role| (*role).to_string()) .collect::>(); - requested_roles.sort(); - requested_roles.dedup(); + let mut requested_schema_roles = schema_roles + .iter() + .map(|role| (*role).to_string()) + .collect::>(); + requested_eligible_roles.sort(); + requested_schema_roles.sort(); let SurfaceSelection::Application { - roles: registered_roles, + eligible_roles: registered_roles, + schema_roles: registered_schema_roles, .. } = &surface.selection else { @@ -135,11 +141,15 @@ impl GraphqlEngine { "registered application surface `{application}` has invalid identity" ))); }; - if requested_roles != *registered_roles { + if requested_eligible_roles != *registered_roles + || requested_schema_roles != *registered_schema_roles + { return Err(ClientManifestError(format!( - "application surface `{application}` is registered for roles [{}], not [{}]", + "application surface `{application}` is registered for eligible roles [{}] and schema roles [{}], not eligible [{}] and schema [{}]", registered_roles.join(", "), - requested_roles.join(", ") + registered_schema_roles.join(", "), + requested_eligible_roles.join(", "), + requested_schema_roles.join(", ") ))); } DistributedClientSurfaceExport::from_selected_with_execution( @@ -157,16 +167,17 @@ impl GraphqlEngine { pub fn client_manifest_for_application( &self, application: &str, - roles: &[&str], + eligible_roles: &[&str], + schema_roles: &[&str], ) -> Result { - self.client_surface_for_application(application, roles)? + self.client_surface_for_application(application, eligible_roles, schema_roles)? .manifest() } fn client_export_service_id(&self) -> Result { self.inner.service_id.clone().ok_or_else(|| { ClientManifestError( - "client export requires a service ID; construct the engine with GraphqlEngine::from_manifest or GraphqlEngineBuilder::service_id" + "client export requires a service ID; construct the engine with GraphqlEngine::from_schema_catalog or GraphqlEngineBuilder::service_id" .into(), ) }) diff --git a/src/graphql/engine/tests.rs b/src/graphql/engine/tests.rs index 31a074273..43fe61046 100644 --- a/src/graphql/engine/tests.rs +++ b/src/graphql/engine/tests.rs @@ -130,8 +130,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -146,8 +146,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user"); @@ -206,8 +206,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -231,12 +231,12 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") - .client_application_surface("console", ["user"]) + .client_application_surface("console", ["user"], ["user"]) .protocol_token_key([7; 32]) .graphiql(graphiql) .build() @@ -245,10 +245,10 @@ mod client_surface_parity_tests { let without_graphiql = build(false); let with_graphiql = build(true); let generated = without_graphiql - .client_manifest_for_application("console", &["user"]) + .client_manifest_for_application("console", &["user"], &["user"]) .unwrap(); let runtime = with_graphiql - .client_manifest_for_application("console", &["user"]) + .client_manifest_for_application("console", &["user"], &["user"]) .unwrap(); assert_eq!(generated, runtime); @@ -560,8 +560,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["admin", "user"]) .grant_all("admin") @@ -585,18 +585,18 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["anonymous", "user"]) .grant_all("anonymous") .grant_all("user") - .client_application_surface("public", ["anonymous"]) + .client_application_surface("public", ["anonymous"], ["anonymous"]) .protocol_token_key([7; 32]) .build() .unwrap(); let manifest = engine - .client_manifest_for_application("public", &["anonymous"]) + .client_manifest_for_application("public", &["anonymous"], &["anonymous"]) .unwrap(); let request: Request = serde_json::from_value(serde_json::json!({ "query": "{ __typename }", @@ -606,7 +606,8 @@ mod client_surface_parity_tests { "surface": { "kind": "application", "name": "public", - "roles": ["anonymous"] + "eligible_roles": ["anonymous"], + "schema_roles": ["anonymous"] }, "schemaHash": manifest.schema_fingerprint } @@ -635,8 +636,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["admin", "user"]) .grant_all("admin") @@ -656,11 +657,15 @@ mod client_surface_parity_tests { .unwrap(); let manifest = engine - .client_manifest_for_application("console", &["admin", "user"]) + .client_manifest_for_application("console", &["admin", "user"], &["user"]) .expect("registered multi-role application manifest"); assert_eq!( manifest.surface, - crate::graphql::ClientSurfaceIdentity::application("console", ["admin", "user"]) + crate::graphql::ClientSurfaceIdentity::application_with_schema_roles( + "console", + ["admin", "user"], + ["user"], + ) ); // Wire roles are eligible; schema privilege is user-only so x-user-id // remains a trusted preset (portable owner-style policy). @@ -695,7 +700,8 @@ mod client_surface_parity_tests { "surface": { "kind": "application", "name": "console", - "roles": ["admin", "user"] + "eligible_roles": ["admin", "user"], + "schema_roles": ["user"] }, "schemaHash": schema_hash } @@ -743,18 +749,18 @@ mod client_surface_parity_tests { let pool2 = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project2 = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let user_only = GraphqlEngine::from_manifest(&project2, pool2) + let project2 = ReadModelCatalog::new("orders-service").table_schema(orders()); + let user_only = GraphqlEngine::from_schema_catalog(&project2, pool2) .unwrap() .roles(&["admin", "user"]) .grant_all("admin") .grant_all("user") - .client_application_surface("console", ["user"]) + .client_application_surface("console", ["user"], ["user"]) .protocol_token_key([7; 32]) .build() .unwrap(); let user_only_manifest = user_only - .client_manifest_for_application("console", &["user"]) + .client_manifest_for_application("console", &["user"], &["user"]) .unwrap(); let user_only_request = || -> Request { serde_json::from_value(serde_json::json!({ @@ -765,7 +771,8 @@ mod client_surface_parity_tests { "surface": { "kind": "application", "name": "console", - "roles": ["user"] + "eligible_roles": ["user"], + "schema_roles": ["user"] }, "schemaHash": user_only_manifest.schema_fingerprint } @@ -792,21 +799,21 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["admin", "user"]) .grant_all("admin") .grant_all("user") - .client_application_surface("console", ["admin", "user"]) + .client_application_surface("console", ["admin", "user"], ["admin", "user"]) .protocol_token_key([7; 32]) .build() .unwrap(); let manifest = engine - .client_manifest_for_application("console", &["user", "admin"]) + .client_manifest_for_application("console", &["user", "admin"], &["user", "admin"]) .expect("registered application manifest"); assert!(engine - .client_manifest_for_application("console", &["user"]) + .client_manifest_for_application("console", &["user"], &["user"]) .is_err()); let request = |schema_hash: &str, roles: serde_json::Value| -> Request { @@ -818,7 +825,8 @@ mod client_surface_parity_tests { "surface": { "kind": "application", "name": "console", - "roles": roles + "eligible_roles": roles, + "schema_roles": roles }, "schemaHash": schema_hash } @@ -1062,8 +1070,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let raw = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let raw = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -1105,7 +1113,7 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); let commands = TypedCommandInventory::from_contracts(&[test_command::< ChangeOrderInput, ChangeOrderPayload, @@ -1115,7 +1123,7 @@ mod client_surface_parity_tests { &["user"], )]) .unwrap(); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -1261,8 +1269,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["empty"]) .build() @@ -1289,8 +1297,8 @@ mod client_surface_parity_tests { let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://postgres:postgres@localhost/distributed_test") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -1451,8 +1459,8 @@ mod client_surface_parity_tests { } } - fn matrix_project() -> DistributedProjectManifest { - DistributedProjectManifest::new("acceptance-service") + fn matrix_project() -> ReadModelCatalog { + ReadModelCatalog::new("acceptance-service") .table_schema(orders()) .table_schema(customers()) } @@ -1505,7 +1513,7 @@ mod client_surface_parity_tests { fn matrix_engine(pool: GraphqlPool) -> GraphqlEngine { let project = matrix_project(); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted", "admin"]) .default_limit(11) @@ -1567,7 +1575,7 @@ mod client_surface_parity_tests { other => panic!("unexpected matrix role `{other}`"), }; let selected = surface_for_role(&full, role, &grants).unwrap(); - DistributedClientSurfaceExport::from_project(&project, selected) + DistributedClientSurfaceExport::from_selected(project.name.clone(), selected) .unwrap() .manifest() .unwrap() @@ -1803,7 +1811,7 @@ mod client_surface_parity_tests { assert_eq!(response.errors.len(), 1, "{response:?}"); assert_eq!( response.errors[0].message, - "command dispatcher not configured (use graphql_router_with_service)" + "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)" ); } @@ -1953,8 +1961,8 @@ mod client_surface_parity_tests { .await .unwrap(); let project = - DistributedProjectManifest::new("composite-service").table_schema(composite_records()); - let engine = GraphqlEngine::from_manifest(&project, pool.clone()) + ReadModelCatalog::new("composite-service").table_schema(composite_records()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool.clone()) .unwrap() .roles(&["admin"]) .grant_all("admin") @@ -2169,10 +2177,10 @@ mod client_surface_parity_tests { .await .unwrap(); - let project = DistributedProjectManifest::new("relationship-policy-service") + let project = ReadModelCatalog::new("relationship-policy-service") .table_schema(policy_parents()) .table_schema(policy_children()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted"]); insert_permission( @@ -2256,10 +2264,10 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("composite-service") + let project = ReadModelCatalog::new("composite-service") .table_schema(composite) .table_schema(simple); - let error = GraphqlEngine::from_manifest(&project, pool) + let error = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["admin"]) .grant_all("admin") @@ -2303,8 +2311,8 @@ mod client_surface_parity_tests { .connect_lazy("sqlite::memory:") .unwrap(); let project = - DistributedProjectManifest::new("metrics-service").table_schema(metrics()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + ReadModelCatalog::new("metrics-service").table_schema(metrics()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted"]); insert_permission( @@ -2324,8 +2332,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("metrics-service").table_schema(metrics()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("metrics-service").table_schema(metrics()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted"]); insert_permission( @@ -2379,8 +2387,8 @@ mod client_surface_parity_tests { .connect_lazy("sqlite::memory:") .unwrap(); let project = - DistributedProjectManifest::new("metrics-service").table_schema(metrics()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + ReadModelCatalog::new("metrics-service").table_schema(metrics()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted"]); insert_permission( diff --git a/src/graphql/http.rs b/src/graphql/http.rs index b8717aa4d..1b22be812 100644 --- a/src/graphql/http.rs +++ b/src/graphql/http.rs @@ -205,6 +205,10 @@ pub fn graphql_router(engine: Arc) -> Router { } /// GraphQL router that can dispatch command mutations through a [`Service`]. +/// +/// Prefer [`graphql_router_with_dispatcher`] for new hosts: local command +/// mounts are still Service-backed, but the public host API is the dispatcher +/// boundary rather than attaching `Service` directly. pub fn graphql_router_with_service(engine: Arc, service: Arc) -> Router { service .validate_graphql_engine(&engine) @@ -229,6 +233,19 @@ pub fn graphql_router_with_service(engine: Arc, service: Arc, + dispatcher: Arc, +) -> Router { + graphql_router_with_service(engine, Arc::clone(dispatcher.service())) +} + #[derive(Clone)] struct GraphqlHttpState { engine: Arc, diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 07bf7066d..0e821db6f 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -37,11 +37,12 @@ pub use command_contract::{ pub use command_contract::{ __command_projection_event_descriptor, __command_projection_event_preview, __command_projection_events, __command_projection_preview_constant, - __command_projection_state_preview, typed_command, Atomic, CommandConsistency, + __command_projection_state_known_values, __command_projection_state_preview, + command_transition, typed_command, Atomic, CommandConsistency, CommandEventSet, CommandOutcome, CommandProjectionEventSet, CommandProjectionPreview, CommandProjectionPreviewSource, - CompiledDirectProjectionTarget, CompiledInputDefaults, Eventual, PrepareCommandError, - PreparedCommand, Succeeded, TypedCommand, TypedEffectExpression, TypedEffectKey, - TypedEffectRelationship, + 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, @@ -96,7 +97,9 @@ pub use engine::{ GraphqlEngineBuilder, GraphqlPool, GraphqlPoolSource, }; #[cfg(feature = "graphql")] -pub use http::{graphiql_page, graphql_router, graphql_router_with_service}; +pub use http::{ + graphiql_page, graphql_router, graphql_router_with_dispatcher, graphql_router_with_service, +}; #[cfg(feature = "graphql")] pub use identity::{ extract_bearer, map_claims_to_session, public_oidc_identity_from_env, diff --git a/src/graphql/projection_delta/tests.rs b/src/graphql/projection_delta/tests.rs index df482ff44..fddf3dad9 100644 --- a/src/graphql/projection_delta/tests.rs +++ b/src/graphql/projection_delta/tests.rs @@ -618,6 +618,7 @@ fn application_wire_surface_uses_authenticated_role_policy_for_actual_rows() { &surface, "delta-app", &["delta-admin".into(), "delta-user".into()], + &["delta-admin".into(), "delta-user".into()], &grants_by_role, ) .unwrap(); diff --git a/src/graphql/projection_delta/types.rs b/src/graphql/projection_delta/types.rs index 8880fdc8c..8af91baa0 100644 --- a/src/graphql/projection_delta/types.rs +++ b/src/graphql/projection_delta/types.rs @@ -100,7 +100,11 @@ pub struct ProjectionDeltaIdentity { #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ProjectionDeltaSurfaceIdentity { Role { name: String }, - Application { name: String, roles: Vec }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } /// Exact selected program/binding compatibility pins. @@ -453,10 +457,15 @@ impl From<&crate::graphql::client_manifest::ClientSurfaceIdentity> crate::graphql::client_manifest::ClientSurfaceIdentity::Role { name } => { Self::Role { name: name.clone() } } - crate::graphql::client_manifest::ClientSurfaceIdentity::Application { name, roles } => { + crate::graphql::client_manifest::ClientSurfaceIdentity::Application { + name, + eligible_roles, + schema_roles, + } => { Self::Application { name: name.clone(), - roles: roles.clone(), + eligible_roles: eligible_roles.clone(), + schema_roles: schema_roles.clone(), } } } @@ -467,14 +476,33 @@ impl ProjectionDeltaSurfaceIdentity { fn validate(&self) -> Result<(), ProjectionDeltaError> { match self { Self::Role { name } => validate_identity("role surface", name), - Self::Application { name, roles } => { + Self::Application { + name, + eligible_roles, + schema_roles, + } => { validate_identity("application surface", name)?; - if roles.is_empty() { + if eligible_roles.is_empty() { + return Err(ProjectionDeltaError::InvalidIdentity { + field: "application eligible roles", + }); + } + validate_names("application eligible roles", eligible_roles)?; + if schema_roles.is_empty() { return Err(ProjectionDeltaError::InvalidIdentity { - field: "application roles", + field: "application schema roles", }); } - validate_names("application roles", roles) + validate_names("application schema roles", schema_roles)?; + if schema_roles + .iter() + .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) + { + return Err(ProjectionDeltaError::InvalidIdentity { + field: "application schema roles", + }); + } + Ok(()) } } } diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index c9ff8c761..0e69fc105 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -948,7 +948,7 @@ async fn resolve_command( let Some(service) = service else { return Err(client_error( "INTERNAL", - "command dispatcher not configured (use graphql_router_with_service)", + "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)", )); }; @@ -1041,7 +1041,7 @@ async fn resolve_command_status( let service = ctx.data_opt::>().ok_or_else(|| { client_error( "INTERNAL", - "command dispatcher not configured (use graphql_router_with_service)", + "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)", ) })?; let command_id = ctx diff --git a/src/graphql/sdl.rs b/src/graphql/sdl.rs index e29f5382a..5c7362157 100644 --- a/src/graphql/sdl.rs +++ b/src/graphql/sdl.rs @@ -574,7 +574,7 @@ fn surface_arguments_sdl(arguments: &[super::surface::SurfaceArgument]) -> Strin format!("({arguments})") } -/// Filter operational tables and render SDL for a project manifest's tables. +/// Filter operational tables and render SDL for a read-model schema catalog. pub fn graphql_sdl_from_schemas( schemas: impl IntoIterator, ) -> Result { diff --git a/src/graphql/surface/application.rs b/src/graphql/surface/application.rs index 77ba0edcf..9cbd40440 100644 --- a/src/graphql/surface/application.rs +++ b/src/graphql/surface/application.rs @@ -545,7 +545,7 @@ pub(in crate::graphql::surface) fn validate_surface_filter( } /// Build an explicit named application surface as the structural intersection -/// of all runtime roles it supports (eligible roles = schema roles). +/// of its declared schema roles, with a separate eligible opener set. /// /// A missing role declaration is an error rather than an accidental empty or /// admin surface. Commands must be granted to every schema role; differing row @@ -557,10 +557,17 @@ pub(in crate::graphql::surface) fn validate_surface_filter( pub fn surface_for_application( surface: &Surface, application: &str, - roles: &[String], + eligible_roles: &[String], + schema_roles: &[String], grants_by_role: &BTreeMap>, ) -> Result { - surface_for_application_contract(surface, application, roles, roles, grants_by_role) + surface_for_application_contract( + surface, + application, + eligible_roles, + schema_roles, + grants_by_role, + ) } /// Build an application surface with distinct **eligible** and **schema** roles. @@ -585,20 +592,48 @@ pub fn surface_for_application_contract( } let mut eligible_roles = eligible_roles.to_vec(); let mut schema_roles = schema_roles.to_vec(); + if eligible_roles.iter().any(|role| role.trim().is_empty()) { + return Err(format!( + "application surface `{application}` eligible roles must be nonempty" + )); + } + if schema_roles.iter().any(|role| role.trim().is_empty()) { + return Err(format!( + "application surface `{application}` schema roles must be nonempty" + )); + } + let eligible_roles_were_unique = { + let mut sorted = eligible_roles.clone(); + sorted.sort(); + sorted.windows(2).all(|roles| roles[0] != roles[1]) + }; + let schema_roles_were_unique = { + let mut sorted = schema_roles.clone(); + sorted.sort(); + sorted.windows(2).all(|roles| roles[0] != roles[1]) + }; eligible_roles.sort(); - eligible_roles.dedup(); schema_roles.sort(); - schema_roles.dedup(); if eligible_roles.is_empty() { return Err(format!( "application surface `{application}` must declare at least one eligible role" )); } + if !eligible_roles_were_unique { + return Err(format!( + "application surface `{application}` eligible roles must be unique" + )); + } if schema_roles.is_empty() { return Err(format!( "application surface `{application}` must declare at least one schema role" )); } + if !schema_roles_were_unique { + return Err(format!( + "application surface `{application}` schema roles must be unique" + )); + } if schema_roles .iter() .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) @@ -688,7 +723,8 @@ pub fn surface_for_application_contract( .sort_by(|a, b| a.command_name.cmp(&b.command_name)); selected.selection = SurfaceSelection::Application { name: application.to_string(), - roles: eligible_roles, + eligible_roles, + schema_roles, }; Ok(selected) } diff --git a/src/graphql/surface/projections.rs b/src/graphql/surface/projections.rs index 423f2c091..b1b53fd0e 100644 --- a/src/graphql/surface/projections.rs +++ b/src/graphql/surface/projections.rs @@ -15,7 +15,7 @@ use super::types::{SurfaceProjectionOwner, SurfaceProjectionOwnerKind}; use super::{SurfaceModel, SurfaceRelationshipKeys}; /// One role-safe selected operation from an authoritative projection program. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub(crate) struct SurfaceProjectionOperation { pub operation_id: String, pub staging_ordinal: u32, @@ -31,7 +31,7 @@ pub(crate) struct SurfaceProjectionOperation { /// One exact selected event arm. Its selector is server-only; client export /// receives only a digest-derived event reference. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub(crate) struct SurfaceProjectionArm { pub arm_id: String, pub selector: crate::ProjectionEventSelector, @@ -39,7 +39,7 @@ pub(crate) struct SurfaceProjectionArm { } /// Role-safe program inventory retained after Surface selection. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub(crate) struct SurfaceSelectedProjectionProgram { pub name: String, pub version: u64, @@ -112,6 +112,48 @@ impl std::fmt::Debug for SurfaceModeledProjection { } impl SurfaceModeledProjection { + /// Return the complete portable behavior contract for this selected + /// registration. Executor routes, server executor closures, and physical + /// topology are intentionally excluded; the program IR and binding + /// compatibility fields remain canonical identity material. + pub(crate) fn canonical_contract_value(&self) -> Result { + let program = if let Some(raw_program) = &self.raw_program { + serde_json::to_value(raw_program).map_err(|error| error.to_string())? + } else if let Some(selected) = &self.selected { + serde_json::to_value(selected).map_err(|error| error.to_string())? + } else { + return Err("modeled projection has no canonical program material".to_owned()); + }; + let binding = self.raw_binding.as_ref().map(|binding| { + serde_json::json!({ + "identity_version": binding.identity_version(), + "program_ir_version": binding.program_ir_version(), + "operation_semantics_version": binding.operation_semantics_version(), + "program_id": binding.program_id().to_string(), + "events": binding.events(), + "source": binding.source(), + "owner": binding.owner(), + "placement": binding.placement(), + "execution_class": binding.execution_class(), + "partition": binding.partition(), + "outputs": binding.outputs(), + "relationships": binding.relationships(), + }) + }); + Ok(serde_json::json!({ + "program_id": self.program_id.to_string(), + "binding_id": self.binding_id.to_string(), + "owner": self.owner, + "placement": self.placement, + "execution_class": self.execution_class, + "state": self.state, + "epoch": self.epoch.as_str(), + "output_models": self.output_models, + "program": program, + "binding": binding, + })) + } + #[cfg(test)] pub(crate) fn selected_for_client_manifest_test( program_id: ProjectionProgramId, diff --git a/src/graphql/surface/tests.rs b/src/graphql/surface/tests.rs index 61f9005c1..65cae0d37 100644 --- a/src/graphql/surface/tests.rs +++ b/src/graphql/surface/tests.rs @@ -778,7 +778,7 @@ fn selected_surfaces_reject_command_and_projector_reattachment() { let grants_by_role = BTreeMap::from([("user".into(), grants)]); let application = - surface_for_application(&full, "web", &["user".into()], &grants_by_role).unwrap(); + surface_for_application(&full, "web", &["user".into()], &["user".into()], &grants_by_role).unwrap(); assert!(application .clone() .with_typed_commands(&TypedCommandInventory::empty()) diff --git a/src/graphql/surface/types.rs b/src/graphql/surface/types.rs index 30f9f5202..2b8dbf3ac 100644 --- a/src/graphql/surface/types.rs +++ b/src/graphql/surface/types.rs @@ -61,7 +61,7 @@ pub enum SurfaceRowPolicy { } /// Semantic category for one GraphQL field argument. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum SurfaceArgumentKind { Filter, Order, @@ -71,7 +71,7 @@ pub enum SurfaceArgumentKind { } /// One accepted root/relationship argument from the shared Surface IR. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct SurfaceArgument { pub name: String, pub kind: SurfaceArgumentKind, @@ -105,7 +105,7 @@ pub struct RootField { } /// Column field on an object type (after skips / role filter). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct ColumnField { pub name: String, pub scalar: String, @@ -129,7 +129,7 @@ pub struct RelField { pub aggregate: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct SurfaceRelationshipAggregate { pub name: String, pub type_name: String, @@ -565,10 +565,168 @@ impl std::fmt::Debug for Surface { pub(crate) enum SurfaceSelection { Catalog, Role { name: String }, - Application { name: String, roles: Vec }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } impl Surface { + /// Serialize the complete behavior-affecting Surface IR once. Application + /// manifests, SDL metadata, and client exports use this snapshot as their + /// shared identity input; none of them re-walks a table catalog. + pub(crate) fn canonical_contract_value(&self) -> Result { + let selection = match &self.selection { + SurfaceSelection::Catalog => serde_json::json!({"kind": "catalog"}), + SurfaceSelection::Role { name } => serde_json::json!({"kind": "role", "name": name}), + SurfaceSelection::Application { + name, + eligible_roles, + schema_roles, + } => { + let mut eligible_roles = eligible_roles.clone(); + let mut schema_roles = schema_roles.clone(); + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); + serde_json::json!({ + "kind": "application", + "name": name, + "eligible_roles": eligible_roles, + "schema_roles": schema_roles, + }) + } + }; + let models = self + .models + .values() + .map(|model| { + let mut columns = model.columns.clone(); + columns.sort_by(|left, right| left.name.cmp(&right.name)); + let mut primary_key = model.primary_key.clone(); + primary_key.sort(); + let mut relationships = model + .relationships + .iter() + .map(|relationship| { + serde_json::json!({ + "name": relationship.name, + "target_model": relationship.target_model, + "target_object": relationship.target_object, + "kind": format!("{:?}", relationship.kind).to_ascii_lowercase(), + "list": relationship.list, + "nullable": relationship.nullable, + "arguments": relationship + .arguments + .iter() + .map(argument_value) + .collect::>(), + "keys": relationship_keys_value(&relationship.keys), + "dependencies": relationship.dependencies, + "aggregate": relationship.aggregate.as_ref().map(|aggregate| { + serde_json::json!({ + "name": aggregate.name, + "type_name": aggregate.type_name, + "arguments": aggregate + .arguments + .iter() + .map(argument_value) + .collect::>(), + "dependencies": aggregate.dependencies, + }) + }), + }) + }) + .collect::>(); + 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, + "object_name": model.object_name, + "columns": columns, + "relationships": relationships, + "primary_key": primary_key, + "row_policy": row_policy_value(&model.row_policy), + "role_limit": model.role_limit, + "aggregations": model.aggregations, + }) + }) + .collect::>(); + let mut roots = self + .query_fields + .iter() + .map(|root| root_value("query", 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()) + .cmp(&(right["operation"].as_str(), right["name"].as_str())) + }); + let mut commands = self + .commands + .iter() + .map(|command| { + serde_json::json!({ + "command_name": command.command_name, + "field_name": command.field_name, + "roles": command.roles, + "input": command_shape_value(&command.input), + "output": command_shape_value(&command.output), + "consistency": command.consistency, + "input_defaults": command.input_defaults, + "effects": command.effects, + "confirmations": command.confirmations, + "projected_model": command.projected_model.as_ref().map(|model| model.model.clone()), + "direct_projection": command.direct_projection.as_ref().map(|target| target.canonical_value()), + "projections": command.projections, + "confirmation_unavailable": command.confirmation_unavailable, + }) + }) + .collect::>(); + commands.sort_by(|left, right| left["command_name"].as_str().cmp(&right["command_name"].as_str())); + let mut projectors = self + .projectors + .iter() + .map(|owner| { + let modeled = owner + .modeled + .iter() + .map(|modeled| modeled.canonical_contract_value()) + .collect::, _>>()?; + Ok::<_, String>(serde_json::json!({ + "name": owner.name, + "facts": owner.facts, + "models": owner.models, + "dependencies": owner.dependencies, + "change_epoch": owner.change_epoch, + "partition": owner.partition, + "kind": if owner.is_direct() { "direct" } else { "async" }, + "modeled": modeled, + })) + }) + .collect::, _>>()?; + projectors.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); + let value = serde_json::json!({ + "version": 1, + "selection": selection, + "dialect": format!("{:?}", self.dialect).to_ascii_lowercase(), + "aggregates": self.aggregates, + "subscriptions": self.subscriptions, + "default_limit": self.default_limit, + "max_limit": self.max_limit, + "models": models, + "roots": roots, + "comparison_ops": self.comparison_ops, + "commands": commands, + "commands_attached": self.commands_attached, + "projectors": projectors, + "projectors_attached": self.projectors_attached, + }); + Ok(crate::application::canonical_json(&value)) + } + /// Inventory of query root field names (sorted). pub fn query_root_names(&self) -> Vec<&str> { let mut names: Vec<&str> = self.query_fields.iter().map(|f| f.name.as_str()).collect(); @@ -602,8 +760,9 @@ impl Surface { } /// Attach the crate-private typed command inventory to this unselected - /// catalog surface. Public callers derive this exclusively via - /// [`Surface::with_service`]. + /// catalog surface. The inventory must be bound before authorization + /// selection so role/application filtering operates on the declaration-owned + /// typed command contracts. pub(crate) fn with_typed_commands( mut self, commands: &crate::graphql::commands::TypedCommandInventory, @@ -636,6 +795,41 @@ impl Surface { Ok(self) } + /// Bind one explicit logical module's retained typed command contracts to + /// this unselected catalog Surface without constructing executable runtime + /// state. The module definitions are the authoritative source; public + /// command JSON is never used to reconstruct typed shapes or effects. + pub fn with_module(self, module: &crate::application::Module) -> Result { + self.with_modules(std::iter::once(module)) + } + + /// Bind several explicit logical modules before role/application + /// authorization selection. + pub fn with_modules<'a, I>( + mut self, + modules: I, + ) -> Result + where + I: IntoIterator, + { + if !matches!(self.selection, SurfaceSelection::Catalog) { + return Err( + "module commands can only be attached to the unselected catalog Surface before authorization selection" + .into(), + ); + } + if self.commands_attached { + return Err("a command registry has already been attached to this Surface".into()); + } + let mut contracts = Vec::new(); + for module in modules { + contracts.extend(module.typed_command_contracts()?); + } + let inventory = crate::graphql::commands::TypedCommandInventory::from_contracts(&contracts)?; + self = self.with_typed_commands(&inventory)?; + Ok(self) + } + /// Pool-free authoritative typed command path. The executable Routes /// inventory supplies both GraphQL declarations and non-forgeable service /// provenance used by static client export. @@ -814,3 +1008,105 @@ impl Surface { Ok(self) } } + +fn root_value(operation: &str, root: &RootField) -> serde_json::Value { + serde_json::json!({ + "operation": operation, + "name": root.name, + "kind": match root.kind { + RootKind::List => "list", + RootKind::ByPk => "by_pk", + RootKind::Aggregate => "aggregate", + }, + "object": root.object, + "model_name": root.model_name, + "arguments": root.arguments.iter().map(argument_value).collect::>(), + "dependencies": root.dependencies, + "default_limit": root.default_limit, + "max_limit": root.max_limit, + }) +} + +fn argument_value(argument: &SurfaceArgument) -> serde_json::Value { + serde_json::json!({ + "name": argument.name, + "kind": argument_kind_name(argument.kind), + "type_name": argument.type_name, + "nullable": argument.nullable, + "list": argument.list, + }) +} + +fn argument_kind_name(kind: SurfaceArgumentKind) -> &'static str { + match kind { + SurfaceArgumentKind::Filter => "filter", + SurfaceArgumentKind::Order => "order", + SurfaceArgumentKind::Limit => "limit", + SurfaceArgumentKind::Offset => "offset", + SurfaceArgumentKind::PrimaryKey => "primary_key", + } +} + +fn command_shape_value(shape: &SurfaceCommandShape) -> serde_json::Value { + match shape { + SurfaceCommandShape::None => serde_json::Value::Null, + SurfaceCommandShape::Typed(definition) => type_def_value(definition), + } +} + +fn type_def_value(definition: &SurfaceTypeDef) -> serde_json::Value { + serde_json::json!({ + "name": definition.name, + "fields": definition.fields.iter().map(|field| serde_json::json!({ + "name": field.name, + "type_name": field.type_name, + "nullable": field.nullable, + "list": field.list, + "item_nullable": field.item_nullable, + "nested": field.nested.as_deref().map(type_def_value), + })).collect::>(), + }) +} + +fn row_policy_value(policy: &SurfaceRowPolicy) -> serde_json::Value { + match policy { + SurfaceRowPolicy::Unrestricted => serde_json::json!({"kind": "unrestricted"}), + SurfaceRowPolicy::Predicate(predicate) => { + serde_json::json!({"kind": "predicate", "expression": predicate}) + } + SurfaceRowPolicy::ServerOnly => serde_json::json!({"kind": "server_only"}), + } +} + +fn relationship_keys_value(keys: &SurfaceRelationshipKeys) -> serde_json::Value { + match keys { + SurfaceRelationshipKeys::Direct { local, remote } => { + serde_json::json!({"kind": "direct", "local": local, "remote": remote}) + } + SurfaceRelationshipKeys::Through { + local, + remote, + table, + source_foreign_key, + target_foreign_key, + } => serde_json::json!({ + "kind": "through", + "local": local, + "remote": remote, + "table": table, + "source_foreign_key": source_foreign_key, + "target_foreign_key": target_foreign_key, + }), + SurfaceRelationshipKeys::ThroughOpaque { + local, + remote, + dependency, + } => serde_json::json!({ + "kind": "through_opaque", + "local": local, + "remote": remote, + "dependency": dependency, + }), + SurfaceRelationshipKeys::Embedded => serde_json::json!({"kind": "embedded"}), + } +} diff --git a/src/lib.rs b/src/lib.rs index 170c3a96c..cb5c5c3a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,17 @@ // Allow proc-macros to reference this crate by name even when used internally extern crate self as distributed; +/// Macro implementation support. External packages may rename the +/// distributed dependency and re-export it under the generated path; the +/// generated code must not require a direct serde dependency of its own. +#[doc(hidden)] +pub mod __private { + pub use serde; +} + pub mod aggregate; +pub mod application; +pub mod command_dispatch; pub mod bus; pub mod domain_event; pub mod entity; @@ -24,7 +34,6 @@ pub mod emitter; pub mod graphql; mod in_memory_repo; pub mod lock; -pub mod manifest; #[cfg(feature = "metrics")] pub mod metrics; pub mod microsvc; @@ -53,6 +62,21 @@ pub use entity::{ BITCODE_PAYLOAD_CODEC_VERSION, }; +// Placement-independent application composition. The module path remains the +// canonical namespace; these common contract types are also convenient at the +// crate root for contract-only packages. +pub use application::{ + Application, ApplicationError, ApplicationManifest, CommandMount, CommandMountHandler, + CommandMountRegistrar, CommandSpec, ContractCompiler, DeploymentPlan, LogicalId, Module, + ModuleManifest, MountSelector, ProcessIntent, ProcessPreset, ProjectionSpec, SurfaceSpec, + APPLICATION_MANIFEST_SCHEMA_VERSION, DEPLOYMENT_PLAN_SCHEMA_VERSION, +}; +pub use command_dispatch::{ + CommandDispatchEnvelope, CommandDispatchError, CommandDispatchReceipt, CommandDispatcher, + LocalCommandDispatcher, RemoteCommandDispatcher, RemoteDispatchConfig, RemoteTrustMode, + SharedCommandDispatcher, APPROVED_REMOTE_DISPATCH_PROFILE, COMMAND_DISPATCH_ENVELOPE_VERSION, +}; + // Domain events: typed outward contracts distinct from replay events/snapshots. pub use domain_event::{ DomainDeletion, DomainDeletionError, DomainEvent, DomainEventBodyDescriptor, @@ -65,7 +89,7 @@ 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::{ +pub use projection::{LocalProjectionMounts, LocalProjectionMountsBuilder, ProjectionArm, ProjectionAssignment, ProjectionEnvelopeField, ProjectionEventSelector, ProjectionEventSet, ProjectionExpression, ProjectionField, ProjectionInvalidation, ProjectionKeyField, ProjectionMutationKind, ProjectionMutationProvenance, @@ -366,13 +390,8 @@ pub use table::{ TableIndex, TableKind, TableMigrationArtifact, TableModel, TableMutation, TableRowMutation, TableSchema, TableSchemaAdapter, TableSchemaAdapterCapabilities, TableSchemaBootstrap, TableSchemaIssue, TableSchemaIssueKind, TableSchemaRegistry, TableSchemaRegistryExt, - TableSchemaVerification, TableStoreError, TableWritePlan, DEFAULT_TABLE_VERSION_COLUMN, -}; - -pub use manifest::{ - DistributedManifestEnvelope, DistributedProjectManifest, MessageEndpointManifest, - MetricsEndpointManifest, ServiceManifest, ServiceObservabilityManifest, TraceExportMode, - TracePropagationMode, TracingManifest, TransportManifest, DISTRIBUTED_MANIFEST_SCHEMA_VERSION, + ReadModelCatalog, TableSchemaVerification, TableStoreError, TableWritePlan, + DEFAULT_TABLE_VERSION_COLUMN, }; pub use trace_context::{ is_valid_traceparent, TraceContext, CAUSATION_ID, CORRELATION_ID, TRACEPARENT, TRACESTATE, @@ -404,15 +423,20 @@ macro_rules! graphql_models { } // Session convenience re-exports used by GraphQL permission filters. -pub use microsvc::{ROLE_KEY, USER_ID_KEY}; +pub use microsvc::{ + MessageEndpointDescriptor, MetricsEndpointDescriptor, ROLE_KEY, ServiceDescriptor, + ServiceObservabilityDescriptor, TraceExportMode, TracePropagationMode, TracingDescriptor, + TransportDescriptor, USER_ID_KEY, +}; // Re-export proc macros. The old event-owning projection proc-macro and // separately authored `command_effects!` / `command_confirmations!` are gone. // Use `mutation!` / `mutation_file!` + declarative `projection!` (event→mutation // mount); commands predict events via `.emits`/`.preview`. pub use distributed_macros::{ - aggregate, command_input_defaults, digest, mutation, mutation_file, sourced, DomainEvent, - DomainState, GraphqlInput, GraphqlOutput, ReadModel, Snapshot, + aggregate, application, command, command_input_defaults, digest, module, mutation, + mutation_file, sourced, DomainEvent, DomainState, GraphqlInput, GraphqlOutput, ReadModel, + Snapshot, }; // Re-export enqueue macro (requires "emitter" feature) diff --git a/src/manifest.rs b/src/manifest.rs deleted file mode 100644 index 75360766e..000000000 --- a/src/manifest.rs +++ /dev/null @@ -1,376 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::table::{ - generate_table_migration_artifacts, table_schema_statements, TableSchema, TableSchemaRegistry, - TableSqlDialect, -}; -use crate::{RelationalReadModel, TableMigrationArtifact, TableStoreError}; - -pub const DISTRIBUTED_MANIFEST_SCHEMA_VERSION: u32 = 1; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct DistributedManifestEnvelope { - pub schema_version: u32, - pub project: DistributedProjectManifest, -} - -impl DistributedManifestEnvelope { - pub fn new(project: DistributedProjectManifest) -> Self { - Self { - schema_version: DISTRIBUTED_MANIFEST_SCHEMA_VERSION, - project, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct DistributedProjectManifest { - pub name: String, - pub tables: Vec, - pub services: Vec, -} - -impl DistributedProjectManifest { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - tables: Vec::new(), - services: Vec::new(), - } - } - - pub fn read_model(mut self) -> Self - where - M: RelationalReadModel, - { - self.try_register_read_model::() - .expect("read model schema should be valid in distributed manifest"); - self - } - - pub fn try_read_model(mut self) -> Result - where - M: RelationalReadModel, - { - self.try_register_read_model::()?; - Ok(self) - } - - pub fn try_register_read_model(&mut self) -> Result<&mut Self, TableStoreError> - where - M: RelationalReadModel, - { - self.try_register_table_schema(M::schema().clone()) - } - - pub fn table_schema(mut self, schema: TableSchema) -> Self { - self.try_register_table_schema(schema) - .expect("table schema should be valid in distributed manifest"); - self - } - - pub fn try_table_schema(mut self, schema: TableSchema) -> Result { - self.try_register_table_schema(schema)?; - Ok(self) - } - - pub fn try_register_table_schema( - &mut self, - schema: TableSchema, - ) -> Result<&mut Self, TableStoreError> { - let mut registry = self.table_registry()?; - registry.register_schema(schema.clone())?; - self.tables.push(schema); - Ok(self) - } - - pub fn service(mut self, service: ServiceManifest) -> Self { - self.services.push(service); - self - } - - pub fn table_registry(&self) -> Result { - let mut registry = TableSchemaRegistry::new(); - for schema in &self.tables { - registry.register_schema(schema.clone())?; - } - Ok(registry) - } - - pub fn sql_statements(&self, dialect: TableSqlDialect) -> Result, TableStoreError> { - table_schema_statements(&self.table_registry()?, dialect) - } - - pub fn sql_migration_artifacts( - &self, - dialect: TableSqlDialect, - ) -> Result, TableStoreError> { - generate_table_migration_artifacts(&self.table_registry()?, dialect) - } - - pub fn envelope(self) -> DistributedManifestEnvelope { - DistributedManifestEnvelope::new(self) - } - - /// Render the dialect-independent GraphQL SDL artifact for all - /// [`TableKind::ReadModel`] tables in this manifest. - pub fn graphql_sdl(&self) -> Result { - crate::graphql::graphql_sdl_for_tables(&self.tables) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServiceManifest { - pub name: String, - pub commands: Vec, - pub events: Vec, - pub transports: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub observability: Option, -} - -impl ServiceManifest { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - commands: Vec::new(), - events: Vec::new(), - transports: Vec::new(), - observability: None, - } - } - - pub fn command(mut self, name: impl Into) -> Self { - self.commands.push(MessageEndpointManifest::new(name)); - self - } - - pub fn event(mut self, name: impl Into) -> Self { - self.events.push(MessageEndpointManifest::new(name)); - self - } - - pub fn transport(mut self, kind: impl Into) -> Self { - self.transports.push(TransportManifest::new(kind)); - self - } - - pub fn observability(mut self, observability: ServiceObservabilityManifest) -> Self { - self.observability = Some(observability); - self - } - - pub fn metrics(mut self, metrics: MetricsEndpointManifest) -> Self { - let mut observability = self.observability.unwrap_or_default(); - observability.metrics = Some(metrics); - self.observability = Some(observability); - self - } - - pub fn tracing(mut self, tracing: TracingManifest) -> Self { - let mut observability = self.observability.unwrap_or_default(); - observability.tracing = Some(tracing); - self.observability = Some(observability); - self - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServiceObservabilityManifest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub metrics: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tracing: Option, -} - -impl ServiceObservabilityManifest { - pub fn new() -> Self { - Self::default() - } - - pub fn metrics(mut self, metrics: MetricsEndpointManifest) -> Self { - self.metrics = Some(metrics); - self - } - - pub fn tracing(mut self, tracing: TracingManifest) -> Self { - self.tracing = Some(tracing); - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct MetricsEndpointManifest { - pub path: String, - pub port_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub interval: Option, -} - -impl MetricsEndpointManifest { - pub fn new(path: impl Into, port_name: impl Into) -> Self { - Self { - path: path.into(), - port_name: port_name.into(), - interval: None, - } - } - - pub fn prometheus_default() -> Self { - Self::new("/metrics", "http").interval("30s") - } - - pub fn interval(mut self, interval: impl Into) -> Self { - self.interval = Some(interval.into()); - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct TracingManifest { - pub propagation: TracePropagationMode, - pub export: TraceExportMode, -} - -impl TracingManifest { - pub fn otlp() -> Self { - Self { - propagation: TracePropagationMode::W3cTraceContext, - export: TraceExportMode::Otlp, - } - } - - pub fn disabled() -> Self { - Self { - propagation: TracePropagationMode::Disabled, - export: TraceExportMode::Disabled, - } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TracePropagationMode { - #[default] - W3cTraceContext, - Disabled, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TraceExportMode { - #[default] - Otlp, - Disabled, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct MessageEndpointManifest { - pub name: String, -} - -impl MessageEndpointManifest { - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct TransportManifest { - pub kind: String, -} - -impl TransportManifest { - pub fn new(kind: impl Into) -> Self { - Self { kind: kind.into() } - } - - pub fn http() -> Self { - Self::new("http") - } -} - -#[cfg(test)] -mod tests { - use serde::{Deserialize, Serialize}; - - use super::*; - use crate::{outbox_message_schema, ReadModel}; - - #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] - #[table("orders")] - struct OrderView { - #[id("order_id")] - order_id: String, - status: String, - } - - #[test] - fn manifest_collects_schema_service_metadata_and_renders_sql() { - let manifest = DistributedProjectManifest::new("checkout") - .read_model::() - .table_schema(outbox_message_schema().clone()) - .service( - ServiceManifest::new("checkout-saga") - .command("checkout.start") - .event("seat.reserved") - .transport("http"), - ); - - let envelope = DistributedManifestEnvelope::new(manifest.clone()); - let json = serde_json::to_string(&envelope).expect("manifest should serialize"); - assert!(json.contains("\"schema_version\":1")); - assert!(json.contains("\"table_name\":\"orders\"")); - - let restored: DistributedManifestEnvelope = - serde_json::from_str(&json).expect("manifest should deserialize"); - assert_eq!(restored.project.name, "checkout"); - assert_eq!(restored.project.tables.len(), 2); - assert_eq!( - restored.project.services[0].commands[0].name, - "checkout.start" - ); - - let sql = manifest - .sql_statements(TableSqlDialect::Postgres) - .expect("manifest SQL should render") - .join("\n"); - assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"orders\"")); - assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"outbox_messages\"")); - } - - #[test] - fn service_manifest_serializes_observability_metadata_when_declared() { - let service = ServiceManifest::new("checkout-saga") - .metrics(MetricsEndpointManifest::prometheus_default()) - .tracing(TracingManifest::otlp()); - - let json = serde_json::to_string(&service).expect("service manifest should serialize"); - assert!(json.contains("\"observability\"")); - assert!(json.contains("\"path\":\"/metrics\"")); - assert!(json.contains("\"propagation\":\"w3c_trace_context\"")); - - let restored: ServiceManifest = - serde_json::from_str(&json).expect("service manifest should deserialize"); - let observability = restored - .observability - .expect("observability should deserialize"); - assert_eq!( - observability.metrics.expect("metrics").port_name, - "http".to_string() - ); - assert_eq!( - observability.tracing.expect("tracing").export, - TraceExportMode::Otlp - ); - } - - #[test] - fn service_manifest_observability_is_optional_for_older_json() { - let json = r#"{"name":"checkout-saga","commands":[],"events":[],"transports":[]}"#; - let restored: ServiceManifest = - serde_json::from_str(json).expect("older service manifest should deserialize"); - - assert!(restored.observability.is_none()); - } -} diff --git a/src/microsvc/descriptor.rs b/src/microsvc/descriptor.rs new file mode 100644 index 000000000..6551c2904 --- /dev/null +++ b/src/microsvc/descriptor.rs @@ -0,0 +1,175 @@ +use serde::{Deserialize, Serialize}; + +/// Deployment/service endpoint metadata kept outside the logical application +/// contract. It is consumed by service tooling and never serialized into an +/// [`crate::application::ApplicationManifest`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceDescriptor { + pub name: String, + pub commands: Vec, + pub events: Vec, + pub transports: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observability: Option, +} + +impl ServiceDescriptor { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + commands: Vec::new(), + events: Vec::new(), + transports: Vec::new(), + observability: None, + } + } + + pub fn command(mut self, name: impl Into) -> Self { + self.commands.push(MessageEndpointDescriptor::new(name)); + self + } + + pub fn event(mut self, name: impl Into) -> Self { + self.events.push(MessageEndpointDescriptor::new(name)); + self + } + + pub fn transport(mut self, kind: impl Into) -> Self { + self.transports.push(TransportDescriptor::new(kind)); + self + } + + pub fn observability(mut self, observability: ServiceObservabilityDescriptor) -> Self { + self.observability = Some(observability); + self + } + + pub fn metrics(mut self, metrics: MetricsEndpointDescriptor) -> Self { + let mut observability = self.observability.unwrap_or_default(); + observability.metrics = Some(metrics); + self.observability = Some(observability); + self + } + + pub fn tracing(mut self, tracing: TracingDescriptor) -> Self { + let mut observability = self.observability.unwrap_or_default(); + observability.tracing = Some(tracing); + self.observability = Some(observability); + self + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceObservabilityDescriptor { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracing: Option, +} + +impl ServiceObservabilityDescriptor { + pub fn new() -> Self { + Self::default() + } + + pub fn metrics(mut self, metrics: MetricsEndpointDescriptor) -> Self { + self.metrics = Some(metrics); + self + } + + pub fn tracing(mut self, tracing: TracingDescriptor) -> Self { + self.tracing = Some(tracing); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MetricsEndpointDescriptor { + pub path: String, + pub port_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interval: Option, +} + +impl MetricsEndpointDescriptor { + pub fn new(path: impl Into, port_name: impl Into) -> Self { + Self { + path: path.into(), + port_name: port_name.into(), + interval: None, + } + } + + pub fn prometheus_default() -> Self { + Self::new("/metrics", "http").interval("30s") + } + + pub fn interval(mut self, interval: impl Into) -> Self { + self.interval = Some(interval.into()); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TracingDescriptor { + pub propagation: TracePropagationMode, + pub export: TraceExportMode, +} + +impl TracingDescriptor { + pub fn otlp() -> Self { + Self { + propagation: TracePropagationMode::W3cTraceContext, + export: TraceExportMode::Otlp, + } + } + + pub fn disabled() -> Self { + Self { + propagation: TracePropagationMode::Disabled, + export: TraceExportMode::Disabled, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TracePropagationMode { + #[default] + W3cTraceContext, + Disabled, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TraceExportMode { + #[default] + Otlp, + Disabled, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MessageEndpointDescriptor { + pub name: String, +} + +impl MessageEndpointDescriptor { + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportDescriptor { + pub kind: String, +} + +impl TransportDescriptor { + pub fn new(kind: impl Into) -> Self { + Self { kind: kind.into() } + } + + pub fn http() -> Self { + Self::new("http") + } +} diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 4d5ddd501..45d0d6c3c 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -57,6 +57,7 @@ mod causal; mod context; +mod descriptor; mod dependencies; mod error; mod message_router; @@ -64,6 +65,18 @@ mod projector; mod runtime; mod service; mod session; +// Worker loops need a Tokio runtime (spawn/sleep); only compile when a feature +// that enables the optional `tokio` dep is active (default feature set does not). +#[cfg(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", +))] +mod workers; pub use crate::bus::{Message, MessageKind, PayloadDecodeError, SubscriptionPlan}; pub use causal::AggregateCheckout; @@ -74,6 +87,11 @@ pub use dependencies::{ HasRepo, ReadModelStoreDependencies, RepoDependencies, RepoReadModelDependencies, }; pub use error::HandlerError; +pub use descriptor::{ + MessageEndpointDescriptor, MetricsEndpointDescriptor, ServiceDescriptor, + ServiceObservabilityDescriptor, TraceExportMode, TracePropagationMode, TracingDescriptor, + TransportDescriptor, +}; pub use projector::{ CausalProjectorContext, CausalProjectorRouteBuilder, LoadedProjection, ProjectionRepairHandle, ProjectionRepairHandleParseError, @@ -85,6 +103,16 @@ pub use runtime::{DEFAULT_MAX_PUBLISH_ATTEMPTS, DEFAULT_PUBLISH_LEASE}; pub(crate) use service::CausalCommandProjectionEvidence; #[cfg(feature = "graphql")] pub use service::GraphqlServiceBindError; +#[cfg(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", +))] +pub use workers::{spawn_outbox_publish_loop, spawn_service_consumer_loop}; pub use service::{ direct_read_model, CausalCommandContext, CausalCommitBuilder, CausalRepository, CommandRequest, CommandResponse, DeliveryKind, DirectReadModelProjection, HandlerNames, HandlerSpec, @@ -93,7 +121,8 @@ pub use service::{ #[cfg(feature = "graphql")] pub(crate) use service::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalProjectionEvidenceState, + CausalCommandReceiptSource, CausalDispatchError, CausalDispatchResult, + CausalProjectionEvidenceState, }; pub use session::{Session, ROLE_KEY, USER_ID_KEY}; diff --git a/src/microsvc/service/defaults.rs b/src/microsvc/service/defaults.rs index d4cfbc606..c8a172bf4 100644 --- a/src/microsvc/service/defaults.rs +++ b/src/microsvc/service/defaults.rs @@ -16,6 +16,41 @@ impl Routes<()> { Self::from_dependencies(()) } + /// Framework helper: wire an aggregate repository + read-model store without + /// application code naming `QueuedRepository` / `RepoReadModelDependencies`. + /// + /// Product modules should start here, then register `typed_command` mounts. + pub fn for_aggregate( + repo: R, + locks: L, + read_models: S, + ) -> Routes< + RepoReadModelDependencies< + crate::AggregateRepository, A>, + S, + >, + > + where + R: crate::GetStream + crate::TransactionalCommit + Clone + Send + Sync + 'static, + L: crate::LockManager + Clone + 'static, + A: crate::Aggregate + Send + Sync + 'static, + S: HasReadModelStore + Send + Sync + 'static, + crate::QueuedRepository: Clone + + crate::AggregateBuilder + + HasOutboxStore + + crate::TransactionalCommit + + Send + + Sync + + 'static, + crate::AggregateRepository, A>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, + { + use crate::{AggregateBuilder, Queueable}; + Routes::new() + .with_repo(repo.queued_with(locks).aggregate::()) + .with_read_model_store(read_models) + } + /// Use any custom dependency value for this route bundle. pub fn with_dependencies(self, dependencies: D) -> Routes where diff --git a/src/microsvc/service/handlers.rs b/src/microsvc/service/handlers.rs index 5f7a17e37..cc3d892d6 100644 --- a/src/microsvc/service/handlers.rs +++ b/src/microsvc/service/handlers.rs @@ -575,9 +575,15 @@ where self.message.trace_context() } + /// Session for this command attempt (transport/gateway claims). + pub fn session(&self) -> &Session { + self.session + } + pub fn user_id(&self) -> Result<&str, HandlerError> { self.session .user_id() + .filter(|s| !s.is_empty()) .ok_or_else(|| HandlerError::Unauthorized("missing user ID in session".into())) } diff --git a/src/microsvc/service/mod.rs b/src/microsvc/service/mod.rs index a01aec1cb..1b5718328 100644 --- a/src/microsvc/service/mod.rs +++ b/src/microsvc/service/mod.rs @@ -42,7 +42,8 @@ pub use causal::GraphqlServiceBindError; #[cfg(feature = "graphql")] pub(crate) use causal::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalProjectionEvidenceState, + CausalCommandReceiptSource, CausalDispatchError, CausalDispatchResult, + CausalProjectionEvidenceState, }; #[allow(unused_imports)] // public API surface for handler-owned projected commits pub use handlers::StagedProjectedRow; diff --git a/src/microsvc/service/routes.rs b/src/microsvc/service/routes.rs index 40055b026..91efc087f 100644 --- a/src/microsvc/service/routes.rs +++ b/src/microsvc/service/routes.rs @@ -21,6 +21,7 @@ use super::handlers::{ ProjectorBootstrapFuture, }; use crate::aggregate::Aggregate; +use crate::application::{CommandMount, CommandMountRegistrar, CommandSpec}; use crate::bus::{Bus, Message, MessageKind, MessagePublisher, OrderedDelivery, TransportError}; #[cfg(feature = "graphql")] use crate::command_ledger::{ @@ -31,12 +32,16 @@ use crate::command_ledger::{ }; #[cfg(feature = "graphql")] use crate::graphql::command_contract::CommandConsistency; -use crate::graphql::command_contract::{CommandOutcome, TypedCommandContract}; +use crate::graphql::command_contract::{ + CommandEventSet, CommandOutcome, CompiledInputDefaults, TypedCommandContract, +}; #[cfg(feature = "graphql")] use crate::graphql::command_input::canonicalize_command_input; #[cfg(feature = "graphql")] use crate::graphql::identity::VerifiedPrincipal; -use crate::graphql::{SurfaceProjector, TypedCommand}; +use crate::graphql::{ + command_transition, GraphqlInputType, SurfaceProjector, TypedCommand, +}; #[cfg(feature = "graphql")] use crate::microsvc::causal::CausalWorkspace; use crate::microsvc::context::Context; @@ -433,6 +438,7 @@ pub struct TypedRouteBuilder { routes: Routes, route_name: &'static str, contract: TypedCommandContract, + mount: Option, _types: std::marker::PhantomData K>, } @@ -457,6 +463,75 @@ impl RouteBuilder { } } +impl TypedRouteBuilder +where + K: CommandOutcome, +{ + /// Override the GraphQL mutation field name (defaults from the command id). + #[must_use] + pub fn field_name(mut self, field_name: impl Into) -> Self { + self.contract.field_name = field_name.into(); + self + } + + /// Restrict the command to these surface roles. + #[must_use] + pub fn roles(mut self, roles: impl IntoIterator>) -> Self { + self.contract.roles = roles.into_iter().map(Into::into).collect(); + self.contract.roles.sort(); + self.contract.roles.dedup(); + self + } + + /// Declare values generated once into the canonical command input before + /// dispatch. + #[must_use] + pub fn input_defaults(mut self, defaults: CompiledInputDefaults) -> Self { + self.contract.input_defaults = defaults.0; + self.contract + .input_defaults + .sort_by(|left, right| left.path.cmp(&right.path)); + self + } + + /// Declare that one emitted state field is the authenticated `x-user-id`. + /// + /// This is preview provenance only; command admission and handler-side + /// principal binding remain mandatory. + #[must_use] + pub fn authenticated_user_field(mut self, rust_field: &'static str) -> Self + where + E: crate::domain_event::DomainEventBodyContract, + S: crate::DomainState + crate::projection::lower::ProjectionBodyMetadata, + { + let values = crate::graphql::__command_projection_state_known_values::(vec![( + rust_field, + crate::graphql::CommandProjectionPreviewSource::trusted("x-user-id", "string"), + )]); + self.contract + .projections + .add_authenticated_user_field(rust_field, values); + self + } + + /// Pure reducer over a known cache row for client auto-optimism. + #[must_use] + pub fn preview_reduce_known_record( + mut self, + reduce: crate::graphql::CommandProjectionPureReduce, + ) -> Self { + self.contract.projections.add_pure_reduce(reduce); + self + } + + /// Attach the mount produced by the same command declaration. The route + /// adapter checks its identity before registration. + pub fn mount(mut self, mount: CommandMount) -> Self { + self.mount = Some(mount); + self + } +} + impl TypedRouteBuilder where D: CausalRouteDependencies + Send + Sync + 'static, @@ -472,6 +547,7 @@ where self.routes.register_typed_handler( self.route_name, self.contract, + self.mount, None, boxed_prepared_handler(handler), ) @@ -487,6 +563,7 @@ where self.routes.register_typed_handler( self.route_name, self.contract, + self.mount, Some(guard), boxed_prepared_handler(handler), ) @@ -501,6 +578,7 @@ pub struct Routes { pub(super) dependencies: D, handlers: HashMap>>, handler_specs: Vec, + command_mounts: Vec, projectors: Vec>>, modeled_local_services: BTreeSet, outbox_configurator: Option>, @@ -513,6 +591,7 @@ impl Routes { dependencies, handlers: HashMap::new(), handler_specs: Vec::new(), + command_mounts: Vec::new(), projectors: Vec::new(), modeled_local_services: BTreeSet::new(), outbox_configurator: None, @@ -531,6 +610,7 @@ impl Routes { assert!( self.handlers.is_empty() && self.handler_specs.is_empty() + && self.command_mounts.is_empty() && self.projectors.is_empty() && self.modeled_local_services.is_empty(), "Routes::{builder} must be called before registering handlers" @@ -575,10 +655,46 @@ impl Routes { routes: self, route_name, contract, + mount: None, _types: std::marker::PhantomData, } } + /// Register a typed command whose outward emit set comes from a domain + /// transition witness (`domain_commands::*` or a domain-event marker). + /// + /// Equivalent to + /// [`typed_command`](Self::typed_command)`(command_transition::(name))` + /// so callers do not also declare `.emits` / `.emits_events`. + /// + /// Chain `.field_name`, `.roles`, optional provenance/default declarations, + /// then `.handle`. + pub fn command_transition(self, name: &'static str) -> TypedRouteBuilder + where + S: CommandEventSet, + I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + K: CommandOutcome, + { + self.typed_command(command_transition::(name)) + } + + /// Compile the registered typed declarations into portable command specs. + pub fn command_specs(&self) -> crate::application::ApplicationResult> { + let mut specs = self + .typed_contracts() + .into_iter() + .map(CommandSpec::from_contract) + .collect::>>()?; + specs.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(specs) + } + + /// Executable mounts created when typed handlers are registered. These + /// mounts are never part of a serialized application manifest. + pub fn command_mounts(&self) -> &[CommandMount] { + &self.command_mounts + } + /// Register one typed, ordered causal projector using the exact /// [`SurfaceProjector`] declaration also supplied to the GraphQL engine. pub fn causal_projector( @@ -751,6 +867,7 @@ impl Routes { mut self, route_name: &'static str, contract: TypedCommandContract, + declared_mount: Option, guard: Option>>, handle: Arc>, ) -> Self @@ -771,12 +888,30 @@ impl Routes { MessageKind::Command, route_name, ); + let spec = CommandSpec::from_contract(&contract) + .unwrap_or_else(|error| panic!("typed command contract cannot compile: {error}")); + let mount = declared_mount.unwrap_or_else(|| { + CommandMount::from_typed_route(spec.clone(), route_name) + }); + assert_eq!( + mount.spec().id, + spec.id, + "typed command mount and route declaration ids must match" + ); + assert_eq!( + mount.spec().fingerprint, + spec.fingerprint, + "typed command mount and route declaration fingerprints must match" + ); by_name.insert( route_name.to_string(), RegisteredHandler::Causal(Box::new( RegisteredCausalHandler::::new(contract, guard, handle), )), ); + mount + .register_with(&mut self) + .unwrap_or_else(|error| panic!("typed command mount registration failed: {error}")); self.handler_specs.push(HandlerSpec::command(route_name)); self } @@ -910,6 +1045,59 @@ impl Routes { } } +impl CommandMountRegistrar for Routes { + fn register_command_mount( + &mut self, + mount: CommandMount, + ) -> Result<(), crate::microsvc::HandlerError> { + let Some(handlers) = self.handlers.get(&MessageKind::Command) else { + return Err(crate::microsvc::HandlerError::UnknownCommand( + mount.spec().id.clone(), + )); + }; + if !handlers.contains_key(&mount.spec().id) { + return Err(crate::microsvc::HandlerError::UnknownCommand( + mount.spec().id.clone(), + )); + } + if mount.typed_route_name() != Some(mount.spec().id.as_str()) { + return Err(crate::microsvc::HandlerError::Rejected(format!( + "command mount `{}` is not the generated typed-route registration for its declaration", + mount.spec().id + ))); + } + let expected = self + .typed_contracts() + .into_iter() + .find(|contract| contract.name == mount.spec().id) + .and_then(|contract| CommandSpec::from_contract(contract).ok()) + .ok_or_else(|| { + crate::microsvc::HandlerError::Rejected(format!( + "command mount `{}` is not backed by a typed causal declaration", + mount.spec().id + )) + })?; + if expected.fingerprint != mount.spec().fingerprint { + return Err(crate::microsvc::HandlerError::Rejected(format!( + "command mount `{}` has a stale declaration fingerprint", + mount.spec().id + ))); + } + if self + .command_mounts + .iter() + .any(|registered| registered.spec().id == mount.spec().id) + { + return Err(crate::microsvc::HandlerError::Rejected(format!( + "command mount `{}` is registered more than once", + mount.spec().id + ))); + } + self.command_mounts.push(mount); + Ok(()) + } +} + impl ErasedCausalHandler for RegisteredCausalHandler where D: CausalRouteDependencies + Send + Sync + 'static, diff --git a/src/microsvc/service/runtime.rs b/src/microsvc/service/runtime.rs index b7817e45e..522ec6a10 100644 --- a/src/microsvc/service/runtime.rs +++ b/src/microsvc/service/runtime.rs @@ -18,6 +18,7 @@ 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::bus::{ Message, MessageKind, OrderedDelivery, RunOptions, SubscriptionPlan, TransportError, }; @@ -49,6 +50,7 @@ pub struct Service { handler_specs: Vec, causal_command_policy: CausalCommandPolicy, runner: Option, + registered_command_mounts: Vec, /// When false, HTTP does not mount `POST /{command}` (GraphQL / health only). /// Commands remain dispatchable via GraphQL mutations and in-process `dispatch`. http_command_routes: bool, @@ -66,6 +68,7 @@ impl Service { handler_specs: Vec::new(), causal_command_policy: CausalCommandPolicy::default(), runner: None, + registered_command_mounts: Vec::new(), http_command_routes: true, #[cfg(feature = "graphql")] graphql: None, @@ -299,6 +302,169 @@ impl Service { self.name.as_deref() } + /// Exact generated mounts registered with this service. The returned + /// values contain portable identity only; typed execution still requires + /// the authenticated causal dispatch path. + pub fn registered_command_mounts(&self) -> &[CommandMount] { + &self.registered_command_mounts + } + + /// 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> { + self.register_command_mount_inner(mount) + } + + /// Invoke a registered mount through the service adapter. The request + /// still enters the normal transport boundary, so typed causal routes + /// retain their authentication/receipt/projection-proof requirements. + pub async fn dispatch_mount( + &self, + mount: &CommandMount, + request: &CommandRequest, + ) -> CommandResponse { + match self.dispatch_mount_result(mount, request).await { + Ok(response) => response, + Err(error) => CommandResponse { + status: error.status_code(), + body: serde_json::json!({ "error": error.to_string() }), + }, + } + } + + pub(crate) async fn dispatch_mount_result( + &self, + mount: &CommandMount, + request: &CommandRequest, + ) -> Result { + if request.command != mount.spec().id { + return Err(HandlerError::Rejected(format!( + "command request `{}` does not match mount `{}`", + request.command, + mount.spec().id + ))); + } + 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(), + )); + } + if mount.typed_route_name().is_some() { + return Err(HandlerError::Unauthorized( + "typed command mounts require the authenticated causal dispatch adapter".into(), + )); + } + mount.invoke(request).await + } + + #[cfg(feature = "graphql")] + #[allow(dead_code)] + pub(crate) async fn dispatch_registered_mount_causally( + &self, + mount: &CommandMount, + request: &CommandRequest, + command_id: &str, + session: Session, + principal: VerifiedPrincipal, + ) -> Result { + self.ensure_registered_mount(mount) + .map_err(CausalDispatchError::Handler)?; + if mount.typed_route_name() != Some(request.command.as_str()) { + return Err(CausalDispatchError::BadRequest( + "typed command mount route identity does not match the request".into(), + )); + } + self.dispatch_causal_with_receipt( + &request.command, + command_id, + request.input.clone(), + session, + principal, + ) + .await + } + + fn register_command_mount_inner( + &mut self, + mount: CommandMount, + ) -> Result<(), HandlerError> { + let Some(indices) = self + .index + .get(&MessageKind::Command) + .and_then(|commands| commands.get(&mount.spec().id)) + else { + return Err(HandlerError::UnknownCommand(mount.spec().id.clone())); + }; + if indices.len() != 1 { + return Err(HandlerError::Rejected(format!( + "command mount `{}` has ambiguous service routes", + mount.spec().id + ))); + } + if mount.typed_route_name() != Some(mount.spec().id.as_str()) { + return Err(HandlerError::Rejected(format!( + "command mount `{}` is not the generated typed-route registration for its declaration", + mount.spec().id + ))); + } + let expected = self + .routes + .get(indices[0]) + .and_then(|routes| { + routes + .typed_command_contracts() + .into_iter() + .find(|contract| contract.name == mount.spec().id) + .and_then(|contract| CommandSpec::from_contract(contract).ok()) + }) + .ok_or_else(|| { + HandlerError::Rejected(format!( + "command mount `{}` is not backed by a typed causal route", + mount.spec().id + )) + })?; + if expected.fingerprint != mount.spec().fingerprint { + return Err(HandlerError::Rejected(format!( + "command mount `{}` has a stale declaration fingerprint", + 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 + ))); + } + self.registered_command_mounts.push(mount); + Ok(()) + } + + #[cfg(feature = "graphql")] + #[allow(dead_code)] + fn ensure_registered_mount(&self, mount: &CommandMount) -> Result<(), HandlerError> { + if self.registered_command_mounts.iter().any(|registered| { + registered.spec().id == mount.spec().id + && registered.spec().fingerprint == mount.spec().fingerprint + }) { + Ok(()) + } else { + Err(HandlerError::Rejected( + "command mount was not registered against this service".into(), + )) + } + } + /// Install the bus run behavior (used by `with_bus`). pub(crate) fn set_runner(&mut self, runner: ServiceRunner) { self.runner = Some(runner); @@ -343,6 +509,7 @@ impl Service { .into_iter() .cloned() .collect::>(); + let command_mounts = routes.command_mounts().to_vec(); #[cfg(feature = "graphql")] assert!( self.graphql.is_none() || typed_commands.is_empty(), @@ -390,6 +557,7 @@ impl Service { .push(route_index); } self.handler_specs.extend_from_slice(routes.handler_specs()); + self.registered_command_mounts.extend(command_mounts); self.routes.push(Box::new(routes)); } @@ -401,6 +569,18 @@ impl Service { .collect() } + /// Compile every explicitly registered typed command into portable specs. + /// The returned values contain no Service, repository, or handler pointer. + pub fn command_specs(&self) -> crate::application::ApplicationResult> { + let mut specs = self + .typed_command_contracts() + .iter() + .map(crate::application::CommandSpec::from_contract) + .collect::>>()?; + specs.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(specs) + } + pub(crate) fn typed_command_binding(&self) -> Result { let service_id = self .name() @@ -970,6 +1150,50 @@ impl Service { } } +impl CommandMountRegistrar for Service { + fn register_command_mount( + &mut self, + mount: CommandMount, + ) -> Result<(), HandlerError> { + self.register_command_mount_inner(mount) + } +} + +impl crate::application::CommandMountExecution for Service { + fn invoke_mount<'a>( + &'a self, + mount: &'a CommandMount, + request: CommandRequest, + invocation: crate::application::CommandMountInvocation, + ) -> crate::application::CommandMountExecutionFuture<'a> { + Box::pin(async move { + match invocation { + crate::application::CommandMountInvocation::Transport => self + .dispatch_mount_result(mount, &request) + .await + .map(crate::application::CommandMountExecutionResult::Transport) + .map_err(crate::application::CommandMountExecutionError::Handler), + #[cfg(feature = "graphql")] + crate::application::CommandMountInvocation::Authenticated { + command_id, + session, + principal, + } => self + .dispatch_registered_mount_causally( + mount, + &request, + &command_id, + session, + principal, + ) + .await + .map(crate::application::CommandMountExecutionResult::Causal) + .map_err(crate::application::CommandMountExecutionError::Causal), + } + }) + } +} + pub(super) fn validate_projector_registrations<'a>( registrations: impl IntoIterator, ) { diff --git a/src/microsvc/service/tests.rs b/src/microsvc/service/tests.rs index e2009625c..51b79c7ca 100644 --- a/src/microsvc/service/tests.rs +++ b/src/microsvc/service/tests.rs @@ -643,6 +643,24 @@ async fn typed_handler( Ok(PreparedCommand::prepare(TypedOutput { id: input.id }).unwrap()) } +#[cfg(all(feature = "graphql", feature = "application-runtime"))] +static GENERATED_MOUNT_HANDLER_INVOKED: AtomicUsize = AtomicUsize::new(0); + +#[cfg(all(feature = "graphql", feature = "application-runtime"))] +#[distributed::command( + id = "causal.generated_mount", + roles(user), + input = CausalTestInput, + outcome = Succeeded +)] +async fn generated_mount_handler( + _context: &CausalCommandContext<'_, CausalDispatcherAggregate>, + input: CausalTestInput, +) -> Result>, HandlerError> { + GENERATED_MOUNT_HANDLER_INVOKED.fetch_add(1, Ordering::SeqCst); + Ok(PreparedCommand::prepare(TypedOutput { id: input.id }).unwrap()) +} + #[derive(Default)] struct RouteComboAggregate { entity: Entity, @@ -1111,6 +1129,49 @@ async fn typed_direct_dispatch_fails_before_invoking_guard_or_handler() { assert!(!TYPED_HANDLER_INVOKED.load(Ordering::SeqCst)); } +#[cfg(all(feature = "graphql", feature = "application-runtime"))] +#[tokio::test] +async fn generated_mount_registers_and_executes_original_handler_through_causal_protocol() { + GENERATED_MOUNT_HANDLER_INVOKED.store(0, Ordering::SeqCst); + let repository = InMemoryRepository::new(); + let routes = generated_mount_handler_register( + Routes::new().with_repo(repository.aggregate::()), + ); + let service = Service::new().named("generated-mounts").routes(routes); + + assert_eq!(service.registered_command_mounts().len(), 1); + assert_eq!( + service.registered_command_mounts()[0].spec().id, + GENERATED_MOUNT_HANDLER_MOUNT.spec().id + ); + + let request = CommandRequest { + command: "causal.generated_mount".into(), + input: json!({"id": "generated-1", "label": "mounted"}), + session_variables: HashMap::new(), + }; + let result = service + .registered_command_mounts()[0] + .invoke_with( + &service, + &request, + crate::application::CommandMountInvocation::Authenticated { + command_id: causal_test_command_id(), + session: session_with_role("user"), + principal: causal_test_principal(), + }, + ) + .await; + let crate::application::CommandMountExecutionResult::Causal(result) = result + .expect("authenticated causal mount dispatch should commit") + else { + panic!("typed mount must use the causal execution result"); + }; + assert_eq!(result.payload, json!({"id": "generated-1"})); + assert_eq!(result.receipt.command_name, "causal.generated_mount"); + assert_eq!(GENERATED_MOUNT_HANDLER_INVOKED.load(Ordering::SeqCst), 1); +} + #[cfg(feature = "graphql")] #[tokio::test] async fn causal_dispatch_replays_canonical_equivalent_input_without_reinvoking_handler() { diff --git a/src/microsvc/workers.rs b/src/microsvc/workers.rs new file mode 100644 index 000000000..532337f74 --- /dev/null +++ b/src/microsvc/workers.rs @@ -0,0 +1,63 @@ +//! Framework-owned outbox and consumer worker loops. +//! +//! Applications should not reimplement dialect-specific spawn loops. + +use std::sync::Arc; +use std::time::Duration; + +use crate::bus::{Bus, RunOptions}; +use crate::microsvc::Service; +use crate::outbox_worker::{BusPublisher, OutboxDispatcher, OutboxStore}; + +/// Spawn the standard outbox publish loop for a bus-backed store. +pub fn spawn_outbox_publish_loop( + store: S, + bus: Arc, + service_name: impl Into, + lease: Duration, + max_attempts: u32, +) where + S: OutboxStore + 'static, + B: Bus + Send + Sync + 'static, +{ + let service_name = service_name.into(); + tokio::spawn(async move { + let dispatcher = OutboxDispatcher::new( + store, + BusPublisher::new(bus), + format!("outbox:{}", std::process::id()), + lease, + max_attempts, + ) + .with_service(service_name); + loop { + match dispatcher.dispatch_batch(32).await { + Ok(o) if o.published > 0 || o.claimed > 0 => {} + Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await, + Err(e) => { + eprintln!("outbox: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + }); +} + +/// Spawn a service consumer loop that re-runs the bus handler continuously. +pub fn spawn_service_consumer_loop(build_service: F) +where + F: Fn() -> Service + Send + Sync + 'static, +{ + tokio::spawn(async move { + loop { + let service = build_service(); + match service.run(RunOptions::idempotent()).await { + Ok(()) => tokio::time::sleep(Duration::from_millis(25)).await, + Err(e) => { + eprintln!("consumer: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + }); +} diff --git a/src/postgres_repo/mod.rs b/src/postgres_repo/mod.rs index c15c21838..041ba1f91 100644 --- a/src/postgres_repo/mod.rs +++ b/src/postgres_repo/mod.rs @@ -20,7 +20,7 @@ use crate::repository::RepositoryError; use crate::sqlx_repo::read_model::quote_identifier; use crate::sqlx_repo::repo::{ embedded_migrator, outbox_message_from_row, system_time_epoch_secs, SqlxOutboxStore, - SqlxRepository, + SqlxRepository, POSTGRES_MIGRATIONS, }; use crate::sqlx_repo::{ self, is_postgres_unique_violation, read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, @@ -32,30 +32,8 @@ use crate::table::{ ColumnType, RowValue, TableColumn as ColumnDef, TableStoreError as ReadModelError, }; -static POSTGRES_MIGRATOR: LazyLock = LazyLock::new(|| { - embedded_migrator(&[ - ( - 1, - "initial", - include_str!("../../migrations/postgres/0001_initial.sql"), - ), - ( - 2, - "command ledger", - include_str!("../../migrations/postgres/0002_command_ledger.sql"), - ), - ( - 3, - "projection protocol", - include_str!("../../migrations/postgres/0003_projection_protocol.sql"), - ), - ( - 4, - "command ledger atomic state", - include_str!("../../migrations/postgres/0004_command_ledger_atomic_state.sql"), - ), - ]) -}); +static POSTGRES_MIGRATOR: LazyLock = + LazyLock::new(|| embedded_migrator(POSTGRES_MIGRATIONS)); const POSTGRES_BACKEND: &str = "postgres"; const BIGINT_STORAGE: &str = "bigint storage"; diff --git a/src/projection/local_mounts.rs b/src/projection/local_mounts.rs new file mode 100644 index 000000000..e5b44fa9f --- /dev/null +++ b/src/projection/local_mounts.rs @@ -0,0 +1,288 @@ +//! Framework-owned local projection mount compilation. +//! +//! Applications declare program descriptors, read models, and epochs. Physical +//! topology, partition codecs, catalog activation, and Surface packaging are +//! derived here. + +#![allow(missing_docs)] + + +use crate::graphql::{SurfaceDirectProjection, SurfaceModeledProjection, SurfaceProjector}; +use crate::projection::catalog::{ + ActiveProjectionBindings, ProjectionBindingActivation, ProjectionCatalog, +}; +use crate::projection::lower::ProjectionDescriptor; +use crate::projection::placement::{ + ProjectionBinding, ProjectionBindingState, ProjectionEpoch, ProjectionExecutorRoute, + ProjectionOutput, ProjectionOwner, ProjectionPhysicalTopology, ProjectionSourceBinding, + PROJECTION_PARTITION_CODEC_VERSION, +}; +use crate::projection_protocol::ProjectorTopologyId; +use crate::table::TableSchema; +use crate::RelationalReadModel; + +/// One eventual surface projector ready for GraphQL/service mounts. +#[derive(Clone)] +pub struct LocalEventualMount { + pub owner: String, + pub projector: SurfaceProjector, +} + +/// One direct surface projection ready for GraphQL/service mounts. +#[derive(Clone)] +pub struct LocalDirectMount { + pub owner: String, + pub projection: SurfaceDirectProjection, +} + +/// Compiled set of local projection mounts for one application. +#[derive(Clone, Default)] +pub struct LocalProjectionMounts { + pub eventual: Vec, + pub direct: Vec, +} + +impl LocalProjectionMounts { + pub fn projector(&self, owner: &str) -> Option { + self.eventual + .iter() + .find(|mount| mount.owner == owner) + .map(|mount| mount.projector.clone()) + } + + pub fn direct_projection(&self, owner: &str) -> Option { + self.direct + .iter() + .find(|mount| mount.owner == owner) + .map(|mount| mount.projection.clone()) + } + + pub fn all_owners(&self) -> Vec { + let mut owners = Vec::new(); + for mount in &self.eventual { + owners.push(mount.projector.clone().into()); + } + for mount in &self.direct { + owners.push(mount.projection.clone().into()); + } + owners + } +} + +struct PendingEventual { + owner: String, + epoch: String, + binding: ProjectionBinding, + modeled_factory: Box< + dyn Fn( + &ProjectionCatalog, + &ActiveProjectionBindings, + &ProjectionBinding, + ) -> Result + + Send + + Sync, + >, +} + +struct PendingDirect { + owner: String, + epoch: String, + binding: ProjectionBinding, + modeled_factory: Box< + dyn Fn( + &ProjectionCatalog, + &ActiveProjectionBindings, + &ProjectionBinding, + ) -> Result + + Send + + Sync, + >, +} + +/// Builder that materializes a shared catalog and Surface mounts for local hosting. +pub struct LocalProjectionMountsBuilder { + service_id: String, + source: ProjectionSourceBinding, + eventual: Vec, + direct: Vec, +} + +impl LocalProjectionMountsBuilder { + /// `domain_source` is a stable domain event stream name (e.g. `ordered-domain-events`). + pub fn new( + service_id: impl Into, + domain_source: impl Into, + ) -> Result { + let service_id = service_id.into(); + let source = ProjectionSourceBinding::try_new( + format!("{service_id}-domain"), + domain_source, + 1, + ) + .map_err(|error| error.to_string())?; + Ok(Self { + service_id, + source, + eventual: Vec::new(), + direct: Vec::new(), + }) + } + + /// Register an eventual projection program targeting model `M`. + pub fn eventual_model( + mut self, + owner: impl Into, + descriptor: ProjectionDescriptor, + epoch: impl Into, + ) -> Result + where + M: RelationalReadModel, + D: Copy + 'static, + { + let owner = owner.into(); + let epoch = epoch.into(); + let digest = stable_topology_digest(&owner); + let binding = ProjectionBinding::materialize_eventual( + descriptor.eventual(), + self.source.clone(), + ProjectionOwner::try_new(owner.clone()).map_err(|e| e.to_string())?, + "distributed-projection-partition", + PROJECTION_PARTITION_CODEC_VERSION, + vec![projection_output_for::()?], + Vec::new(), + Some(physical_topology(&owner, digest)), + ) + .map_err(|error| error.to_string())?; + self.eventual.push(PendingEventual { + owner, + epoch, + binding, + modeled_factory: Box::new(move |catalog, active, binding| { + SurfaceModeledProjection::try_from_descriptor( + descriptor, + catalog, + active, + binding.id(), + ) + }), + }); + Ok(self) + } + + /// Register a direct projection program targeting model `M`. + pub fn direct_model( + mut self, + owner: impl Into, + descriptor: ProjectionDescriptor, + epoch: impl Into, + ) -> Result + where + M: RelationalReadModel, + { + let owner = owner.into(); + let epoch = epoch.into(); + let digest = stable_topology_digest(&owner); + let binding = ProjectionBinding::materialize_direct( + descriptor.direct(), + self.source.clone(), + ProjectionOwner::try_new(owner.clone()).map_err(|e| e.to_string())?, + "distributed-projection-partition", + PROJECTION_PARTITION_CODEC_VERSION, + vec![projection_output_for::()?], + Vec::new(), + Some(physical_topology(&owner, digest)), + ) + .map_err(|error| error.to_string())?; + self.direct.push(PendingDirect { + owner, + epoch, + binding, + modeled_factory: Box::new(move |catalog, active, binding| { + SurfaceModeledProjection::try_from_descriptor( + descriptor, + catalog, + active, + binding.id(), + ) + }), + }); + Ok(self) + } + + pub fn build(self) -> Result { + let mut bindings = Vec::new(); + for entry in &self.eventual { + bindings.push(entry.binding.clone()); + } + for entry in &self.direct { + bindings.push(entry.binding.clone()); + } + let catalog = ProjectionCatalog::try_new(bindings).map_err(|e| e.to_string())?; + let mut activations = Vec::new(); + for entry in &self.eventual { + activations.push(activation(&entry.binding, &entry.epoch, &self.service_id)?); + } + for entry in &self.direct { + activations.push(activation(&entry.binding, &entry.epoch, &self.service_id)?); + } + let active = catalog + .activate(activations, None) + .map_err(|e| e.to_string())?; + + let mut mounts = LocalProjectionMounts::default(); + for entry in &self.eventual { + let modeled = (entry.modeled_factory)(&catalog, &active, &entry.binding)?; + mounts.eventual.push(LocalEventualMount { + owner: entry.owner.clone(), + projector: SurfaceProjector::new(entry.owner.clone()).modeled(modeled), + }); + } + for entry in &self.direct { + let modeled = (entry.modeled_factory)(&catalog, &active, &entry.binding)?; + mounts.direct.push(LocalDirectMount { + owner: entry.owner.clone(), + projection: SurfaceDirectProjection::new(entry.owner.clone()).modeled(modeled), + }); + } + Ok(mounts) + } +} + +fn activation( + binding: &ProjectionBinding, + epoch: &str, + service_id: &str, +) -> Result { + Ok(ProjectionBindingActivation::new( + binding.id(), + binding.program_id(), + ProjectionEpoch::new(epoch).map_err(|e| e.to_string())?, + ProjectionBindingState::Active, + Some(ProjectionExecutorRoute::local(service_id).map_err(|e| e.to_string())?), + )) +} + +fn projection_output_for() -> Result { + let schema: TableSchema = M::schema().clone(); + ProjectionOutput::try_new(schema.model_name.clone(), schema.table_name.clone(), schema) + .map_err(|e| e.to_string()) +} + +fn physical_topology(name: &str, digest: u8) -> ProjectionPhysicalTopology { + ProjectionPhysicalTopology::from_protocol( + &ProjectorTopologyId::new(1, name, [digest; 32]) + .expect("canonical local projection topology"), + ) +} + +fn stable_topology_digest(owner: &str) -> u8 { + let mut acc = 0u8; + for (i, b) in owner.as_bytes().iter().enumerate() { + acc = acc.wrapping_add(b.wrapping_mul((i as u8).wrapping_add(1))); + } + if acc == 0 { + 0x20 + } else { + acc + } +} diff --git a/src/projection/mod.rs b/src/projection/mod.rs index 98148517f..46b8d98b6 100644 --- a/src/projection/mod.rs +++ b/src/projection/mod.rs @@ -19,9 +19,14 @@ mod provenance; // contract before their owning tasks define one. pub mod catalog; pub mod executor; +pub mod local_mounts; pub mod lower; pub mod placement; +pub use local_mounts::{ + LocalDirectMount, LocalEventualMount, LocalProjectionMounts, LocalProjectionMountsBuilder, +}; + pub use error::ProjectionProgramError; pub use expression::{ ProjectionAssignment, ProjectionEnvelopeField, ProjectionExpression, diff --git a/src/sqlite_repo/mod.rs b/src/sqlite_repo/mod.rs index e06041d04..0f4621d3c 100644 --- a/src/sqlite_repo/mod.rs +++ b/src/sqlite_repo/mod.rs @@ -21,7 +21,7 @@ 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, + SqlxRepository, SQLITE_MIGRATIONS, }; use crate::sqlx_repo::{ self, is_sqlite_unique_constraint, read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, @@ -33,30 +33,7 @@ use crate::table::{ ColumnType, RowValue, TableColumn as ColumnDef, TableStoreError as ReadModelError, }; -static SQLITE_MIGRATOR: LazyLock = LazyLock::new(|| { - embedded_migrator(&[ - ( - 1, - "initial", - include_str!("../../migrations/sqlite/0001_initial.sql"), - ), - ( - 2, - "command ledger", - include_str!("../../migrations/sqlite/0002_command_ledger.sql"), - ), - ( - 3, - "projection protocol", - include_str!("../../migrations/sqlite/0003_projection_protocol.sql"), - ), - ( - 4, - "command ledger atomic state", - include_str!("../../migrations/sqlite/0004_command_ledger_atomic_state.sql"), - ), - ]) -}); +static SQLITE_MIGRATOR: LazyLock = LazyLock::new(|| embedded_migrator(SQLITE_MIGRATIONS)); const SQLITE_BACKEND: &str = "sqlite"; const SIGNED_INTEGER_STORAGE: &str = "signed integer storage"; diff --git a/src/sqlx_repo/repo/backend.rs b/src/sqlx_repo/repo/backend.rs index d816447e7..53535cf8d 100644 --- a/src/sqlx_repo/repo/backend.rs +++ b/src/sqlx_repo/repo/backend.rs @@ -1,20 +1,26 @@ use super::*; -/// Build an embedded migrator from statically included migration files -/// (`(version, description, sql)` per file, in order). sqlx's `migrate!` -/// macro would assemble this at compile time but drags in the whole -/// proc-macro stack; here the checksums are computed once at first use, so -/// keep each backend's list in sync with its `migrations/` directory. -pub(crate) fn embedded_migrator(files: &[(i64, &'static str, &'static str)]) -> Migrator { +/// 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")); + +/// Build an embedded migrator from the validated, generated migration inventory. +pub(crate) fn embedded_migrator(files: &[EmbeddedMigration]) -> Migrator { Migrator::with_migrations( files .iter() - .map(|&(version, description, sql)| { + .map(|migration| { Migration::new( - version, - description.into(), + migration.version, + migration.description.into(), MigrationType::Simple, - sqlx::SqlSafeStr::into_sql_str(sql), + sqlx::SqlSafeStr::into_sql_str(migration.sql), false, ) }) @@ -22,6 +28,111 @@ pub(crate) fn embedded_migrator(files: &[(i64, &'static str, &'static str)]) -> ) } +#[expect( + clippy::items_after_test_module, + reason = "migration parity tests stay beside generated registration data" +)] +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "sqlite")] + #[test] + fn generated_sqlite_inventory_preserves_order_descriptions_and_bytes() { + let versions = SQLITE_MIGRATIONS + .iter() + .map(|migration| migration.version) + .collect::>(); + let descriptions = SQLITE_MIGRATIONS + .iter() + .map(|migration| migration.description) + .collect::>(); + let sql = SQLITE_MIGRATIONS + .iter() + .map(|migration| migration.sql) + .collect::>(); + assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!( + descriptions, + vec![ + "initial", + "command ledger", + "projection protocol", + "command ledger atomic state" + ] + ); + assert_eq!( + sql, + vec![ + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0001_initial.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0002_command_ledger.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0003_projection_protocol.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0004_command_ledger_atomic_state.sql" + )), + ] + ); + } + + #[cfg(feature = "postgres")] + #[test] + fn generated_postgres_inventory_preserves_order_descriptions_and_bytes() { + let versions = POSTGRES_MIGRATIONS + .iter() + .map(|migration| migration.version) + .collect::>(); + let descriptions = POSTGRES_MIGRATIONS + .iter() + .map(|migration| migration.description) + .collect::>(); + let sql = POSTGRES_MIGRATIONS + .iter() + .map(|migration| migration.sql) + .collect::>(); + assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!( + descriptions, + vec![ + "initial", + "command ledger", + "projection protocol", + "command ledger atomic state" + ] + ); + assert_eq!( + sql, + vec![ + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0001_initial.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0002_command_ledger.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0003_projection_protocol.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0004_command_ledger_atomic_state.sql" + )), + ] + ); + } +} + /// Group stream identities by aggregate type so each type is one id-list /// round trip instead of a query per identity. Callers issue single-type /// batches in the common case, so this usually yields one group; the grouping diff --git a/src/sqlx_repo/repo/mod.rs b/src/sqlx_repo/repo/mod.rs index 3805d093e..716b1e161 100644 --- a/src/sqlx_repo/repo/mod.rs +++ b/src/sqlx_repo/repo/mod.rs @@ -90,6 +90,10 @@ use snapshots::*; pub(crate) use backend::embedded_migrator; pub use backend::SqlxRepoBackend; +#[cfg(feature = "postgres")] +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; diff --git a/src/table/catalog.rs b/src/table/catalog.rs new file mode 100644 index 000000000..f8b6ec30d --- /dev/null +++ b/src/table/catalog.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +use super::{ + generate_table_migration_artifacts, table_schema_statements, TableMigrationArtifact, + TableSchema, TableSchemaRegistry, TableSqlDialect, TableStoreError, +}; + +/// A schema-only catalog for relational read models and operational tables. +/// +/// This is deliberately not an application manifest. It owns physical table +/// metadata and SQL rendering only; application commands, projections, +/// surfaces, provenance, and executable mounts belong to +/// [`crate::application::ApplicationManifest`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadModelCatalog { + pub name: String, + pub tables: Vec, +} + +impl ReadModelCatalog { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + tables: Vec::new(), + } + } + + pub fn read_model(mut self) -> Self + where + M: crate::read_model::RelationalReadModel, + { + self.try_register_read_model::() + .expect("read model schema should be valid in the schema catalog"); + self + } + + pub fn try_read_model(mut self) -> Result + where + M: crate::read_model::RelationalReadModel, + { + self.try_register_read_model::()?; + Ok(self) + } + + pub fn try_register_read_model(&mut self) -> Result<&mut Self, TableStoreError> + where + M: crate::read_model::RelationalReadModel, + { + self.try_register_table_schema(M::schema().clone()) + } + + pub fn table_schema(mut self, schema: TableSchema) -> Self { + self.try_register_table_schema(schema) + .expect("table schema should be valid in the schema catalog"); + self + } + + pub fn try_table_schema(mut self, schema: TableSchema) -> Result { + self.try_register_table_schema(schema)?; + Ok(self) + } + + pub fn try_register_table_schema( + &mut self, + schema: TableSchema, + ) -> Result<&mut Self, TableStoreError> { + let mut registry = self.table_registry()?; + registry.register_schema(schema.clone())?; + self.tables.push(schema); + self.tables.sort_by(|left, right| { + (left.model_name.as_str(), left.table_name.as_str()) + .cmp(&(right.model_name.as_str(), right.table_name.as_str())) + }); + Ok(self) + } + + pub fn table_registry(&self) -> Result { + let mut registry = TableSchemaRegistry::new(); + for schema in &self.tables { + registry.register_schema(schema.clone())?; + } + Ok(registry) + } + + pub fn sql_statements(&self, dialect: TableSqlDialect) -> Result, TableStoreError> { + table_schema_statements(&self.table_registry()?, dialect) + } + + pub fn sql_migration_artifacts( + &self, + dialect: TableSqlDialect, + ) -> Result, TableStoreError> { + generate_table_migration_artifacts(&self.table_registry()?, dialect) + } +} diff --git a/src/table/metadata.rs b/src/table/metadata.rs index 9009bb862..693d580e5 100644 --- a/src/table/metadata.rs +++ b/src/table/metadata.rs @@ -168,7 +168,7 @@ pub struct RelationshipDef { /// Discriminator for tables owned by the framework vs read-model projections. /// /// Operational tables (outbox, inbox, …) are never exposed on the GraphQL query -/// surface; `from_manifest` / `graphql_sdl` consume only `ReadModel` entries. +/// surface; schema-catalog adapters consume only `ReadModel` entries. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum TableKind { #[default] diff --git a/src/table/mod.rs b/src/table/mod.rs index e216a8731..8b05c0d17 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -7,6 +7,7 @@ //! typed staging/load surface on top of them. mod error; +mod catalog; mod metadata; mod mutation; mod plan; @@ -14,6 +15,7 @@ mod registry; mod sql; pub use error::TableStoreError; +pub use catalog::ReadModelCatalog; pub use metadata::{ ColumnType, ForeignKey, PrimaryKey, RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, TableColumn, TableIndex, TableKind, TableSchema, DEFAULT_TABLE_VERSION_COLUMN, diff --git a/tests/application_composition.rs b/tests/application_composition.rs new file mode 100644 index 000000000..9aac133cf --- /dev/null +++ b/tests/application_composition.rs @@ -0,0 +1,530 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use distributed::application::{ + Application, ApplicationExtension, CommandDefinition, CommandMount, CommandSpec, + CommandTypeField, CommandTypeSpec, ContractCompiler, Module, ProjectionSpec, SurfaceSpec, +}; +use distributed::graphql::{ + build_surface, col, surface_for_application_contract, surface_for_role, typed_command, + ClientSurfaceIdentity, CommandConsistency, RoleGrant, Succeeded, Surface, SurfaceOptions, +}; +use distributed::{ApplicationManifest, GraphqlInput, GraphqlOutput, ReadModel, RelationalReadModel}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, ReadModel, Serialize)] +#[table("todos")] +struct TodoView { + #[id("todo_id")] + todo_id: String, + title: String, + status: String, +} + +#[allow(dead_code)] +#[derive(Clone, Deserialize, GraphqlInput)] +struct ContractCommandInput { + title: String, +} + +#[derive(Clone, Serialize, GraphqlOutput)] +struct ContractCommandOutput { + id: String, +} + +fn typed_definition(id: &'static str, roles: &[&str]) -> CommandDefinition { + let command = typed_command::< + ContractCommandInput, + Succeeded, + >(id) + .roles(roles.iter().copied()); + CommandDefinition::from_typed_command(command, None) + .expect("typed contract should compile without an executable mount") +} + +fn command_module() -> Module { + Module::new("todo-contract") + .command_definitions([ + typed_definition("todo.allowed", &["user"]), + typed_definition("todo.forbidden", &["admin"]), + ]) + .build() + .expect("typed command module should compile") +} + +fn command_catalog() -> Surface { + full_surface() + .with_module(&command_module()) + .expect("module contracts should bind before authorization") +} + +fn grants_for(role: &str) -> BTreeMap { + BTreeMap::from([( + "TodoView".into(), + RoleGrant::all_columns().rows(col("status").eq(role)), + )]) +} + +fn command_names(surface: &Surface) -> Vec { + surface + .commands() + .iter() + .map(|command| command.command_name.clone()) + .collect() +} + +fn command(id: &str) -> CommandSpec { + CommandSpec::try_new( + id, + id.replace('.', "_"), + CommandTypeSpec { + name: format!("{id}Input"), + fields: vec![CommandTypeField { + name: "title".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + CommandTypeSpec { + name: format!("{id}Output"), + fields: vec![CommandTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + CommandConsistency::Eventual, + ) + .expect("test command should be portable") +} + +fn definition(id: &str) -> CommandDefinition { + CommandDefinition::contract(command(id)) +} + +fn application_with_commands(order: &[&str]) -> Application { + let definitions = order.iter().map(|id| definition(id)).collect::>(); + let module = Module::new("todo") + .command_definitions(definitions) + .build() + .expect("test module should be valid"); + Application::new("todo-app") + .module(module) + .build() + .expect("test application should be valid") +} + +fn full_surface() -> Surface { + build_surface(&[TodoView::schema().clone()], &SurfaceOptions::sqlite()) + .expect("non-empty Surface should compile") +} + +fn selected_surface() -> Surface { + let full = full_surface(); + let grants = BTreeMap::from([( + "user".to_string(), + BTreeMap::from([( + "TodoView".to_string(), + RoleGrant::all_columns() + .with_aggregations() + .rows(col("status").eq("open")), + )]), + )]); + surface_for_application_contract( + &full, + "web", + &["user".into()], + &["user".into()], + &grants, + ) + .expect("role/application-selected Surface should compile") +} + +#[test] +fn explicit_module_commands_filter_allowed_and_forbidden_role_application_inventory() { + let catalog = command_catalog(); + let user_grants = grants_for("user"); + let role = surface_for_role(&catalog, "user", &user_grants).unwrap(); + assert_eq!(command_names(&role), vec!["todo.allowed"]); + + let application = surface_for_application_contract( + &catalog, + "web", + &["admin".into(), "user".into()], + &["user".into()], + &BTreeMap::from([("user".into(), user_grants)]), + ) + .unwrap(); + assert_eq!(command_names(&application), vec!["todo.allowed"]); + let spec = SurfaceSpec::from_surface("web", &application).unwrap(); + assert_eq!(spec.eligible_roles, ["admin", "user"]); + assert_eq!(spec.schema_roles, ["user"]); + assert_eq!(spec.commands.len(), 1); + assert_eq!(spec.commands[0].id, "todo.allowed"); + assert_eq!( + spec.contract["selection"], + serde_json::json!({ + "kind": "application", + "name": "web", + "eligible_roles": ["admin", "user"], + "schema_roles": ["user"], + }) + ); + let client = ContractCompiler::from_surface("command-client", "web", Arc::new(application)) + .unwrap() + .client_manifest() + .unwrap(); + match client.surface { + ClientSurfaceIdentity::Application { + eligible_roles, + schema_roles, + .. + } => { + assert_eq!(eligible_roles, ["admin", "user"]); + assert_eq!(schema_roles, ["user"]); + } + other => panic!("expected application client identity, got {other:?}"), + } +} + +#[test] +fn role_and_application_command_closure_rejects_missing_unauthorized_and_tampered_inventories() { + let module = command_module(); + let catalog = command_catalog(); + let user_grants = grants_for("user"); + let selected = surface_for_application_contract( + &catalog, + "web", + &["admin".into(), "user".into()], + &["user".into()], + &BTreeMap::from([("user".into(), user_grants.clone())]), + ) + .unwrap(); + let valid = SurfaceSpec::from_surface("web", &selected).unwrap(); + Application::try_new("valid-command-closure", [module.clone()], [valid.clone()]) + .expect("authorized command closure should compile"); + + let mut missing = valid.clone(); + missing.commands.clear(); + missing.contract["commands"] = serde_json::json!([]); + 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")); + + 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(), + ); + 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")); + + let admin = surface_for_application_contract( + &catalog, + "admin-web", + &["admin".into()], + &["admin".into()], + &BTreeMap::from([("admin".into(), grants_for("admin"))]), + ) + .unwrap(); + let admin_spec = SurfaceSpec::from_surface("web", &admin).unwrap(); + let forbidden_command = admin_spec.commands[0].clone(); + 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"] + .as_array_mut() + .expect("surface command contract array") + .push(forbidden_contract.clone()); + 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")); + + let role_surface = surface_for_role(&catalog, "user", &user_grants).unwrap(); + let role_valid = SurfaceSpec::from_surface("user", &role_surface).unwrap(); + + let mut role_unauthorized = role_valid.clone(); + role_unauthorized.commands.push(forbidden_command); + role_unauthorized + .commands + .sort_by(|left, right| left.id.cmp(&right.id)); + 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(), + ); + 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")); + + 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(), + ); + 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")); + + 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(), + ); + 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")); +} + +#[test] +fn module_identity_is_identical_across_full_and_split_selection() { + let full = application_with_commands(&["todo.create", "todo.archive"]); + let split = application_with_commands(&["todo.archive", "todo.create"]); + + assert_eq!(full.manifest().commands, split.manifest().commands); + assert_eq!( + full.manifest().canonical_bytes().unwrap(), + split.manifest().canonical_bytes().unwrap() + ); +} + +#[test] +fn application_manifest_is_byte_deterministic_and_contains_no_executable_data() { + let spec = command("todo.create"); + let mount = CommandMount::from_request_handler(spec.clone(), |request| async move { + Ok(distributed::microsvc::CommandResponse { + status: 200, + body: request.input, + }) + }); + let definition = CommandDefinition::with_mount(spec, mount).unwrap(); + let module = Module::new("todo") + .command_definition(definition) + .build() + .expect("mounted module should be valid"); + let application = Application::new("todo-app") + .module(module) + .extension( + ApplicationExtension::try_new( + "ui", + 1, + serde_json::json!({ + "default_view": "board", + "columns": ["title", "status"], + "literal_url": "https://domain.example/view", + "literal_path": "orders/today" + }), + ) + .unwrap(), + ) + .build() + .unwrap(); + + let first = application.manifest().canonical_bytes().unwrap(); + let second = application.manifest().canonical_bytes().unwrap(); + assert_eq!(first, second); + let value: serde_json::Value = serde_json::from_slice(&first).unwrap(); + assert!(value.get("schema_version").is_some()); + assert!(value["fingerprints"]["canonical"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert_eq!( + value["extensions"][0]["value"]["literal_url"], + "https://domain.example/view" + ); + assert_eq!( + value["extensions"][0]["value"]["literal_path"], + "orders/today" + ); + 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"); + let missing_version = serde_json::to_vec(&missing_version).unwrap(); + assert!(ApplicationManifest::from_canonical_bytes(&missing_version).is_err()); + + 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(); + assert!( + ApplicationManifest::from_canonical_bytes(&legacy_owner).is_err(), + "legacy logical-manifest field `{field}` must not decode" + ); + } +} + +#[test] +fn manifest_provenance_separates_logical_and_artifact_identity() { + let application = application_with_commands(&["todo.create"]); + let first = application + .manifest() + .clone() + .with_source_revision("git:one"); + let second = application + .manifest() + .clone() + .with_source_revision("git:two"); + + assert_eq!( + first.logical_fingerprint().unwrap(), + second.logical_fingerprint().unwrap() + ); + 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") + ); +} + +#[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 manifest = compiler.manifest().unwrap(); + let sdl = compiler.graphql_sdl().unwrap(); + let client = compiler.client_manifest().unwrap(); + + 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!(ContractCompiler::new("contract-only") + .with_surface("web", Arc::new(selected)) + .unwrap() + .with_surface("other", Arc::new(full_surface())) + .is_err()); + assert!(matches!( + client.surface, + ClientSurfaceIdentity::Application { ref name, .. } if name == "web" + )); +} + +#[test] +fn surface_contract_retains_policy_literals_and_rejects_stale_redundancy() { + let selected = selected_surface(); + let spec = distributed::application::SurfaceSpec::from_surface("web", &selected).unwrap(); + let row_policy = &spec.models[0].row_policy; + assert_eq!(row_policy["kind"], "predicate"); + assert!(row_policy.get("expression").is_some()); + assert_ne!(row_policy, &serde_json::json!("predicate")); + + let mut stale = spec.clone(); + stale.contract["models"][0]["table_name"] = serde_json::json!("other_table"); + stale.fingerprint = + distributed::application::sha256_fingerprint(&stale.canonical_bytes().unwrap()); + let error = Application::try_new("stale", [], [stale]).unwrap_err(); + assert!(error.to_string().contains("surface contract material")); +} + +#[test] +fn explicit_definition_mount_identity_and_missing_pairing_fail_closed() { + let spec = command("todo.create"); + 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")); + + let duplicate = Module::new("todo") + .command_definitions([definition("todo.create"), definition("todo.create")]) + .build() + .expect_err("duplicate definitions must fail closed"); + assert!(duplicate.to_string().contains("duplicate command identity")); +} + +#[test] +fn no_linker_inventory_is_needed_for_explicit_application_selection() { + let application = application_with_commands(&["todo.create"]); + assert_eq!(application.manifest().module_ids(), ["todo"]); +} + +#[test] +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!(""); + let malformed: ApplicationManifest = serde_json::from_value(value) + .expect("the malformed value should still be structurally deserializable"); + assert!(malformed.clone().refresh_fingerprints().is_err()); + assert!(ApplicationManifest::from_canonical_bytes( + &serde_json::to_vec(&serde_json::to_value(malformed).unwrap()).unwrap() + ) + .is_err()); + + 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(), + ); + let module = Module::new("todo") + .surface( + distributed::application::SurfaceSpec::from_surface("web", &selected_surface()) + .unwrap(), + ) + .projection(projection) + .build() + .unwrap(); + let error = Application::try_new("projection-owner", [module], []) + .expect_err("missing projection dependencies must fail closed"); + assert!(error.to_string().contains("missing")); +} + +#[test] +fn application_surface_must_expose_a_schema_role() { + let mut surface = + 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(), + ); + 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 new file mode 100644 index 000000000..258f92bc3 --- /dev/null +++ b/tests/application_plans.rs @@ -0,0 +1,295 @@ +//! Deployment plan compiler coverage for task 10. + +use distributed::application::{ + compile_deployment_plan, Application, CommandDefinition, CommandSpec, CommandTypeField, + CommandTypeSpec, DeploymentPlan, ModelFieldSpec, ModelSpec, Module, MountSelector, + ProcessIntent, ProcessPreset, ProjectionSpec, +}; +use distributed::graphql::CommandConsistency; + +fn portable_command(id: &str, consistency: CommandConsistency) -> CommandSpec { + let command = CommandSpec::try_new( + id, + id.replace('.', "_"), + CommandTypeSpec { + name: format!("{id}Input"), + fields: vec![CommandTypeField { + name: "title".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + CommandTypeSpec { + name: format!("{id}Output"), + fields: vec![CommandTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + consistency, + ) + .expect("portable command"); + if matches!(consistency, CommandConsistency::Atomic) { + command + .with_direct_projection( + "TodoView", + serde_json::json!({"model": "TodoView", "kind": "direct"}), + ) + .expect("atomic proof") + } else { + command + } +} + +fn definition(id: &str, consistency: CommandConsistency) -> CommandDefinition { + CommandDefinition::contract(portable_command(id, consistency)) +} + +fn todo_model() -> ModelSpec { + ModelSpec::try_new( + "TodoView", + "todos", + [ + ModelFieldSpec { + name: "todo_id".into(), + scalar: "String".into(), + nullable: false, + }, + ModelFieldSpec { + name: "title".into(), + scalar: "String".into(), + nullable: false, + }, + ], + ["todo_id"], + ) + .expect("todo model") +} + +fn plan_manifest() -> distributed::ApplicationManifest { + let module = Module::new("todo") + .command_definitions([ + definition("todo.create", CommandConsistency::Eventual), + definition("todo.force", CommandConsistency::Atomic), + ]) + .models([todo_model()]) + .projections([ + ProjectionSpec::try_new("project_todos", ["todo.created"], ["TodoView"]).unwrap(), + ProjectionSpec::try_new("project_todos_direct", ["todo.forced"], ["TodoView"]) + .unwrap() + .with_direct(true) + .unwrap(), + ]) + .build() + .expect("module should build"); + Application::new("todo-app") + .module(module) + .build() + .expect("application should build") + .manifest() + .clone() +} + +#[test] +fn full_preset_lowers_to_ordinary_mounts_and_is_byte_deterministic() { + let manifest = plan_manifest(); + let plan = compile_deployment_plan( + "local-full", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Full).unwrap()], + ) + .expect("full plan"); + assert_eq!(plan.processes.len(), 1); + let mounts = &plan.processes[0].mounts; + assert!(mounts + .iter() + .any(|m| matches!(m, MountSelector::Command { id } if id == "todo.create"))); + assert!(mounts + .iter() + .any(|m| matches!(m, MountSelector::Command { id } if id == "todo.force"))); + assert!(mounts + .iter() + .any(|m| matches!(m, MountSelector::Projector { id } if id == "project_todos"))); + assert!(mounts + .iter() + .any(|m| matches!(m, MountSelector::Projector { id } if id == "project_todos_direct"))); + + let first = plan.encode().unwrap(); + let second = compile_deployment_plan( + "local-full", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Full).unwrap()], + ) + .unwrap() + .encode() + .unwrap(); + assert_eq!(first, second); + assert_eq!( + DeploymentPlan::from_canonical_bytes(&first) + .unwrap() + .application_manifest_logical, + manifest.fingerprints.logical + ); +} + +#[test] +fn presets_and_mixed_selection_share_one_mount_algebra() { + let manifest = plan_manifest(); + let writer = ProcessPreset::Writer.expand(&manifest).unwrap(); + let projector = ProcessPreset::Projector.expand(&manifest).unwrap(); + assert!(writer + .iter() + .all(|m| matches!(m, MountSelector::Command { .. }))); + assert!(projector + .iter() + .all(|m| matches!(m, MountSelector::Projector { .. }))); + + let mixed = compile_deployment_plan( + "mixed", + &manifest, + [ProcessIntent::new("api") + .unwrap() + .mounts([ + MountSelector::command("todo.create").unwrap(), + MountSelector::projector("project_todos").unwrap(), + ]) + .remote_commands(false)], + ) + .expect("mixed plan"); + assert_eq!(mixed.processes[0].mounts.len(), 2); + assert!(mixed + .capabilities + .iter() + .any(|cap| cap.capability.as_str() == "event_store")); +} + +#[test] +fn atomic_separation_fails_and_eventual_split_succeeds() { + let manifest = plan_manifest(); + + let eventual = compile_deployment_plan( + "eventual-split", + &manifest, + [ + ProcessIntent::new("writer") + .unwrap() + .mounts([MountSelector::command("todo.create").unwrap()]), + ProcessIntent::new("projectors") + .unwrap() + .mounts([MountSelector::projector("project_todos").unwrap()]), + ], + ); + assert!(eventual.is_ok(), "{eventual:?}"); + + let atomic_split = compile_deployment_plan( + "atomic-split", + &manifest, + [ + ProcessIntent::new("writer") + .unwrap() + .mounts([MountSelector::command("todo.force").unwrap()]), + ProcessIntent::new("projectors") + .unwrap() + .mounts([MountSelector::projector("project_todos_direct").unwrap()]), + ], + ); + assert!( + atomic_split.is_err(), + "atomic command must not separate from direct projection" + ); + let message = atomic_split.unwrap_err().to_string(); + assert!( + message.contains("todo.force") && message.to_lowercase().contains("collocat"), + "{message}" + ); + + let collocated = compile_deployment_plan( + "atomic-local", + &manifest, + [ProcessIntent::new("writer") + .unwrap() + .mounts([ + MountSelector::command("todo.force").unwrap(), + MountSelector::projector("project_todos_direct").unwrap(), + ])], + ); + assert!(collocated.is_ok(), "{collocated:?}"); +} + +#[test] +fn default_empty_process_list_expands_to_full_local() { + let manifest = plan_manifest(); + let plan = compile_deployment_plan("default", &manifest, []).expect("default full"); + assert_eq!(plan.processes.len(), 1); + assert_eq!(plan.processes[0].id, "full"); + assert!(!plan.processes[0].remote_commands); +} + +#[test] +fn unknown_mount_and_duplicate_process_fail_closed() { + let manifest = plan_manifest(); + let missing = compile_deployment_plan( + "missing", + &manifest, + [ProcessIntent::new("p") + .unwrap() + .mounts([MountSelector::command("todo.missing").unwrap()])], + ); + assert!(missing.is_err()); + + let duplicate = compile_deployment_plan( + "dup", + &manifest, + [ + ProcessIntent::with_preset("same", &manifest, ProcessPreset::Writer).unwrap(), + ProcessIntent::with_preset("same", &manifest, ProcessPreset::Projector).unwrap(), + ], + ); + assert!(duplicate.is_err()); +} + +#[test] +fn capability_closure_is_explained_and_schema_has_one_owner() { + let manifest = plan_manifest(); + let plan = compile_deployment_plan( + "caps", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Full).unwrap()], + ) + .unwrap(); + assert!(plan.schema_lifecycle.required); + assert_eq!( + plan.schema_lifecycle.logical_owner.as_deref(), + Some("todo-app") + ); + assert!(plan.capabilities.iter().any(|cap| !cap.reasons.is_empty())); + let described = plan.describe(); + assert_eq!(described["application"], "todo-app"); + assert!(!described["capabilities"].as_array().unwrap().is_empty()); +} + +#[test] +fn stale_manifest_predecessor_is_retained_exactly() { + let manifest = plan_manifest(); + let plan = compile_deployment_plan( + "pred", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Full).unwrap()], + ) + .unwrap(); + assert_eq!( + plan.application_manifest_logical, + manifest.fingerprints.logical + ); + assert_eq!( + plan.application_manifest_canonical, + manifest.fingerprints.canonical + ); +} diff --git a/tests/e2e-ui/.gitignore b/tests/e2e-ui/.gitignore index 0e859a8be..4849ea6f2 100644 --- a/tests/e2e-ui/.gitignore +++ b/tests/e2e-ui/.gitignore @@ -14,3 +14,4 @@ e2e/.auth playwright-report test-results blob-report +gitops/cluster/e2e-identity/grants.yaml diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 7595b8515..0427d580c 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -6,8 +6,8 @@ # make test-browser # Playwright UI e2e (needs make up + make run) .PHONY: all up down run run-api stop test ci-offline test-domain test-suite \ - test-browser test-browser-install js-install js-build ui-install ui-build ui-check ui-test \ - gen-client check-client check clean help + test-browser test-browser-install js-install js-build wasm ui-install ui-build ui-check ui-test \ + gen-client check-client contracts-check check clean help # Defaults only — do NOT `include e2e-ui.env` (shell-quoted dotenv breaks Make). # Recipes `source` the env file so values stay clean. @@ -118,7 +118,7 @@ ci-offline: ui-install echo "OK — generated drift + offline Rust/UI suites" test-domain: - cargo test -p todo-domain -p chat-domain $(CARGO_TEST_FLAGS) + cargo test -p todo-domain -p chat-domain -p blob-domain $(CARGO_TEST_FLAGS) test-suite: cargo test -p e2e-suite --test behavioral $(CARGO_TEST_FLAGS) @@ -140,7 +140,15 @@ js-install: js-build: js-install cd $(JS_DIR) && $(NPM) run build -ui-install: js-build +## blob-domain pure core → ui/src/lib/blob/pkg (wasm-pack; requires wasm32-unknown-unknown). +wasm: + @command -v wasm-pack >/dev/null || { echo "wasm-pack required: cargo install wasm-pack"; exit 1; } + @rustup target list --installed | grep -q wasm32-unknown-unknown || rustup target add wasm32-unknown-unknown + @out="$(CURDIR)/ui/src/lib/blob/pkg"; \ + wasm-pack build crates/blob-domain --target web --out-dir "$$out" --out-name blob_wasm \ + -- --no-default-features --features wasm + +ui-install: js-build wasm cd ui && $(NPM) install ui-build: ui-install @@ -157,19 +165,27 @@ ui-test: ui-install gen-client: ui-install cd ui && $(NPM) run client:generate -## Byte/file-set drift check through dctl --check; never repairs generated files. +## Byte/file-set drift check through distributed --check; never repairs generated files. check-client: ui-install cd ui && $(NPM) run client:check +## Aggregate read-only contract check (repo root catalog when present). +contracts-check: + @if [ -f ../../contracts/catalog.json ] || [ -f contracts/catalog.json ]; then \ + $(MAKE) -C ../.. contracts-check; \ + else \ + echo "contracts-check: no catalog yet; skipping aggregate gate"; \ + fi + check: check-client cargo check --workspace - cargo test -p todo-domain -p chat-domain --no-run + cargo test -p todo-domain -p chat-domain -p blob-domain --no-run cargo test -p e2e-suite --test behavioral --no-run clean: stop cargo clean rm -f .make-runner.log e2e-ui.db - rm -rf ui/node_modules ui/build ui/.svelte-kit + rm -rf ui/node_modules ui/build ui/.svelte-kit ui/src/lib/blob/pkg help: @echo "e2e-ui" @@ -177,6 +193,7 @@ help: @echo " make run API + UI (source e2e-ui.env when present)" @echo " make test offline suite + UI structural" @echo " make ci-offline CI drift + offline suites with safe pipeline overlap" + @echo " make wasm blob-domain core → ui/src/lib/blob/pkg (wasm-pack)" @echo " make gen-client typed Service → generated user/admin clients" @echo " make check-client verify generated artifacts byte-for-byte" @echo " make down docker compose down" diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index 308b2662b..599c06bc2 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -4,6 +4,32 @@ A copyable Distributed service and SvelteKit UI demonstrating one modeled projection from aggregate transition to server read model, generated GraphQL client, optimistic replica update, and causal confirmation. +## Option A — local cluster + workspace GitOps + +One-time: start the kind control plane on Dory's Docker engine. Then run the +two GitOps processes in separate terminals: + +```bash +cd tests/e2e-ui +hops local gitops cluster ./gitops/cluster \ + --cluster-provider kind --docker-provider dory \ + --cluster-name hops --context kind-hops + +hops local gitops worktree ./gitops/envs/local --name e2e \ + --cluster-provider kind --docker-provider dory \ + --cluster-name hops --context kind-hops +``` + +Both commands watch by default; use `--once` for CI or a single diagnostic +reconcile. Platform XRs and application workloads must be changed in their +respective GitOps trees, not applied over the watcher with `kubectl`. + +Charts: `api/.gitops/deploy`, `ui/.gitops/deploy`. +App Applications: `gitops/envs/local/`. +Control plane: `gitops/cluster/` (`stacks/`, `configurations/`, …). + +## Option B — compose + host processes + ```bash cd tests/e2e-ui make up @@ -70,20 +96,19 @@ projection! { } ``` -Command registration declares the domain events this command may emit and the -**known mutation input** used for client cache application (not a separate -hand-built cache path): +Command registration binds a **domain transition** (emit set) and lets the +framework auto-derive client cache previews from input + defaults + claims +(not a separate hand-built mapping): ```rust -typed_command::>("todo.complete") - .emits(events![TodoCompletedDomainEvent]) - .applies(state_preview! { - TodoCompletedDomainEvent => TodoState { - todo_id: input.todo_id, - status: "completed", - ..unknown - } - }) +.command_transition::< + domain_commands::Complete, + TodoCompleteInput, + Eventual, +>("todo.complete") +.field_name("todos_complete") +.roles(["user", "admin"]) +.handle(todo_complete::handle) ``` The compiler specializes `TODOS` into safe client operations: apply the same @@ -143,8 +168,8 @@ differs on purpose — do **not** collapse them into “always send a causal del | Contract | Apply site | Mutation response (ship) | Client seal | |---|---|---|---| -| **`Eventual`** + Eventual | Event handler after commit | Payload + **projection-delta** + `expects` | `.applies` preview; retire on obligations | -| **`Atomic`** + Direct | Command handler, same tx | **Typed row `M`** + direct **`records[]`**. No eventual modeled metadata, empty `expects` | Optional `.applies`; **`confirmDirectProjection(row, records)`** before `await` settles | +| **`Eventual`** + Eventual | Event handler after commit | Payload + **projection-delta** + `expects` | Auto-optimism preview; retire on obligations | +| **`Atomic`** + Direct | Command handler, same tx | **Typed row `M`** + direct **`records[]`**. No eventual modeled metadata, empty `expects` | Auto-optimism when input known; **`confirmDirectProjection(row, records)`** before `await` settles | Handler for Atomic — this *is* returning atomic read-model updates: @@ -192,8 +217,9 @@ programs; the same IR lowers on the server and for role-safe client cache optimism. Multi-model atomicity is expressed as multi-op mutation programs, not a public projector ORM workspace. -Application commands declare `.emits` plus `.applies(...)` known mutation-input -mapping for client cache application. Both Eventual and Direct surfaces may +Application commands use `command_transition` so the emit set comes from the +domain (`domain_commands::*`); client cache previews are auto-derived from +input, defaults, and row-policy claims. Both Eventual and Direct surfaces may export those previews from the portable program. Eventual commands do not stage rows in the handler (the event handler applies the mutation later). Blob stages the mutation-derived row with `readmodel(row).commit()?.projected()` so the diff --git a/tests/e2e-ui/api/.gitops/deploy/Chart.yaml b/tests/e2e-ui/api/.gitops/deploy/Chart.yaml new file mode 100644 index 000000000..fde17c038 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: e2e-ui-api +description: Local workbench dogfood chart for e2e-ui API (cluster-dev / package postures) +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/tests/e2e-ui/api/.gitops/deploy/README.md b/tests/e2e-ui/api/.gitops/deploy/README.md new file mode 100644 index 000000000..f2d8964b0 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/README.md @@ -0,0 +1,16 @@ +# e2e-ui API chart (workbench) + +Renders: + +- **Deployment / Service** (`appRuntime: cluster-dev` \| `package`) +- **Optional PSQLCluster** when `database.enabled: true` + +OIDC project / demo humans / web app live on the **UI** chart +(`ui/.gitops/deploy` `identity.*`). When `identity.enabled`, this chart derives +the same generation-specific OIDC connection Secret from the workspace +namespace. Both `OIDC_AUDIENCE` and `OIDC_CLIENT_ID` read its generated +`attribute.client_id`; only the service-user token remains in the residual +`e2e-ui-oidc` Secret. Keep `identity.oidcGeneration` aligned with the UI +Application so an OIDC rotation rolls both workloads onto matching credentials. + +Platform AuthStack + ProviderConfig stay under `gitops/cluster/`. diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/_helpers.tpl b/tests/e2e-ui/api/.gitops/deploy/templates/_helpers.tpl new file mode 100644 index 000000000..849d8c8f5 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/_helpers.tpl @@ -0,0 +1,44 @@ +{{- define "e2e-ui-api.name" -}} +{{- default .Chart.Name .Values.name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "e2e-ui-api.labels" -}} +app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "e2e-ui-api.workspace" -}} +{{- coalesce .Values.namespace .Release.Namespace "default" -}} +{{- end -}} + +{{/* Must match the UI chart's OIDC connection Secret helper. */}} +{{- define "e2e-ui-api.oidcConnectionSecretName" -}} +{{- if .Values.identity.connectionSecretName -}} +{{- .Values.identity.connectionSecretName | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $prefix := printf "e2e-ui-%s" (include "e2e-ui-api.workspace" .) -}} +{{- $generation := int (.Values.identity.oidcGeneration | default 0) -}} +{{- if gt $generation 0 -}} +{{- printf "%s-oidc-conn-g%d" $prefix $generation | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-oidc-conn" $prefix | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "e2e-ui-api.oidcClientEnv" -}} +{{- if .Values.identity.enabled }} +{{- $secret := include "e2e-ui-api.oidcConnectionSecretName" . }} +- name: OIDC_AUDIENCE + valueFrom: + secretKeyRef: + name: {{ $secret | quote }} + key: attribute.client_id +- name: OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ $secret | quote }} + key: attribute.client_id +{{- end }} +{{- end -}} diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/deployment-cluster-dev.yaml b/tests/e2e-ui/api/.gitops/deploy/templates/deployment-cluster-dev.yaml new file mode 100644 index 000000000..68e1614b9 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/deployment-cluster-dev.yaml @@ -0,0 +1,95 @@ +{{- if or (eq .Values.appRuntime "cluster-dev") (and .Values.local (eq .Values.appRuntime "cluster-dev")) }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "e2e-ui-api.name" . }} + labels: + {{- include "e2e-ui-api.labels" . | nindent 4 }} + hops.ops.com.ai/runtime: cluster-dev +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + template: + metadata: + annotations: + hops.ops.com.ai/source-generation: {{ .Values.clusterDev.sourceGeneration | default 0 | quote }} + {{- if .Values.identity.enabled }} + hops.ops.com.ai/oidc-generation: {{ .Values.identity.oidcGeneration | default 0 | quote }} + {{- end }} + labels: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + hops.ops.com.ai/runtime: cluster-dev + spec: + containers: + - name: api + image: {{ .Values.clusterDev.image | quote }} + workingDir: {{ .Values.clusterDev.workingDir | quote }} + {{- if eq .Values.sourceDelivery.mode "sync" }} + command: + - /bin/bash + - -c + - | + set -e + echo "waiting for source sync at {{ .Values.sourceDelivery.mountPath }}..." + waited=0 + while [ ! -f {{ .Values.sourceDelivery.mountPath }}/.hops-synced ] \ + && [ ! -f {{ .Values.clusterDev.workingDir }}/Cargo.toml ]; do + sleep 2 + waited=$((waited + 2)) + if [ $((waited % 30)) -eq 0 ]; then + echo "still waiting for hops source sync (${waited}s) — is tar watcher running?" + fi + if [ "$waited" -ge 600 ]; then + echo "ERROR: source sync timeout after ${waited}s (no .hops-synced / Cargo.toml)" + exit 1 + fi + done + echo "source present; starting dev process" + command -v cargo + rustc --version + cd {{ .Values.clusterDev.workingDir }} + cargo run -p e2e-runner --bin e2e-ui + {{- else }} + command: + {{- toYaml .Values.clusterDev.command | nindent 12 }} + {{- end }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http + env: + {{- include "e2e-ui-api.oidcClientEnv" . | nindent 12 }} + {{- range $k, $v := .Values.env }} + {{- if and $v (ne $v "") }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- end }} + {{- range $k, $ref := .Values.secretEnv }} + - name: {{ $k }} + valueFrom: + secretKeyRef: + name: {{ $ref.secretName | quote }} + key: {{ $ref.key | quote }} + {{- end }} + # Keep cargo on PATH even if something mangles the environment. + - name: PATH + value: "/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + volumeMounts: + - name: source + mountPath: {{ .Values.sourceDelivery.mountPath }} + - name: cargo-target + mountPath: {{ .Values.clusterDev.workingDir }}/target + volumes: + - name: source + {{- if eq .Values.sourceDelivery.mode "hostPath" }} + hostPath: + path: {{ .Values.sourceDelivery.hostPath | quote }} + type: Directory + {{- else }} + emptyDir: {} + {{- end }} + - name: cargo-target + emptyDir: {} +{{- end }} diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/deployment-package.yaml b/tests/e2e-ui/api/.gitops/deploy/templates/deployment-package.yaml new file mode 100644 index 000000000..679bb3a95 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/deployment-package.yaml @@ -0,0 +1,31 @@ +{{- /* Package Deployment: production/CI image posture. Omitted for local/cluster-dev. */ -}} +{{- if and (not .Values.local) (ne .Values.appRuntime "cluster-dev") (ne .Values.appRuntime "host") }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "e2e-ui-api.name" . }} + labels: + {{- include "e2e-ui-api.labels" . | nindent 4 }} + hops.ops.com.ai/runtime: package +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + hops.ops.com.ai/runtime: package + spec: + containers: + - name: api + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http + env: + - name: BIND + value: {{ .Values.env.BIND | quote }} +{{- end }} diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/psqlcluster.yaml b/tests/e2e-ui/api/.gitops/deploy/templates/psqlcluster.yaml new file mode 100644 index 000000000..12eb9f56f --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/psqlcluster.yaml @@ -0,0 +1,15 @@ +{{- if and .Values.database.enabled (or .Values.local (eq .Values.appRuntime "cluster-dev") (eq .Values.appRuntime "host")) }} +# Local-capable PSQLCluster XR. Storage class defaults to values.database.storage.class +# (local-path for k3s/dory; override for other provisioners). +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLCluster +metadata: + name: {{ .Values.database.name }} + labels: + {{- include "e2e-ui-api.labels" . | nindent 4 }} +spec: + clusterName: {{ .Values.database.clusterName }} + storage: + size: {{ .Values.database.storage.size }} + class: {{ .Values.database.storage.class }} +{{- end }} diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/service.yaml b/tests/e2e-ui/api/.gitops/deploy/templates/service.yaml new file mode 100644 index 000000000..b6ff98336 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "e2e-ui-api.name" . }} + labels: + {{- include "e2e-ui-api.labels" . | nindent 4 }} +spec: + selector: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/tests/e2e-ui/api/.gitops/deploy/values.yaml b/tests/e2e-ui/api/.gitops/deploy/values.yaml new file mode 100644 index 000000000..eb034ac0d --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/values.yaml @@ -0,0 +1,74 @@ +# Local workbench values for e2e-ui API. +# Storage class for PSQLCluster: override per local CP (e.g. local-path, hostpath, psql). + +local: false +appRuntime: package # host | cluster-dev | package + +name: e2e-ui-api +image: + repository: ghcr.io/hops-ops/e2e-ui-api + tag: latest + pullPolicy: IfNotPresent + +# Source tree is the *distributed* monorepo root; API Cargo workspace is tests/e2e-ui. +clusterDev: + image: rust:1.94-bookworm + # Bump in the local Application after host-mounted Rust/framework changes. + # This stays declarative while restarting the one-shot cargo process. + sourceGeneration: 0 + # bash -c keeps Docker ENV (PATH includes /usr/local/cargo/bin). -lc can drop it. + command: + - /bin/bash + - -c + - | + set -e + cd /workspace/tests/e2e-ui + cargo run -p e2e-runner --bin e2e-ui + workingDir: /workspace/tests/e2e-ui + sourcePath: /workspace + +# The API validates Zitadel access tokens against the generated OIDC client. +# Keep this generation aligned with the UI chart's identity.oidcGeneration. +identity: + enabled: false + oidcGeneration: 0 + connectionSecretName: "" + +service: + port: 8791 + targetPort: 8791 + +env: + BIND: "0.0.0.0:8791" + # Offline-friendly default until PSQL platform pack is installed. + DATABASE_URL: "sqlite:/tmp/e2e-ui.db?mode=rwc" + # OidcBearer — requires issuer **and** audience (project id). If either is empty + # the API falls back to DevHeaders and live user surfaces misbehave. + OIDC_ISSUER: "http://zitadel-zitadel.auth.svc.cluster.local:8080" + # Residual after Zitadel Project Ready (external-name / status.atProvider.id). + # Override from Application values (gitops/envs/local/api.yaml) for local dogfood. + OIDC_AUDIENCE: "" + OIDC_JWKS_URI: "http://zitadel-zitadel.auth.svc.cluster.local:8080/oauth/v2/keys" + ZITADEL_PROJECT_ID: "" + +# Residual secrets (same e2e-ui-oidc object as the UI chart, in app namespace). +# OIDC app + humans live on the UI chart (identity.*); API only consumes tokens. +secretEnv: + ZITADEL_SERVICE_USER_TOKEN: + secretName: e2e-ui-oidc + key: ZITADEL_SERVICE_USER_TOKEN + +database: + # Dogfood local CP often lacks PSQLCluster CRD; disable by default for up path. + # Enable when PSQL pack is installed: --set database.enabled=true via Application values. + enabled: false + name: e2e-ui-api-psql + clusterName: local + storage: + size: 2Gi + class: local-path + +sourceDelivery: + mode: none # none | hostPath | sync + hostPath: "" + mountPath: /workspace diff --git a/tests/e2e-ui/contracts/application-manifest.placeholder.json b/tests/e2e-ui/contracts/application-manifest.placeholder.json new file mode 100644 index 000000000..333e6c6bf --- /dev/null +++ b/tests/e2e-ui/contracts/application-manifest.placeholder.json @@ -0,0 +1,15 @@ +{ + "name": "e2e-ui", + "modules": [ + "todo", + "chat", + "blob", + "identity" + ], + "surfaces": [ + "e2e-ui", + "e2e-ui-admin", + "e2e-ui-public" + ], + "note": "Canonical ApplicationManifest bytes are produced by the application composition compiler; this inventory documents selectable process cuts for task 13." +} diff --git a/tests/e2e-ui/contracts/deployment-plans/api-only.json b/tests/e2e-ui/contracts/deployment-plans/api-only.json new file mode 100644 index 000000000..7aadc5d1c --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/api-only.json @@ -0,0 +1,11 @@ +{ + "name": "e2e-ui-api", + "application": "e2e-ui", + "processes": [ + { + "id": "api", + "preset": "query_api", + "remote_commands": true + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/full.json b/tests/e2e-ui/contracts/deployment-plans/full.json new file mode 100644 index 000000000..cb3134d01 --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/full.json @@ -0,0 +1,11 @@ +{ + "name": "e2e-ui-full", + "application": "e2e-ui", + "processes": [ + { + "id": "full", + "preset": "full", + "remote_commands": false + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/mixed.json b/tests/e2e-ui/contracts/deployment-plans/mixed.json new file mode 100644 index 000000000..325ac87ab --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/mixed.json @@ -0,0 +1,19 @@ +{ + "name": "e2e-ui-mixed", + "application": "e2e-ui", + "processes": [ + { + "id": "mixed", + "mounts": [ + { + "kind": "command", + "id": "todo.create" + }, + { + "kind": "surface", + "id": "e2e-ui" + } + ] + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/projector-todo-chat.json b/tests/e2e-ui/contracts/deployment-plans/projector-todo-chat.json new file mode 100644 index 000000000..0f6b1e0e2 --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/projector-todo-chat.json @@ -0,0 +1,19 @@ +{ + "name": "e2e-ui-projectors", + "application": "e2e-ui", + "processes": [ + { + "id": "projectors", + "mounts": [ + { + "kind": "projector", + "id": "project_todos" + }, + { + "kind": "projector", + "id": "project_chat" + } + ] + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/split.json b/tests/e2e-ui/contracts/deployment-plans/split.json new file mode 100644 index 000000000..e6f6cdb1a --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/split.json @@ -0,0 +1,29 @@ +{ + "name": "e2e-ui-split", + "application": "e2e-ui", + "processes": [ + { + "id": "api", + "preset": "query_api", + "remote_commands": true + }, + { + "id": "todo-writer", + "mounts": [ + { + "kind": "command", + "id": "todo.create" + } + ] + }, + { + "id": "projectors", + "mounts": [ + { + "kind": "projector", + "id": "project_todos" + } + ] + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/writer-todo.json b/tests/e2e-ui/contracts/deployment-plans/writer-todo.json new file mode 100644 index 000000000..b7bd2bde4 --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/writer-todo.json @@ -0,0 +1,15 @@ +{ + "name": "e2e-ui-writer-todo", + "application": "e2e-ui", + "processes": [ + { + "id": "todo-writer", + "mounts": [ + { + "kind": "command", + "id": "todo.create" + } + ] + } + ] +} diff --git a/tests/e2e-ui/crates/blob-domain/Cargo.toml b/tests/e2e-ui/crates/blob-domain/Cargo.toml index c6b22f070..d7ef74f5f 100644 --- a/tests/e2e-ui/crates/blob-domain/Cargo.toml +++ b/tests/e2e-ui/crates/blob-domain/Cargo.toml @@ -4,13 +4,25 @@ version.workspace = true edition.workspace = true license.workspace = true publish.workspace = true +description = "Blob game domain: pure board core, aggregate host, optional WASM export" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["domain"] +# Aggregate + levels + distributed host (server / tests). +domain = ["dep:distributed", "dep:thiserror", "dep:rand"] +# Client pure export (wasm-pack / wasm32). +wasm = ["dep:wasm-bindgen"] [dependencies] -distributed = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -thiserror = { workspace = true } -rand = { workspace = true } +distributed = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +rand = { workspace = true, optional = true } +wasm-bindgen = { version = "0.2", optional = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/tests/e2e-ui/crates/blob-domain/src/core/direction.rs b/tests/e2e-ui/crates/blob-domain/src/core/direction.rs new file mode 100644 index 000000000..c2f35d8c3 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/core/direction.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + Up, + Down, + Left, + Right, +} + +impl Direction { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "up" => Some(Self::Up), + "down" => Some(Self::Down), + "left" => Some(Self::Left), + "right" => Some(Self::Right), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Up => "up", + Self::Down => "down", + Self::Left => "left", + Self::Right => "right", + } + } +} diff --git a/tests/e2e-ui/crates/blob-domain/src/core/mod.rs b/tests/e2e-ui/crates/blob-domain/src/core/mod.rs new file mode 100644 index 000000000..a2b14e634 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/core/mod.rs @@ -0,0 +1,10 @@ +//! Pure board rules — no I/O, no `distributed`, WASM-eligible. +//! +//! Shared by the aggregate host (`models`) and the client WASM surface (`wasm`). + +mod direction; +mod simulate; +pub mod tile; + +pub use direction::Direction; +pub use simulate::{simulate_move, MovePreview, SimulateError}; diff --git a/tests/e2e-ui/crates/blob-domain/src/core/simulate.rs b/tests/e2e-ui/crates/blob-domain/src/core/simulate.rs new file mode 100644 index 000000000..240e8be9b --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/core/simulate.rs @@ -0,0 +1,146 @@ +//! Pure post-move board snapshot. + +use super::tile; +use super::Direction; + +/// Pure post-move board snapshot (no ownership / aggregate checks). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MovePreview { + pub map: Vec>, + pub score: i64, + pub player_dead: bool, + pub level_complete: bool, +} + +impl MovePreview { + pub fn status(&self) -> String { + if self.player_dead { + "dead".into() + } else if self.level_complete { + "level_complete".into() + } else { + "active".into() + } + } +} + +/// Failures from pure board simulation (fail-closed on the client). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SimulateError { + NoActiveLevel, + CannotMove(&'static str), +} + +/// Apply one direction to a map + score. +pub fn simulate_move( + map: &[Vec], + score: i64, + direction: Direction, +) -> Result { + if map.is_empty() || map[0].is_empty() { + return Err(SimulateError::NoActiveLevel); + } + let (r, c) = player_pos_in(map)?; + let (nr, nc) = match direction { + Direction::Up => { + if r == 0 { + return Err(SimulateError::CannotMove("row already 0")); + } + (r - 1, c) + } + Direction::Down => { + if r + 1 >= map.len() { + return Err(SimulateError::CannotMove("already at bottom edge")); + } + (r + 1, c) + } + Direction::Left => { + if c == 0 { + return Err(SimulateError::CannotMove("column already 0")); + } + (r, c - 1) + } + Direction::Right => { + if c + 1 >= map[r].len() { + return Err(SimulateError::CannotMove("already at right edge")); + } + (r, c + 1) + } + }; + + let mut next_map = map.to_vec(); + let mut score = score; + let mut player_dead = false; + let mut level_complete = false; + + next_map[r][c] = tile::VISITED; + match next_map[nr][nc] { + tile::HOLE => next_map[nr][nc] = tile::DEAD_BY_HOLE, + tile::VISITED => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, + tile::UNVISITED | tile::PLAYER => { + score += 1; + next_map[nr][nc] = tile::PLAYER; + } + _ => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, + } + for row in &next_map { + if row.contains(&tile::DEAD_BY_HOLE) || row.contains(&tile::DEAD_BY_SUICIDE) { + player_dead = true; + level_complete = false; + break; + } + } + if !player_dead { + let any_u = next_map.iter().any(|row| row.contains(&tile::UNVISITED)); + level_complete = !any_u; + } + + Ok(MovePreview { + map: next_map, + score, + player_dead, + level_complete, + }) +} + +fn player_pos_in(map: &[Vec]) -> Result<(usize, usize), SimulateError> { + for (r, row) in map.iter().enumerate() { + for (c, &t) in row.iter().enumerate() { + if t == tile::PLAYER { + return Ok((r, c)); + } + } + } + Err(SimulateError::NoActiveLevel) +} + +#[cfg(test)] +mod tests { + use super::*; + use super::tile::*; + + fn tiny() -> Vec> { + vec![ + vec![PLAYER, UNVISITED, UNVISITED], + vec![UNVISITED, UNVISITED, UNVISITED], + vec![UNVISITED, UNVISITED, UNVISITED], + ] + } + + #[test] + fn move_right_increments_score() { + let preview = simulate_move(&tiny(), 0, Direction::Right).unwrap(); + assert_eq!(preview.score, 1); + assert!(!preview.player_dead); + assert_eq!(preview.map[0][0], VISITED); + assert_eq!(preview.map[0][1], PLAYER); + } + + #[test] + fn edge_fails_closed() { + assert!(matches!( + simulate_move(&tiny(), 0, Direction::Up), + Err(SimulateError::CannotMove(_)) + )); + } +} diff --git a/tests/e2e-ui/crates/blob-domain/src/core/tile.rs b/tests/e2e-ui/crates/blob-domain/src/core/tile.rs new file mode 100644 index 000000000..9b2cb17fd --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/core/tile.rs @@ -0,0 +1,8 @@ +//! Canonical tile values (client board + domain parity). + +pub const HOLE: u8 = 0; +pub const UNVISITED: u8 = 1; +pub const VISITED: u8 = 2; +pub const DEAD_BY_SUICIDE: u8 = 3; +pub const DEAD_BY_HOLE: u8 = 4; +pub const PLAYER: u8 = 9; diff --git a/tests/e2e-ui/crates/blob-domain/src/lib.rs b/tests/e2e-ui/crates/blob-domain/src/lib.rs index ed98c039b..f71a4b4a1 100644 --- a/tests/e2e-ui/crates/blob-domain/src/lib.rs +++ b/tests/e2e-ui/crates/blob-domain/src/lib.rs @@ -1,15 +1,28 @@ -//! BlobGame aggregate — grid trail game (remake of ig-blob-game-model-service). +//! Blob game domain — one crate, three faces: //! -//! Tile ints match JS `constants.ts` (player=9, hole=0, unvisited=1, visited=2, -//! dead_by_suicide=3, dead_by_hole=4). Read models update only from emitted facts. +//! - [`core`] — pure board rules (always available; WASM-eligible) +//! - [`models`] / [`levels`] — aggregate host (`feature = "domain"`, default) +//! - [`wasm`] — `blobSimulateMove` for the client (`feature = "wasm"`) +//! +//! Tile ints match the client board helpers (player=9, hole=0, unvisited=1, …). + +pub mod core; +#[cfg(feature = "domain")] pub mod levels; +#[cfg(feature = "domain")] pub mod models; +#[cfg(feature = "wasm")] +pub mod wasm; + +pub use core::{simulate_move, tile, Direction, MovePreview, SimulateError}; + +#[cfg(feature = "domain")] pub use levels::{demo_map, generate_level, generate_level_with, is_hamiltonian_passable}; -pub use models::tile; +#[cfg(feature = "domain")] pub use models::{ - simulate_move, test_map_no_holes, test_map_with_hole, BlobError, BlobGame, BlobGameState, + domain_commands, test_map_no_holes, test_map_with_hole, BlobError, BlobGame, BlobGameState, BlobInitializedDomainEvent, BlobLevelStartedDomainEvent, BlobMovedDomainEvent, - BlobStartedDomainEvent, Direction, MovePreview, + BlobStartedDomainEvent, }; diff --git a/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs b/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs index df0748f3c..e15f1ae4b 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs @@ -63,97 +63,17 @@ fn status_of(player_dead: bool, level_complete: bool) -> String { } } -/// Pure post-move board snapshot (no ownership / aggregate checks). -/// -/// Shared by the aggregate and client-side optimistic preview (TypeScript port -/// in e2e-ui must stay byte-identical for tile rules). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MovePreview { - pub map: Vec>, - pub score: i64, - pub player_dead: bool, - pub level_complete: bool, -} +// Pure post-move board snapshot — defined in `crate::core`, re-exported here. +pub use crate::core::{simulate_move, MovePreview}; -impl MovePreview { - pub fn status(&self) -> String { - status_of(self.player_dead, self.level_complete) +fn map_simulate_err(err: crate::core::SimulateError) -> BlobError { + match err { + crate::core::SimulateError::NoActiveLevel => BlobError::NoActiveLevel, + crate::core::SimulateError::CannotMove(msg) => BlobError::CannotMove(msg.into()), } } -/// Apply one direction to a map + score. Pure — used by [`BlobGame::move_dir`] -/// and mirrored in the e2e-ui optimistic board sim. -pub fn simulate_move( - map: &[Vec], - score: i64, - direction: Direction, -) -> Result { - if map.is_empty() || map[0].is_empty() { - return Err(BlobError::NoActiveLevel); - } - let (r, c) = player_pos_in(map)?; - let (nr, nc) = match direction { - Direction::Up => { - if r == 0 { - return Err(BlobError::CannotMove("row already 0".into())); - } - (r - 1, c) - } - Direction::Down => { - if r + 1 >= map.len() { - return Err(BlobError::CannotMove("already at bottom edge".into())); - } - (r + 1, c) - } - Direction::Left => { - if c == 0 { - return Err(BlobError::CannotMove("column already 0".into())); - } - (r, c - 1) - } - Direction::Right => { - if c + 1 >= map[r].len() { - return Err(BlobError::CannotMove("already at right edge".into())); - } - (r, c + 1) - } - }; - - let mut next_map = map.to_vec(); - let mut score = score; - let mut player_dead = false; - let mut level_complete = false; - - next_map[r][c] = tile::VISITED; - match next_map[nr][nc] { - tile::HOLE => next_map[nr][nc] = tile::DEAD_BY_HOLE, - tile::VISITED => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, - tile::UNVISITED | tile::PLAYER => { - score += 1; - next_map[nr][nc] = tile::PLAYER; - } - _ => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, - } - for row in &next_map { - if row.contains(&tile::DEAD_BY_HOLE) || row.contains(&tile::DEAD_BY_SUICIDE) { - player_dead = true; - level_complete = false; - break; - } - } - if !player_dead { - let any_u = next_map.iter().any(|row| row.contains(&tile::UNVISITED)); - level_complete = !any_u; - } - - Ok(MovePreview { - map: next_map, - score, - player_dead, - level_complete, - }) -} - +#[cfg(test)] fn player_pos_in(map: &[Vec]) -> Result<(usize, usize), BlobError> { for (r, row) in map.iter().enumerate() { for (c, &t) in row.iter().enumerate() { @@ -343,7 +263,7 @@ impl BlobGame { if self.current_level == 0 || self.map.is_empty() { return Err(BlobError::NoActiveLevel); } - let preview = simulate_move(&self.map, self.score, direction)?; + let preview = simulate_move(&self.map, self.score, direction).map_err(map_simulate_err)?; self.record_moved( preview.score, preview.player_dead, diff --git a/tests/e2e-ui/crates/blob-domain/src/models/direction.rs b/tests/e2e-ui/crates/blob-domain/src/models/direction.rs index c2f35d8c3..85323ef2a 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/direction.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/direction.rs @@ -1,31 +1,3 @@ -use serde::{Deserialize, Serialize}; +//! Re-export pure direction from [`crate::core`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Direction { - Up, - Down, - Left, - Right, -} - -impl Direction { - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "up" => Some(Self::Up), - "down" => Some(Self::Down), - "left" => Some(Self::Left), - "right" => Some(Self::Right), - _ => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Up => "up", - Self::Down => "down", - Self::Left => "left", - Self::Right => "right", - } - } -} +pub use crate::core::Direction; diff --git a/tests/e2e-ui/crates/blob-domain/src/models/mod.rs b/tests/e2e-ui/crates/blob-domain/src/models/mod.rs index e0316c2cc..a2f7ea520 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/mod.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/mod.rs @@ -8,7 +8,7 @@ pub mod tile; pub use blob_error::BlobError; pub use blob_game::{ - simulate_move, test_map_no_holes, test_map_with_hole, BlobGame, MovePreview, + domain_commands, simulate_move, test_map_no_holes, test_map_with_hole, BlobGame, MovePreview, BlobGameInitializedDomainEvent as BlobInitializedDomainEvent, BlobGameLevelStartedDomainEvent as BlobLevelStartedDomainEvent, BlobGameMovedDomainEvent as BlobMovedDomainEvent, diff --git a/tests/e2e-ui/crates/blob-domain/src/models/tile.rs b/tests/e2e-ui/crates/blob-domain/src/models/tile.rs index 5534c1cb5..56a7ba469 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/tile.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/tile.rs @@ -1,8 +1,3 @@ -//! Canonical tile values (JS parity). +//! Re-export pure tile constants from [`crate::core`]. -pub const HOLE: u8 = 0; -pub const UNVISITED: u8 = 1; -pub const VISITED: u8 = 2; -pub const DEAD_BY_SUICIDE: u8 = 3; -pub const DEAD_BY_HOLE: u8 = 4; -pub const PLAYER: u8 = 9; +pub use crate::core::tile::*; diff --git a/tests/e2e-ui/crates/blob-domain/src/wasm.rs b/tests/e2e-ui/crates/blob-domain/src/wasm.rs new file mode 100644 index 000000000..c8936ad91 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/wasm.rs @@ -0,0 +1,50 @@ +//! Client WASM surface for pure board rules (`--features wasm`). +//! +//! One export: JSON record + JSON args → JSON assign fields (or undefined). +//! All validation lives here so the TS host stays a thin framework bridge. + +use crate::core::{simulate_move, Direction}; +use serde_json::{Map, Value}; +use wasm_bindgen::prelude::*; + +/// Known-row pure reduce for `blob.simulate_move`. +/// +/// * `record_json` — known `BlobGames` row (needs `map_json`, `score`) +/// * `args_json` — command args (needs `direction`) +/// +/// Returns assign payload: +/// `{ map_json, score, player_dead, current_level_completed, status }` +/// or `undefined` when invalid / impossible (fail closed). +#[wasm_bindgen(js_name = blobSimulateMove)] +pub fn blob_simulate_move(record_json: &str, args_json: &str) -> Option { + let record: Value = serde_json::from_str(record_json).ok()?; + let args: Value = serde_json::from_str(args_json).ok()?; + let map_json = record.get("map_json")?.as_str()?; + let score = json_i64(record.get("score")?)?; + let direction = args.get("direction")?.as_str().and_then(Direction::parse)?; + let map: Vec> = serde_json::from_str(map_json).ok()?; + let preview = simulate_move(&map, score, direction).ok()?; + let next_map_json = serde_json::to_string(&preview.map).ok()?; + + let mut out = Map::new(); + out.insert("map_json".into(), Value::String(next_map_json)); + out.insert("score".into(), Value::from(preview.score)); + out.insert("player_dead".into(), Value::Bool(preview.player_dead)); + out.insert( + "current_level_completed".into(), + Value::Bool(preview.level_complete), + ); + out.insert("status".into(), Value::String(preview.status())); + serde_json::to_string(&Value::Object(out)).ok() +} + +fn json_i64(value: &Value) -> Option { + match value { + Value::Number(n) => n + .as_i64() + .or_else(|| n.as_u64().and_then(|u| i64::try_from(u).ok())) + .or_else(|| n.as_f64().filter(|f| f.is_finite()).map(|f| f as i64)), + Value::String(s) => s.parse().ok(), + _ => None, + } +} diff --git a/tests/e2e-ui/crates/chat-domain/src/lib.rs b/tests/e2e-ui/crates/chat-domain/src/lib.rs index d89f4c8c6..2c35356c1 100644 --- a/tests/e2e-ui/crates/chat-domain/src/lib.rs +++ b/tests/e2e-ui/crates/chat-domain/src/lib.rs @@ -2,4 +2,6 @@ pub mod models; -pub use models::{ChatError, ChatMessage, ChatMessagePostedDomainEvent, ChatMessageState}; +pub use models::{ + domain_commands, ChatError, ChatMessage, ChatMessagePostedDomainEvent, ChatMessageState, +}; diff --git a/tests/e2e-ui/crates/chat-domain/src/models/mod.rs b/tests/e2e-ui/crates/chat-domain/src/models/mod.rs index 87931869b..39f97a18c 100644 --- a/tests/e2e-ui/crates/chat-domain/src/models/mod.rs +++ b/tests/e2e-ui/crates/chat-domain/src/models/mod.rs @@ -5,5 +5,5 @@ mod chat_message; mod chat_message_state; pub use chat_error::ChatError; -pub use chat_message::{ChatMessage, ChatMessagePostedDomainEvent}; +pub use chat_message::{domain_commands, ChatMessage, ChatMessagePostedDomainEvent}; pub use chat_message_state::ChatMessageState; diff --git a/tests/e2e-ui/crates/readmodels/src/lib.rs b/tests/e2e-ui/crates/readmodels/src/lib.rs index 8454992f8..d406e81cc 100644 --- a/tests/e2e-ui/crates/readmodels/src/lib.rs +++ b/tests/e2e-ui/crates/readmodels/src/lib.rs @@ -7,10 +7,12 @@ pub mod models; pub use models::{AuthUsers, BlobGames, ChatMessages, Todos}; -pub fn distributed_manifest() -> distributed::DistributedProjectManifest { +pub fn distributed_manifest() -> distributed::ReadModelCatalog { use distributed::RelationalReadModel; - distributed::DistributedProjectManifest::new("e2e-ui") + // Schema-only catalog (physical tables). ApplicationManifest is the logical + // application contract and is composed separately. + distributed::ReadModelCatalog::new("e2e-ui") .table_schema(Todos::schema().clone()) .table_schema(ChatMessages::schema().clone()) .table_schema(BlobGames::schema().clone()) diff --git a/tests/e2e-ui/crates/runner/src/main.rs b/tests/e2e-ui/crates/runner/src/main.rs index 50d2abb0e..014a2df5c 100644 --- a/tests/e2e-ui/crates/runner/src/main.rs +++ b/tests/e2e-ui/crates/runner/src/main.rs @@ -1,4 +1,4 @@ -//! e2e-ui runner — SQLite (offline) or Postgres (compose stack). +//! e2e-ui runner — one-screen host invocation. //! //! Env: //! - `DATABASE_URL` — `sqlite:…` (default) or `postgres://…` @@ -8,196 +8,20 @@ //! - `ZITADEL_SCRAPE_INTERVAL_SECS` (default 60; `0` = no background loop) use std::env; -use std::sync::Arc; -use std::time::Duration; -use distributed::bus::{PostgresBus, RunOptions, SqliteBus}; -use distributed::{ - BusPublisher, OutboxDispatcher, PostgresLockManager, PostgresRepository, SqliteLockManager, - SqliteRepository, -}; -use e2e_service::{ - build_graphql_engine, build_service, distributed_manifest, identity_from_env, serve_with_oidc, - spawn_scrape_loop, ZitadelScrapeConfig, -}; - -const BUS_GROUP: &str = "e2e-ui"; +use e2e_service::{identity_from_env, run_e2e_host, HostOptions}; #[tokio::main] async fn main() -> Result<(), Box> { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:./e2e-ui.db?mode=rwc".into()); let bind = env::var("BIND").unwrap_or_else(|_| "127.0.0.1:8791".into()); - let identity = identity_from_env(); - - if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { - run_postgres(&database_url, &bind, identity).await - } else { - run_sqlite(&database_url, &bind, identity).await - } -} - -async fn run_sqlite( - database_url: &str, - bind: &str, - identity: distributed::graphql::IdentityConfig, -) -> Result<(), Box> { - let repo = SqliteRepository::connect_and_migrate(database_url).await?; - let registry = distributed_manifest() - .table_registry() - .map_err(|e| format!("manifest: {e}"))?; - repo.bootstrap_table_schema_for_dev(®istry).await?; - let locks = SqliteLockManager::new(repo.pool().clone()); - - let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); - bus.ensure_tables().await?; - - let change_rx = repo.read_model_changes(); - let service = build_service(repo.clone(), locks.clone(), repo.clone()) - .with_bus(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)); - let gql = build_graphql_engine(&repo, &service, identity.clone(), Some(change_rx))?; - let http_service = Arc::new(service.try_with_graphql(gql)?); - - spawn_outbox_sqlite(repo.clone()); - spawn_consumer_sqlite(repo.clone(), locks); - spawn_zitadel_scrape(repo.clone()); - - eprintln!("e2e-ui (sqlite) listening on http://{bind}"); - serve_with_oidc(http_service, identity, bind).await?; - Ok(()) -} - -async fn run_postgres( - database_url: &str, - bind: &str, - identity: distributed::graphql::IdentityConfig, -) -> Result<(), Box> { - let repo = PostgresRepository::connect_and_migrate(database_url).await?; - let registry = distributed_manifest() - .table_registry() - .map_err(|e| format!("manifest: {e}"))?; - repo.bootstrap_table_schema_for_dev(®istry).await?; - let locks = PostgresLockManager::new(repo.pool().clone()); - - let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); - bus.ensure_tables().await?; - - let change_rx = repo.read_model_changes(); - let service = build_service(repo.clone(), locks.clone(), repo.clone()) - .with_bus(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)); - let gql = build_graphql_engine(&repo, &service, identity.clone(), Some(change_rx))?; - let http_service = Arc::new(service.try_with_graphql(gql)?); - - spawn_outbox_postgres(repo.clone()); - spawn_consumer_postgres(repo.clone(), locks); - spawn_zitadel_scrape(repo.clone()); - - eprintln!("e2e-ui (postgres) listening on http://{bind}"); - serve_with_oidc(http_service, identity, bind).await?; - Ok(()) -} - -fn spawn_zitadel_scrape(repo: R) -where - R: distributed::TransactionalCommit + Clone + Send + Sync + 'static, -{ - match ZitadelScrapeConfig::from_env() { - Some(cfg) if cfg.background_enabled() || cfg.on_start => { - eprintln!( - "zitadel scrape: enabled (api={}, interval={}s, on_start={})", - cfg.api_base, - cfg.interval.as_secs(), - cfg.on_start - ); - spawn_scrape_loop(repo, cfg); - } - Some(_) => { - eprintln!("zitadel scrape: credentials present, background off (interval=0); use POST /zitadel.scrape.v1"); - } - None => { - eprintln!( - "zitadel scrape: disabled (set ZITADEL_SERVICE_USER_TOKEN + OIDC_ISSUER/ZITADEL_API_URL)" - ); - } - } -} - -fn spawn_outbox_sqlite(repo: SqliteRepository) { - tokio::spawn(async move { - let bus = Arc::new(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)); - let dispatcher = OutboxDispatcher::new( - repo.outbox_store(), - BusPublisher::new(bus), - format!("outbox:{}", std::process::id()), - Duration::from_secs(30), - 5, - ) - .with_service("e2e-ui"); - loop { - match dispatcher.dispatch_batch(32).await { - Ok(o) if o.published > 0 || o.claimed > 0 => {} - Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await, - Err(e) => { - eprintln!("outbox: {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); -} - -fn spawn_consumer_sqlite(repo: SqliteRepository, locks: SqliteLockManager) { - tokio::spawn(async move { - loop { - let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); - let service = build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus); - match service.run(RunOptions::idempotent()).await { - Ok(()) => tokio::time::sleep(Duration::from_millis(25)).await, - Err(e) => { - eprintln!("consumer: {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); -} - -fn spawn_outbox_postgres(repo: PostgresRepository) { - tokio::spawn(async move { - let bus = Arc::new(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)); - let dispatcher = OutboxDispatcher::new( - repo.outbox_store(), - BusPublisher::new(bus), - format!("outbox:{}", std::process::id()), - Duration::from_secs(30), - 5, - ) - .with_service("e2e-ui"); - loop { - match dispatcher.dispatch_batch(32).await { - Ok(o) if o.published > 0 || o.claimed > 0 => {} - Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await, - Err(e) => { - eprintln!("outbox: {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); -} - -fn spawn_consumer_postgres(repo: PostgresRepository, locks: PostgresLockManager) { - tokio::spawn(async move { - loop { - let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); - let service = build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus); - match service.run(RunOptions::idempotent()).await { - Ok(()) => tokio::time::sleep(Duration::from_millis(25)).await, - Err(e) => { - eprintln!("consumer: {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); + run_e2e_host( + &database_url, + HostOptions { + bind, + identity: identity_from_env(), + }, + ) + .await } diff --git a/tests/e2e-ui/crates/service/src/application.rs b/tests/e2e-ui/crates/service/src/application.rs new file mode 100644 index 000000000..fd816495d --- /dev/null +++ b/tests/e2e-ui/crates/service/src/application.rs @@ -0,0 +1,28 @@ +//! e2e-ui application composition root. +//! +//! This is the review-visible product declaration: surface identities, module +//! inventory, and re-exports of the composed host APIs. Infrastructure +//! (dialect, outbox, OIDC serve) stays in `host`; handlers stay in modules. + +use crate::modules::{blob, chat, compose, todo}; + +/// Stable normal-application surface shared by user and admin sessions. +pub const DISTRIBUTED_CLIENT_SURFACE: &str = "e2e-ui"; +/// Stable elevated surface for routes that intentionally include admin-only fields. +pub const DISTRIBUTED_ADMIN_CLIENT_SURFACE: &str = "e2e-ui-admin"; +/// Unauthenticated public surface (lobby message peek). +pub const DISTRIBUTED_PUBLIC_CLIENT_SURFACE: &str = "e2e-ui-public"; + +/// Logical application name used for manifest / plan identity. +pub const E2E_UI_APPLICATION: &str = "e2e-ui"; + +/// Explicit module identities owned by the e2e application. +pub const E2E_UI_MODULE_IDS: &[&str] = compose::MODULE_IDS; + +/// Compile-time proof that module inventory matches bounded-context crates. +pub const MODULE_DECLARATIONS: &[(&str, &str)] = &[ + (todo::MODULE_ID, "todo commands + projector"), + (chat::MODULE_ID, "chat commands + Zitadel extension + projectors"), + (blob::MODULE_ID, "blob Atomic commands"), + ("identity", "AuthUsers projection via chat module ingestors"), +]; diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs index 482910cf3..081a09141 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs @@ -1,9 +1,8 @@ //! Command: `blob.move` — direction up|down|left|right. //! -//! Optimistic outcome fields on the input are **client preview fill** (same -//! pattern as chat `created_at` / `message_id`): the generated `.applies` -//! preview maps them into the replica optimistic layer. Authority still comes -//! only from `game_id` + `direction` + domain `move_dir`. +//! Input is only what the client knows (`game_id` + `direction`). Server domain +//! seals via Atomic. Client may run the declared pure (`blob.simulate_move` / +//! `$lib/blob/simulate-move`) for known-row optimism; that paint is provisional. use blob_domain::{BlobGame, BlobGameState, Direction}; use distributed::graphql::{Atomic, PreparedCommand}; @@ -12,7 +11,7 @@ use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; use serde::Deserialize; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "blob.move"; @@ -20,21 +19,13 @@ pub const COMMAND: &str = "blob.move"; pub struct BlobMoveInput { pub game_id: String, pub direction: String, - /// Optimistic board JSON (`number[][]`) for `.applies` preview only. - pub map_json: String, - pub score: i64, - pub player_dead: bool, - pub current_level: i64, - pub current_level_completed: bool, - /// `active` | `dead` | `level_complete` - pub status: String, } pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobMoveInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let dir = Direction::parse(&input.direction).ok_or_else(|| { HandlerError::Rejected(format!( "invalid direction `{}` (use up|down|left|right)", @@ -47,10 +38,8 @@ pub async fn handle( .get(&input.game_id) .await? .ok_or_else(|| HandlerError::NotFound(input.game_id.clone()))?; - // Preview fields on `input` are not trusted for authority. game.move_dir(&owner, dir).map_err(rejected)?; - // Handler-owned atomic: same mutation IR as event→mutation bindings. let row = save_blob_game() .from_state(&BlobGameState::from(&*game)) .map_err(|error| HandlerError::Other(Box::new(error)))?; diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs index b92d8c56e..3d89e9b72 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs @@ -7,7 +7,7 @@ use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; use serde::Deserialize; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "blob.start"; @@ -22,7 +22,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobStartInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); if repo.get(&input.game_id).await?.is_some() { diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs index c4fba25b3..b83a7844b 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs @@ -7,7 +7,7 @@ use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; use serde::Deserialize; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "blob.start_level"; @@ -20,7 +20,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobStartLevelInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut game = repo .get(&input.game_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs b/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs index 77088a5cb..a5118dbd8 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs @@ -5,7 +5,7 @@ use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "chat.post"; @@ -32,7 +32,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, ChatMessage>, input: ChatPostInput, ) -> Result>, HandlerError> { - let author = ctx.user_id()?.to_string(); + let author = principal(ctx)?; let created_at = canonical_near_unix_millis(&input.created_at)?; let repo = ctx.repo(); diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs index 4d199736c..4ae904c2f 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use todo_domain::{Todo, TodoState}; use crate::handlers::commands::payloads::TodoStatusPayload; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.archive"; @@ -22,7 +22,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoArchiveInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs index 760d4e115..9a10485b4 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use todo_domain::{Todo, TodoState}; use crate::handlers::commands::payloads::TodoStatusPayload; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.complete"; @@ -19,7 +19,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoCompleteInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs index f7f8d035d..60dbf185f 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs @@ -1,14 +1,14 @@ //! Command: `todo.create` — owner is always the authenticated session user. //! -//! GraphQL: exposed as mutation field `todos_create` (roles: user, admin). -//! Owner cannot be spoofed via input — only `require_user(session)` is written. +//! GraphQL: `todos_create` (roles: user, admin). Session admission is the +//! mount guard (`causal_has_user`); this body binds that principal as owner. use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.create"; @@ -33,7 +33,7 @@ pub async fn handle( input: TodoCreateInput, ) -> Result>, HandlerError> { // Owner is always the authenticated principal — not client-supplied. - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); if repo.get(&input.todo_id).await?.is_some() { diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs index 1e37e1341..98ee8d358 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs @@ -9,7 +9,7 @@ use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.force_archive"; @@ -31,7 +31,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoForceArchiveInput, ) -> Result>, HandlerError> { - let admin = ctx.user_id()?.to_string(); + let admin = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs index 44b20514d..0d820c2e7 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs @@ -5,7 +5,7 @@ use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::Todo; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.purge"; @@ -24,7 +24,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoPurgeInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs index 946f1d71c..0a512b7b6 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs @@ -5,7 +5,7 @@ use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.rename"; @@ -26,7 +26,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoRenameInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs index 03623fc12..9ef34d794 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use todo_domain::{Todo, TodoState}; use crate::handlers::commands::payloads::TodoStatusPayload; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.reopen"; @@ -21,7 +21,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoReopenInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/util.rs b/tests/e2e-ui/crates/service/src/handlers/util.rs index a51cebd32..9aa0154b2 100644 --- a/tests/e2e-ui/crates/service/src/handlers/util.rs +++ b/tests/e2e-ui/crates/service/src/handlers/util.rs @@ -1,8 +1,15 @@ //! Shared handler helpers. +//! +//! **Admission vs domain** +//! - [`session_has_user`] / [`session_is_admin`] / [`causal_has_user`] / +//! [`causal_is_admin`] — command **guards** (session admission only). +//! - Handler bodies bind the principal and call the domain; they do not re-check +//! “am I logged in?” when a guard already did. +//! - Domain owns entity invariants (empty title, ownership, board rules). use distributed::bus::Message; -use distributed::microsvc::{HandlerError, Session}; -use distributed::{BitcodePayloadCodec, PayloadCodec}; +use distributed::microsvc::{CausalCommandContext, HandlerError, Session}; +use distributed::{Aggregate, BitcodePayloadCodec, PayloadCodec}; use serde::de::DeserializeOwned; /// Decode event payload as JSON (tests) or bitcode (outbox → bus). @@ -50,6 +57,30 @@ pub fn session_is_admin(session: &Session) -> bool { session.has_role("admin") } +/// Typed causal guard: non-empty session user id. +pub fn causal_has_user(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + session_has_user(ctx.session()) +} + +/// Typed causal guard: session user present and carries `admin`. +pub fn causal_is_admin(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + session_has_user(ctx.session()) && session_is_admin(ctx.session()) +} + +/// Principal after a user-session guard (for domain `owner_id` / author args). +pub fn principal(ctx: &CausalCommandContext<'_, A>) -> Result +where + A: Aggregate + Send + Sync + 'static, +{ + ctx.user_id().map(str::to_string) +} + /// Require engine role `admin` (handler-path Result form). pub fn require_admin(session: &Session) -> Result<(), HandlerError> { if session.has_role("admin") { @@ -108,4 +139,13 @@ mod tests { s.set(USER_ID_KEY, "bob"); assert_eq!(require_user(&s).unwrap(), "bob"); } + + #[test] + fn session_is_admin_requires_user_for_causal_admin_guard_semantics() { + // Admin role without a user id is not a usable principal for force_archive. + let mut s = Session::new(); + s.set(ROLE_KEY, "admin"); + assert!(session_is_admin(&s)); + assert!(!session_has_user(&s)); + } } diff --git a/tests/e2e-ui/crates/service/src/host.rs b/tests/e2e-ui/crates/service/src/host.rs new file mode 100644 index 000000000..9f04d7c75 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/host.rs @@ -0,0 +1,155 @@ +//! One-screen host bootstrap for the e2e-ui application. +//! +//! Dialect selection and identity remain here. Outbox/consumer loops use +//! framework worker helpers. + +use std::sync::Arc; +use std::time::Duration; + +use distributed::bus::{PostgresBus, SqliteBus}; +use distributed::command_dispatch::LocalCommandDispatcher; +use distributed::graphql::IdentityConfig; +use distributed::microsvc::{spawn_outbox_publish_loop, spawn_service_consumer_loop}; +use distributed::{ + PostgresLockManager, PostgresRepository, SqliteLockManager, SqliteRepository, +}; + +use crate::{ + build_graphql_engine, build_service, distributed_manifest, serve_with_oidc, spawn_scrape_loop, + ZitadelScrapeConfig, E2E_UI_APPLICATION, +}; + +const BUS_GROUP: &str = "e2e-ui"; + +/// Bind address and identity for one local process host. +pub struct HostOptions { + pub bind: String, + pub identity: IdentityConfig, +} + +/// Start the e2e-ui full-local process for SQLite or Postgres from `DATABASE_URL`. +pub async fn run_e2e_host( + database_url: &str, + options: HostOptions, +) -> Result<(), Box> { + eprintln!( + "e2e-ui host application=`{}` bind={}", + E2E_UI_APPLICATION, options.bind + ); + if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { + run_postgres(database_url, options).await + } else { + run_sqlite(database_url, options).await + } +} + +async fn run_sqlite( + database_url: &str, + options: HostOptions, +) -> Result<(), Box> { + let repo = SqliteRepository::connect_and_migrate(database_url).await?; + let registry = distributed_manifest() + .table_registry() + .map_err(|e| format!("manifest: {e}"))?; + repo.bootstrap_table_schema_for_dev(®istry).await?; + let locks = SqliteLockManager::new(repo.pool().clone()); + let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); + bus.ensure_tables().await?; + + let change_rx = repo.read_model_changes(); + let service = build_service(repo.clone(), locks.clone(), repo.clone()) + .with_bus(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)); + let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let service = Arc::new(service.try_with_graphql(gql)?); + let _dispatcher = Arc::new(LocalCommandDispatcher::new(Arc::clone(&service))); + + spawn_outbox_publish_loop( + repo.outbox_store(), + Arc::new(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)), + "e2e-ui", + Duration::from_secs(30), + 5, + ); + { + let repo = repo.clone(); + let locks = locks.clone(); + spawn_service_consumer_loop(move || { + let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); + build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus) + }); + } + spawn_zitadel_scrape(repo.clone()); + + eprintln!("e2e-ui (sqlite) listening on http://{}", options.bind); + serve_with_oidc(service, options.identity, &options.bind).await?; + Ok(()) +} + +async fn run_postgres( + database_url: &str, + options: HostOptions, +) -> Result<(), Box> { + let repo = PostgresRepository::connect_and_migrate(database_url).await?; + let registry = distributed_manifest() + .table_registry() + .map_err(|e| format!("manifest: {e}"))?; + repo.bootstrap_table_schema_for_dev(®istry).await?; + let locks = PostgresLockManager::new(repo.pool().clone()); + let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); + bus.ensure_tables().await?; + + let change_rx = repo.read_model_changes(); + let service = build_service(repo.clone(), locks.clone(), repo.clone()) + .with_bus(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)); + let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let service = Arc::new(service.try_with_graphql(gql)?); + let _dispatcher = Arc::new(LocalCommandDispatcher::new(Arc::clone(&service))); + + spawn_outbox_publish_loop( + repo.outbox_store(), + Arc::new(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)), + "e2e-ui", + Duration::from_secs(30), + 5, + ); + { + let repo = repo.clone(); + let locks = locks.clone(); + spawn_service_consumer_loop(move || { + let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); + build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus) + }); + } + spawn_zitadel_scrape(repo.clone()); + + eprintln!("e2e-ui (postgres) listening on http://{}", options.bind); + serve_with_oidc(service, options.identity, &options.bind).await?; + Ok(()) +} + +fn spawn_zitadel_scrape(repo: R) +where + R: distributed::TransactionalCommit + Clone + Send + Sync + 'static, +{ + match ZitadelScrapeConfig::from_env() { + Some(cfg) if cfg.background_enabled() || cfg.on_start => { + eprintln!( + "zitadel scrape: enabled (api={}, interval={}s, on_start={})", + cfg.api_base, + cfg.interval.as_secs(), + cfg.on_start + ); + spawn_scrape_loop(repo, cfg); + } + Some(_) => { + eprintln!( + "zitadel scrape: credentials present, background off (interval=0); use POST /zitadel.scrape.v1" + ); + } + None => { + eprintln!( + "zitadel scrape: disabled (set ZITADEL_SERVICE_USER_TOKEN + OIDC_ISSUER/ZITADEL_API_URL)" + ); + } + } +} diff --git a/tests/e2e-ui/crates/service/src/lib.rs b/tests/e2e-ui/crates/service/src/lib.rs index add5fd223..810761aeb 100644 --- a/tests/e2e-ui/crates/service/src/lib.rs +++ b/tests/e2e-ui/crates/service/src/lib.rs @@ -1,26 +1,35 @@ -//! e2e-ui service library — handlers + GraphQL. +//! e2e-ui service library — application modules + host. //! -//! Domain logic lives in `*-domain`, query models and read RBAC in -//! `e2e-readmodels`, and portable event mappings in `e2e-projections`. This -//! crate owns the explicit command/projector handlers, placement, GraphQL, and -//! Zitadel ingress. Eventual read models are written by projector handlers; -//! eligible direct projections are written in the command transaction. +//! # Authoring story +//! +//! - [`application`] — surface names and module inventory +//! - [`modules`] — todo / chat / blob mounts + compose + GraphQL +//! - [`host`] — one-screen process bootstrap (`run_e2e_host`) +//! - [`handlers`] — command/event bodies (domain-adjacent, not infrastructure) +//! +//! Domain crates stay pure aggregates; read models live in `e2e-readmodels`; +//! portable projections in `e2e-projections`. +mod application; mod bounds; mod deps; pub mod handlers; +mod host; +pub mod modules; mod oidc_layer; -mod service; +pub use application::{ + DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + E2E_UI_APPLICATION, E2E_UI_MODULE_IDS, +}; pub use e2e_readmodels::distributed_manifest; -/// Zitadel Management API scrape (reconcile missed Action events). pub use handlers::ingestors::zitadel::{ scrape_users_to_outbox, spawn_scrape_loop, ScrapeReport, ZitadelScrapeConfig, }; -pub use oidc_layer::serve_with_oidc; -pub use service::{ - build_graphql_engine, build_service, dev_identity, distributed_admin_client_surface, - distributed_client_surface, distributed_public_client_surface, identity_from_env, - oidc_bearer_config, DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, - DISTRIBUTED_PUBLIC_CLIENT_SURFACE, +pub use host::{run_e2e_host, HostOptions}; +pub use modules::compose::build_service; +pub use modules::graphql::{ + build_graphql_engine, dev_identity, distributed_admin_client_surface, distributed_client_surface, + distributed_public_client_surface, identity_from_env, oidc_bearer_config, }; +pub use oidc_layer::serve_with_oidc; diff --git a/tests/e2e-ui/crates/service/src/modules/blob.rs b/tests/e2e-ui/crates/service/src/modules/blob.rs new file mode 100644 index 000000000..021051653 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/blob.rs @@ -0,0 +1,94 @@ +//! Blob game module: Atomic command mounts (direct projection seal). + +use blob_domain::domain_commands; +use blob_domain::BlobGame; +use distributed::graphql::{ + Atomic, CommandProjectionPureReduce, SurfaceDirectProjection, +}; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; +use e2e_readmodels::BlobGames; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers::commands::{blob_move, blob_start, blob_start_level}; +use crate::handlers::util::causal_has_user; + +/// Logical module id for composition inventories. +pub const MODULE_ID: &str = "blob"; + +type BlobRoutes = + Routes, BlobGame>, S>>; + +/// Mount blob Atomic commands. +/// +/// Emit sets come from domain transitions that directly capture events: +/// - start → [`domain_commands::StartWithMap`] (`blob.started`; demo start uses this path) +/// - move → [`domain_commands::MoveDir`] (`blob.moved`) +/// - start_level → [`domain_commands::StartLevel`] (`blob.level_started`) +pub fn routes( + repo: R, + locks: L, + read_models: S, + _blob_direct: SurfaceDirectProjection, +) -> BlobRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, BlobGame>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + let _ = _blob_direct; + Routes::for_aggregate::(repo, locks, read_models) + .command_transition::< + domain_commands::StartWithMap, + blob_start::BlobStartInput, + Atomic, + >(blob_start::COMMAND) + .field_name("blob_games_start") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, blob_start::handle) + .command_transition::< + domain_commands::MoveDir, + blob_move::BlobMoveInput, + Atomic, + >(blob_move::COMMAND) + .field_name("blob_games_move") + .roles(["user", "admin"].into_iter()) + // Domain pure: blob_domain::core — WASM package under $lib; gen-client hosts it. + .preview_reduce_known_record( + CommandProjectionPureReduce::wasm( + "blob.simulate_move", + "blob/pkg/blob_wasm", + "blobSimulateMove", + "BlobGames", + ) + .key_input("game_id", ["game_id"]) + .arg_input("direction", ["direction"]) + .assign([ + "map_json", + "score", + "player_dead", + "current_level_completed", + "status", + ]), + ) + .guarded(causal_has_user, blob_move::handle) + .command_transition::< + domain_commands::StartLevel, + blob_start_level::BlobStartLevelInput, + Atomic, + >(blob_start_level::COMMAND) + .field_name("blob_games_start_level") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, blob_start_level::handle) +} diff --git a/tests/e2e-ui/crates/service/src/modules/chat.rs b/tests/e2e-ui/crates/service/src/modules/chat.rs new file mode 100644 index 000000000..84f3b6150 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/chat.rs @@ -0,0 +1,74 @@ +//! Chat + identity-ingestor module: room messages, Zitadel ingress, auth_user projector. + +use chat_domain::domain_commands; +use chat_domain::{ChatMessage, ChatMessagePostedDomainEvent, ChatMessageState}; +use distributed::graphql::{Eventual, SurfaceProjector}; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers; +use crate::handlers::commands::chat_post; +use crate::handlers::util::causal_has_user; + +/// Logical module id for composition inventories. +pub const MODULE_ID: &str = "chat"; + +type ChatRoutes = + Routes, ChatMessage>, S>>; + +/// Mount chat commands, Zitadel extension commands, and chat/auth projectors. +pub fn routes( + repo: R, + locks: L, + read_models: S, + chat_projector: SurfaceProjector, +) -> ChatRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, ChatMessage>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + Routes::for_aggregate::(repo, locks, read_models) + .command_transition::< + domain_commands::Post, + chat_post::ChatPostInput, + Eventual, + >(chat_post::COMMAND) + .field_name("chat_messages_post") + .roles(["user", "admin"].into_iter()) + // Lobby rows are public-readable, so no owner row policy can supply + // this otherwise server-only transition value to automatic optimism. + .authenticated_user_field::("author_id") + .guarded(causal_has_user, chat_post::handle) + // Zitadel Action ingress + on-demand scrape remain non-GraphQL + // integration commands (explicit extension mounts). + .command(handlers::ingestors::zitadel::COMMAND) + .guarded( + handlers::ingestors::zitadel::guard, + handlers::ingestors::zitadel::handle, + ) + .command(handlers::ingestors::zitadel_scrape::COMMAND) + .guarded( + handlers::ingestors::zitadel_scrape::guard, + handlers::ingestors::zitadel_scrape::handle, + ) + .modeled_projector(chat_projector) + .handle(handlers::events::project_chat_messages::handle) + .events(handlers::events::project_auth_user::EVENTS) + .guarded( + handlers::events::project_auth_user::guard, + handlers::events::project_auth_user::handle, + ) +} diff --git a/tests/e2e-ui/crates/service/src/modules/compose.rs b/tests/e2e-ui/crates/service/src/modules/compose.rs new file mode 100644 index 000000000..cb3cf016b --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/compose.rs @@ -0,0 +1,64 @@ +//! Compose bounded-context modules into one e2e-ui Service. + +use blob_domain::BlobGame; +use chat_domain::ChatMessage; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, Service, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; +use todo_domain::Todo; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::modules::{blob, chat, projections, todo}; + +/// Explicit module inventory for the e2e-ui application. +pub const MODULE_IDS: &[&str] = &[todo::MODULE_ID, chat::MODULE_ID, blob::MODULE_ID, "identity"]; + +/// Compose todo + chat (+ identity ingestors) + blob modules into one Service. +/// +/// This is the review-visible application wiring: list modules, do not invent +/// infrastructure. Dialect runners and workers live in `host`. +pub fn build_service(repo: R, locks: L, read_models: S) -> Service +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, Todo>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, + AggregateRepository, ChatMessage>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, + AggregateRepository, BlobGame>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + let projections = projections::projection_owners(); + let todos = todo::routes( + repo.clone(), + locks.clone(), + read_models.clone(), + projections.todo, + ); + let chat = chat::routes( + repo.clone(), + locks.clone(), + read_models.clone(), + projections.chat, + ); + let blob = blob::routes(repo, locks, read_models, projections.blob); + + // GraphQL-only public write surface (POST /todo.* must 404 — suite T0 / oidc_pg). + // Zitadel Action ingress still needs HTTP: those commands are registered in + // the chat module and re-mounted in `serve_with_oidc` when wildcards are off. + Service::new() + .named("e2e-ui") + .without_http_command_routes() + .routes(todos) + .routes(chat) + .routes(blob) +} diff --git a/tests/e2e-ui/crates/service/src/modules/graphql.rs b/tests/e2e-ui/crates/service/src/modules/graphql.rs new file mode 100644 index 000000000..1aec044db --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/graphql.rs @@ -0,0 +1,728 @@ +use std::sync::Arc; + +use distributed::graphql::{ + build_surface, surface_for_application_contract, DistributedClientSurfaceExport, GraphqlEngine, + GraphqlPoolSource, IdentityConfig, OidcConfig, SurfaceOptions, +}; +use distributed::microsvc::Service; +use distributed::{InMemoryLockManager, InMemoryRepository, LockError, LockManager}; +use e2e_readmodels::{AuthUsers, BlobGames, ChatMessages, Todos}; + +use crate::application::{ + DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE, +}; +use crate::modules::projections; + +// Stable only for this local copyable fixture. Real deployments must inject +// their own per-deployment key rather than copying this development value. +const E2E_PROTOCOL_TOKEN_KEY: [u8; 32] = [0xe2; 32]; + +#[derive(Clone, Default)] +pub(crate) struct ClientSurfaceLocks(Arc); + +impl LockManager for ClientSurfaceLocks { + type Lock = distributed::InMemoryLock; + + fn get_lock(&self, id: &str) -> Result, LockError> { + self.0.get_lock(id) + } +} + +/// GraphQL over todos + chat + blob + AuthUsers. +pub fn build_graphql_engine( + pool: impl Into, + service: &Service, + identity: IdentityConfig, + change_rx: Option>, +) -> Result { + build_graphql_engine_with_graphiql(pool, service, identity, change_rx, graphiql_enabled()) +} + +pub(crate) fn build_graphql_engine_with_graphiql( + pool: impl Into, + service: &Service, + identity: IdentityConfig, + change_rx: Option>, + graphiql: bool, +) -> Result { + let projections = projections::projection_owners(); + let mut b = GraphqlEngine::builder(pool) + .protocol_token_key(E2E_PROTOCOL_TOKEN_KEY) + .roles(&["user", "admin", "anonymous"]) + .client_application_surface_with_schema_roles( + DISTRIBUTED_CLIENT_SURFACE, + ["admin", "user"], + ["user"], + ) + .client_application_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, ["admin"], ["admin"]) + .client_application_surface( + DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + ["anonymous"], + ["anonymous"], + ) + .model::(Todos::permissions()) + .model::(ChatMessages::permissions()) + .model::(BlobGames::permissions()) + .model::(AuthUsers::permissions()) + .service(service) + .client_projection_owners([ + projections.todo.into(), + projections.chat.into(), + projections.blob.into(), + ]) + .identity(identity) + .graphiql(graphiql); + if let Some(rx) = change_rx { + b = b.change_stream(rx); + } + b.build().map_err(|e| e.to_string()) +} + +fn pool_free_client_surface(application: &str, roles: &[&str]) -> DistributedClientSurfaceExport { + pool_free_client_surface_contract(application, roles, roles) +} + +fn pool_free_client_surface_contract( + application: &str, + eligible_roles: &[&str], + schema_roles: &[&str], +) -> DistributedClientSurfaceExport { + let project = e2e_readmodels::distributed_manifest(); + let repository = InMemoryRepository::new(); + let service = crate::modules::compose::build_service( + repository.clone(), + ClientSurfaceLocks::default(), + repository, + ); + let projections = projections::projection_owners(); + let full = build_surface(&project.tables, &SurfaceOptions::sqlite()) + .expect("e2e-ui client Surface should build") + .with_projection_owners([ + projections.todo.into(), + projections.chat.into(), + projections.blob.into(), + ]) + .expect("e2e-ui projector topology should bind") + .with_service(&service) + .expect("e2e-ui typed Service inventory should bind"); + let eligible = eligible_roles + .iter() + .map(|role| (*role).to_string()) + .collect::>(); + let schema = schema_roles + .iter() + .map(|role| (*role).to_string()) + .collect::>(); + let grants = e2e_readmodels::application_grants(); + let selected = + surface_for_application_contract(&full, application, &eligible, &schema, &grants) + .expect("e2e-ui application Surface should select"); + DistributedClientSurfaceExport::from_selected("e2e-ui", selected) + .expect("e2e-ui application Surface should export") +} + +/// Pool-free normal application export consumed by `distributed client-manifest`. +pub fn distributed_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface_contract(DISTRIBUTED_CLIENT_SURFACE, &["admin", "user"], &["user"]) +} + +pub fn distributed_admin_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, &["admin"]) +} + +pub fn distributed_public_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, &["anonymous"]) +} + +pub fn dev_identity() -> IdentityConfig { + IdentityConfig::dev_headers() +} + +pub fn graphiql_enabled() -> bool { + match std::env::var("GRAPHIQL") { + Ok(v) => { + let v = v.trim(); + !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off")) + } + Err(_) => true, + } +} + +fn env_clean(name: &str) -> String { + let mut s = std::env::var(name).unwrap_or_default().trim().to_string(); + for _ in 0..2 { + if s.len() >= 2 + && ((s.starts_with('\'') && s.ends_with('\'')) + || (s.starts_with('"') && s.ends_with('"'))) + { + s = s[1..s.len() - 1].trim().to_string(); + } else { + break; + } + } + s +} + +pub fn identity_from_env() -> IdentityConfig { + let iss = env_clean("OIDC_ISSUER"); + let aud = env_clean("OIDC_AUDIENCE"); + if iss.is_empty() || aud.is_empty() { + eprintln!("e2e-ui: OIDC_* unset — using DevHeaders (local only)"); + return dev_identity(); + } + let jwks = env_clean("OIDC_JWKS_URI"); + eprintln!("e2e-ui: OidcBearer issuer={iss} audience={aud}"); + oidc_bearer_config( + iss, + aud, + if jwks.is_empty() { None } else { Some(jwks) }, + None, + ) +} + +pub fn oidc_bearer_config( + issuer: impl Into, + audience: impl Into, + jwks_uri: Option, + static_jwks: Option, +) -> IdentityConfig { + let mut oidc = OidcConfig::new(issuer, audience); + if let Some(uri) = jwks_uri.filter(|s| !s.is_empty()) { + oidc.jwks_uri = Some(uri); + } + if let Some(jwks) = static_jwks { + oidc = oidc.with_static_jwks(jwks); + } + let cid = env_clean("OIDC_CLIENT_ID"); + if !cid.is_empty() { + oidc.extra_audiences = vec![cid]; + } + oidc.claim_map.engine_roles = vec!["user".into(), "admin".into()]; + oidc.claim_map.role_claims = vec![ + "groups".into(), + "roles".into(), + "realm_access.roles".into(), + "urn:zitadel:iam:org:project:roles".into(), + ]; + oidc.require_auth = false; + IdentityConfig::oidc_bearer(oidc) +} + +#[cfg(test)] +mod client_surface_tests { + use super::*; + use crate::application::{DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE}; + use crate::modules::compose::build_service; + use distributed::InMemoryRepository; + + #[test] + fn pool_free_user_and_admin_exports_compile_real_manifests() { + distributed_client_surface() + .manifest() + .expect("normal application client manifest"); + distributed_admin_client_surface() + .manifest() + .expect("elevated application client manifest"); + } + + #[test] + fn application_todos_keep_portable_owner_row_policy_for_optimistic_list_inserts() { + use distributed::graphql::ClientRowPolicy; + + let manifest = distributed_client_surface().manifest().unwrap(); + let todos = manifest + .models + .iter() + .find(|model| model.typename == "Todos") + .expect("Todos model on application surface"); + match &todos.row_policy { + ClientRowPolicy::Predicate { expression } => { + let text = serde_json::to_string(expression).expect("serialize row policy"); + assert!( + text.contains("x-user-id") && text.contains("owner_id"), + "owner claim predicate must be client-portable: {text}" + ); + } + other => panic!( + "Todos must not collapse to server-only row policy (blocks optimistic create list membership); got {other:?}" + ), + } + + let blob = manifest + .models + .iter() + .find(|model| model.typename == "BlobGames") + .expect("BlobGames model on application surface"); + assert!( + matches!(blob.row_policy, ClientRowPolicy::Predicate { .. }), + "BlobGames should keep portable owner row policy" + ); + } + + #[test] + fn todo_commands_auto_derive_optimism_without_applies() { + use distributed::graphql::{ClientProjectionPreviewSource, ClientProjectionValue}; + + let manifest = distributed_client_surface().manifest().unwrap(); + let create = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_create") + .expect("todos_create command"); + let projection = create + .extensions + .projection + .as_ref() + .expect("todos_create must export projection extension"); + assert!( + !projection.preview_occurrences.is_empty(), + "auto-optimism must invent preview occurrences from emits + projection arms" + ); + let sources: Vec<_> = projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["title"] + )), + "create title must map from command input: {sources:?}" + ); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::GeneratedDefault { path } if path == &["todo_id"] + )), + "create todo_id must map from generated default: {sources:?}" + ); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::TrustedPreset { name, codec } + if name == "x-user-id" && codec == "string" + )), + "create owner_id must map from row-policy claim: {sources:?}" + ); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Constant { + value: ClientProjectionValue::String(value), + } if value == "open" + )), + "create status must come from the sourced transition: {sources:?}" + ); + assert!( + sources + .iter() + .any(|source| matches!(source, ClientProjectionPreviewSource::Null)), + "create assignee_id must come from the sourced transition: {sources:?}" + ); + + // Sparse update commands only need the known input slots. + let rename = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_rename") + .expect("todos_rename command"); + let rename_projection = rename + .extensions + .projection + .as_ref() + .expect("todos_rename projection"); + let rename_sources: Vec<_> = rename_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + rename_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["title"] + )), + "rename title must map from input without .applies: {rename_sources:?}" + ); + assert!( + rename_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["todo_id"] + )), + "rename todo_id must map from input without .applies: {rename_sources:?}" + ); + + for (mutation_field, expected_status) in [ + ("todos_complete", "completed"), + ("todos_reopen", "open"), + ("todos_archive", "archived"), + ] { + let command = manifest + .commands + .iter() + .find(|command| command.mutation_field == mutation_field) + .unwrap_or_else(|| panic!("{mutation_field} command")); + let status_sources: Vec<_> = command + .extensions + .projection + .as_ref() + .unwrap_or_else(|| panic!("{mutation_field} projection")) + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + status_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Constant { + value: ClientProjectionValue::String(value), + } if value == expected_status + )), + "{mutation_field} status must come from the sourced transition: {status_sources:?}" + ); + } + + let purge = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_purge") + .expect("todos_purge command"); + let purge_projection = purge + .extensions + .projection + .as_ref() + .expect("todos_purge projection"); + let purge_sources: Vec<_> = purge_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + purge_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["todo_id"] + )), + "purge aggregate id must map from input without envelope .applies: {purge_sources:?}" + ); + } + + #[test] + fn chat_and_blob_commands_auto_derive_optimism_without_applies() { + use distributed::graphql::ClientProjectionPreviewSource; + + let manifest = distributed_client_surface().manifest().unwrap(); + + let post = manifest + .commands + .iter() + .find(|command| command.mutation_field == "chat_messages_post") + .expect("chat_messages_post command"); + let post_projection = post + .extensions + .projection + .as_ref() + .expect("chat post projection"); + let post_sources: Vec<_> = post_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + !post_projection.preview_occurrences.is_empty(), + "chat post must auto-derive preview occurrences" + ); + assert!( + post_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["body"] + )), + "chat body from input: {post_sources:?}" + ); + assert!( + post_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["message_id"] + )), + "chat message_id from input: {post_sources:?}" + ); + assert!( + post_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::TrustedPreset { name, codec } + if name == "x-user-id" && codec == "string" + )), + "chat author_id must bind the authenticated user without .applies: {post_sources:?}" + ); + + let blob_move = manifest + .commands + .iter() + .find(|command| command.mutation_field == "blob_games_move") + .expect("blob_games_move command"); + let move_projection = blob_move + .extensions + .projection + .as_ref() + .expect("blob move projection"); + assert!( + !move_projection.preview_occurrences.is_empty(), + "blob move still exports projection arms for Atomic sealing" + ); + // Thin input: only game_id + direction. Board fields come from pure + // reduce (`blob.simulate_move` over the known cache row) + Atomic seal. + let move_input = match &blob_move.input { + distributed::graphql::ClientCommandShape::Object { definition } => definition, + other => panic!("blob move should be object input, got {other:?}"), + }; + let field_names: Vec<_> = move_input + .fields + .iter() + .map(|field| field.name.as_str()) + .collect(); + assert_eq!( + field_names, + vec!["direction", "game_id"], + "blob move input must stay thin (no fat board fields on the wire)" + ); + } + + #[test] + fn chat_manifest_uses_unit_partition_so_lobby_live_can_stay_active() { + let manifest = distributed_client_surface().manifest().unwrap(); + let program = manifest + .projection_programs + .iter() + .find(|program| program.name == "project_chat_messages") + .expect("Chat projection program should be exported"); + assert!( + program.arms.iter().all(|arm| matches!( + &arm.partition, + distributed::graphql::ClientProjectionPartition::Unit + )), + "lobby chat uses unit partition so the chat_messages live query can advertise \ + supported index evidence (room isolation stays in the GraphQL where clause). \ + Surface-wide live_resume may still be false when owner-scoped models share the surface." + ); + } + + #[test] + fn blob_projection_owner_has_no_async_fact_route() { + let manifest = distributed_client_surface().manifest().unwrap(); + let owner = manifest + .projectors + .iter() + .find(|projector| projector.name == "project_blob") + .expect("Blob direct owner should be exported"); + assert!(owner.facts.is_empty()); + assert!(!owner.causal_confirmation); + + let repository = InMemoryRepository::new(); + let service = build_service( + repository.clone(), + crate::modules::graphql::ClientSurfaceLocks::default(), + repository, + ); + let plan = service.subscription_plan(); + for event in [ + "todo.created", + "todo.renamed", + "todo.completed", + "todo.reopened", + "todo.archived", + "todo.force_archived", + "todo.purged", + "chat_message.posted", + ] { + assert!( + plan.events.iter().any(|candidate| candidate == event), + "eventual modeled projection must subscribe to {event}" + ); + } + for fact in [ + "blob.started", + "blob.initialized", + "blob.level_started", + "blob.moved", + ] { + assert!( + !plan.events.iter().any(|event| event == fact), + "direct-only Blob ownership must not register an async route for {fact}" + ); + } + } + + #[tokio::test] + async fn graphiql_does_not_change_the_postgres_runtime_client_manifest() { + let generated = distributed_client_surface().manifest().unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://postgres:postgres@localhost/distributed") + .unwrap(); + let repository = distributed::PostgresRepository::new(pool.clone()); + let service = build_service( + repository.clone(), + distributed::PostgresLockManager::new(pool), + repository.clone(), + ); + let engine = crate::modules::graphql::build_graphql_engine_with_graphiql( + &repository, + &service, + dev_identity(), + None, + true, + ) + .expect("engine"); + let runtime = engine + .client_manifest_for_application( + DISTRIBUTED_CLIENT_SURFACE, + &["admin", "user"], + &["user"], + ) + .unwrap(); + + assert_eq!(generated, runtime); + + let make_request = || { + serde_json::from_value(serde_json::json!({ + "query": "{ todos @skip(if: true) { todo_id } }", + "extensions": { + "distributed": { + "client": { + "surface": { + "kind": "application", + "name": DISTRIBUTED_CLIENT_SURFACE, + "eligible_roles": ["admin", "user"], + "schema_roles": ["user"] + }, + "schemaHash": generated.schema_fingerprint + } + } + } + })) + .expect("generated application request") + }; + let mut session = distributed::microsvc::Session::new(); + session.set("x-roles", "user"); + session.set("x-user-id", "person-1"); + let response = engine.execute(&session, make_request()).await; + assert!( + !response.is_err(), + "the runtime must accept the generated application surface: {:?}", + response.errors + ); + // Multi-role admin principal may open the same portable contract. + let mut admin = session.clone(); + admin.set("x-roles", "admin,user"); + let admin_response = engine.execute(&admin, make_request()).await; + assert!( + !admin_response.is_err(), + "admin with user asserted roles must open e2e-ui: {:?}", + admin_response.errors + ); + let envelope = response + .extensions + .get("distributed") + .expect("distributed protocol envelope"); + let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); + assert_eq!( + envelope["schemaHash"], generated.schema_fingerprint, + "the authoritative response must attest the generated schema" + ); + } + + /// Empty-session open of e2e-ui-public + chat query (anonymous privilege). + /// + /// Bare protocol path for unauthenticated lobby peeks; UI route `/public` + /// documents the same surface name and extension shape. + #[tokio::test] + async fn public_surface_opens_and_queries_chat_without_identity() { + let generated = distributed_public_client_surface().manifest().unwrap(); + assert_eq!( + generated.surface, + distributed::graphql::ClientSurfaceIdentity::application( + DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + ["anonymous"], + ["anonymous"], + ) + ); + let repository = distributed::SqliteRepository::connect_and_migrate("sqlite::memory:") + .await + .expect("sqlite memory repo"); + let registry = e2e_readmodels::distributed_manifest() + .table_registry() + .expect("registry"); + repository + .bootstrap_table_schema_for_dev(®istry) + .await + .expect("bootstrap tables"); + let service = build_service( + repository.clone(), + crate::modules::graphql::ClientSurfaceLocks::default(), + repository.clone(), + ); + let engine = crate::modules::graphql::build_graphql_engine_with_graphiql( + &repository, + &service, + dev_identity(), + None, + false, + ) + .expect("engine"); + let runtime = engine + .client_manifest_for_application( + DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + &["anonymous"], + &["anonymous"], + ) + .expect("public surface registered"); + assert_eq!(generated.schema_fingerprint, runtime.schema_fingerprint); + + let request = serde_json::from_value(serde_json::json!({ + "query": "{ chat_messages(limit: 5, offset: 0) { message_id body room_id } }", + "extensions": { + "distributed": { + "client": { + "surface": { + "kind": "application", + "name": DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + "eligible_roles": ["anonymous"], + "schema_roles": ["anonymous"] + }, + "schemaHash": generated.schema_fingerprint + } + } + } + })) + .expect("public application request"); + + // No x-user-id, no x-roles — unauthenticated principal. + let session = distributed::microsvc::Session::new(); + let response = engine.execute(&session, request).await; + assert!( + !response.is_err(), + "anonymous open + chat query must succeed: {:?}", + response.errors + ); + let data = response.data.into_json().expect("json data"); + assert!( + data.get("chat_messages") + .and_then(|v| v.as_array()) + .is_some(), + "expected chat_messages array: {data}" + ); + let envelope = response + .extensions + .get("distributed") + .expect("distributed protocol envelope"); + let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); + assert_eq!(envelope["schemaHash"], generated.schema_fingerprint); + } + + #[test] + fn module_inventory_lists_todo_chat_blob_identity() { + assert_eq!( + crate::E2E_UI_MODULE_IDS, + &["todo", "chat", "blob", "identity"] + ); + assert_eq!(crate::application::MODULE_DECLARATIONS.len(), 4); + } +} diff --git a/tests/e2e-ui/crates/service/src/modules/mod.rs b/tests/e2e-ui/crates/service/src/modules/mod.rs new file mode 100644 index 000000000..315d1d82b --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/mod.rs @@ -0,0 +1,11 @@ +//! Bounded-context application modules for e2e-ui. +//! +//! Each module owns its command/projection mounts. [`compose`] lists them +//! into one Service; [`graphql`] owns surfaces and the query engine. + +pub mod blob; +pub mod chat; +pub mod compose; +pub mod graphql; +pub mod projections; +pub mod todo; diff --git a/tests/e2e-ui/crates/service/src/modules/projections.rs b/tests/e2e-ui/crates/service/src/modules/projections.rs new file mode 100644 index 000000000..69b4723c7 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/projections.rs @@ -0,0 +1,43 @@ +//! e2e-ui projection mounts — product declaration only. +//! +//! Topology, catalog activation, and Surface packaging come from +//! [`distributed::LocalProjectionMountsBuilder`]. + +use distributed::graphql::{SurfaceDirectProjection, SurfaceProjector}; +use distributed::LocalProjectionMountsBuilder; +use e2e_projections::{BLOB_GAMES, CHAT_MESSAGES, TODOS}; +use e2e_readmodels::{BlobGames, ChatMessages, Todos}; + +/// Projection surface mounts used by compose + GraphQL. +#[derive(Clone)] +pub struct ProjectionOwners { + pub todo: SurfaceProjector, + pub chat: SurfaceProjector, + pub blob: SurfaceDirectProjection, +} + +/// Compile local projection mounts for the e2e-ui application. +pub fn projection_owners() -> ProjectionOwners { + let mounts = LocalProjectionMountsBuilder::new("e2e-ui", "ordered-domain-events") + .expect("projection source") + .eventual_model::("project_todos", TODOS, "e2e-ui-todos-v2") + .expect("todo mount") + .eventual_model::("project_chat_messages", CHAT_MESSAGES, "e2e-ui-chat-v2") + .expect("chat mount") + .direct_model::("project_blob", BLOB_GAMES, "e2e-ui-blob-v2") + .expect("blob mount") + .build() + .expect("projection catalog"); + + ProjectionOwners { + todo: mounts + .projector("project_todos") + .expect("todo projector"), + chat: mounts + .projector("project_chat_messages") + .expect("chat projector"), + blob: mounts + .direct_projection("project_blob") + .expect("blob direct"), + } +} diff --git a/tests/e2e-ui/crates/service/src/modules/todo.rs b/tests/e2e-ui/crates/service/src/modules/todo.rs new file mode 100644 index 000000000..e57a38ea5 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/todo.rs @@ -0,0 +1,111 @@ +//! Todo bounded-context module: command mounts + eventual projector. + +use distributed::graphql::{Eventual, SurfaceProjector}; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{ + command_input_defaults, AggregateBuilder, AggregateRepository, QueuedRepository, +}; +use todo_domain::domain_commands; +use todo_domain::Todo; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers; +use crate::handlers::commands::{ + payloads, todo_archive, todo_complete, todo_create, todo_force_archive, todo_purge, todo_rename, + todo_reopen, +}; +use crate::handlers::util::{causal_has_user, causal_is_admin}; + +/// Logical module id for composition inventories. +pub const MODULE_ID: &str = "todo"; + +type TodoRoutes = + Routes, Todo>, S>>; + +/// Mount todo commands and the todo projector. +pub fn routes( + repo: R, + locks: L, + read_models: S, + todo_projector: SurfaceProjector, +) -> TodoRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, Todo>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + Routes::for_aggregate::(repo, locks, read_models) + .command_transition::< + domain_commands::Create, + todo_create::TodoCreateInput, + Eventual, + >(todo_create::COMMAND) + .field_name("todos_create") + .roles(["user", "admin"].into_iter()) + .input_defaults(command_input_defaults! { + input: todo_create::TodoCreateInput; + default input.todo_id = uuid_v7(); + }) + .guarded(causal_has_user, todo_create::handle) + .command_transition::< + domain_commands::Rename, + todo_rename::TodoRenameInput, + Eventual, + >(todo_rename::COMMAND) + .field_name("todos_rename") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_rename::handle) + .command_transition::< + domain_commands::Complete, + todo_complete::TodoCompleteInput, + Eventual, + >(todo_complete::COMMAND) + .field_name("todos_complete") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_complete::handle) + .command_transition::< + domain_commands::Reopen, + todo_reopen::TodoReopenInput, + Eventual, + >(todo_reopen::COMMAND) + .field_name("todos_reopen") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_reopen::handle) + .command_transition::< + domain_commands::Archive, + todo_archive::TodoArchiveInput, + Eventual, + >(todo_archive::COMMAND) + .field_name("todos_archive") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_archive::handle) + .command_transition::< + domain_commands::ForceArchive, + todo_force_archive::TodoForceArchiveInput, + Eventual, + >(todo_force_archive::COMMAND) + .field_name("todos_force_archive") + .roles(["admin"]) + .guarded(causal_is_admin, todo_force_archive::handle) + .command_transition::< + domain_commands::Purge, + todo_purge::TodoPurgeInput, + Eventual, + >(todo_purge::COMMAND) + .field_name("todos_purge") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_purge::handle) + .modeled_projector(todo_projector) + .handle(handlers::events::project_todos::handle) +} diff --git a/tests/e2e-ui/crates/service/src/service.rs b/tests/e2e-ui/crates/service/src/service.rs deleted file mode 100644 index 364e12655..000000000 --- a/tests/e2e-ui/crates/service/src/service.rs +++ /dev/null @@ -1,946 +0,0 @@ -//! Route bundles + GraphQL engine for the e2e-ui fixture. - -use std::sync::Arc; - -use blob_domain::{ - BlobGame, BlobLevelStartedDomainEvent, BlobMovedDomainEvent, BlobStartedDomainEvent, -}; -use chat_domain::{ChatMessage, ChatMessagePostedDomainEvent}; -use distributed::graphql::{ - build_surface, typed_command, Eventual, CommandProjectionPreview, - CommandProjectionPreviewSource, DistributedClientSurfaceExport, GraphqlEngine, - GraphqlPoolSource, IdentityConfig, OidcConfig, Atomic, SurfaceDirectProjection, - SurfaceModeledProjection, SurfaceOptions, SurfaceProjector, -}; -use distributed::microsvc::{ - ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, Routes, Service, -}; -use distributed::projection::catalog::{ProjectionBindingActivation, ProjectionCatalog}; -use distributed::projection::lower::ProjectionDescriptor; -use distributed::projection::placement::{ - ProjectionBinding, ProjectionBindingState, ProjectionEpoch, ProjectionExecutorRoute, - ProjectionOutput, ProjectionOwner, ProjectionPhysicalTopology, ProjectionSourceBinding, - PROJECTION_PARTITION_CODEC_VERSION, -}; -use distributed::projection_protocol::ProjectorTopologyId; -use distributed::{ - command_input_defaults, AggregateBuilder, AggregateRepository, InMemoryLockManager, - InMemoryRepository, LockError, LockManager, ProjectionEnvelopeField, Queueable, - QueuedRepository, RelationalReadModel, -}; -use e2e_projections::{BLOB_GAMES, CHAT_MESSAGES, TODOS}; -use e2e_readmodels::{AuthUsers, BlobGames, ChatMessages, Todos}; -use todo_domain::{ - Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, TodoCreatedDomainEvent, - TodoForceArchivedDomainEvent, TodoPurgedDomainEvent, TodoRenamedDomainEvent, - TodoReopenedDomainEvent, -}; - -use crate::bounds::{EventStore, Locks, ReadStore}; -use crate::handlers; - -// Stable only for this local copyable fixture. Real deployments must inject -// their own per-deployment key rather than copying this development value. -const E2E_PROTOCOL_TOKEN_KEY: [u8; 32] = [0xe2; 32]; - -/// Stable normal-application surface shared by user and admin sessions. -pub const DISTRIBUTED_CLIENT_SURFACE: &str = "e2e-ui"; -/// Stable elevated surface for routes that intentionally include admin-only fields. -pub const DISTRIBUTED_ADMIN_CLIENT_SURFACE: &str = "e2e-ui-admin"; -/// Unauthenticated public surface (lobby message peek). -pub const DISTRIBUTED_PUBLIC_CLIENT_SURFACE: &str = "e2e-ui-public"; - -#[derive(Clone, Default)] -struct ClientSurfaceLocks(Arc); - -impl LockManager for ClientSurfaceLocks { - type Lock = distributed::InMemoryLock; - - fn get_lock(&self, id: &str) -> Result, LockError> { - self.0.get_lock(id) - } -} - -#[derive(Clone)] -struct ProjectionOwners { - todo: SurfaceProjector, - chat: SurfaceProjector, - blob: SurfaceDirectProjection, -} - -fn projection_output() -> ProjectionOutput { - let schema = M::schema().clone(); - ProjectionOutput::try_new(schema.model_name.clone(), schema.table_name.clone(), schema) - .expect("canonical e2e-ui projection output") -} - -fn physical_topology(name: &str, digest: u8) -> ProjectionPhysicalTopology { - ProjectionPhysicalTopology::from_protocol( - &ProjectorTopologyId::new(1, name, [digest; 32]) - .expect("canonical e2e-ui physical topology"), - ) -} - -fn modeled_projection( - descriptor: ProjectionDescriptor, - catalog: &ProjectionCatalog, - active: &distributed::projection::catalog::ActiveProjectionBindings, - binding: &ProjectionBinding, -) -> SurfaceModeledProjection { - SurfaceModeledProjection::try_from_descriptor(descriptor, catalog, active, binding.id()) - .expect("modeled projection should resolve through the active catalog") -} - -fn projection_owners() -> ProjectionOwners { - let source = || { - ProjectionSourceBinding::try_new("e2e-ui-domain", "ordered-domain-events", 1) - .expect("canonical e2e-ui domain source") - }; - let owner = |name| ProjectionOwner::try_new(name).expect("canonical projection owner"); - - let todo_binding = ProjectionBinding::materialize_eventual( - TODOS.eventual(), - source(), - owner("project_todos"), - "distributed-projection-partition", - PROJECTION_PARTITION_CODEC_VERSION, - vec![projection_output::()], - Vec::new(), - Some(physical_topology("project_todos", 0x20)), - ) - .expect("Todo projection binding"); - let chat_binding = ProjectionBinding::materialize_eventual( - CHAT_MESSAGES.eventual(), - source(), - owner("project_chat_messages"), - "distributed-projection-partition", - PROJECTION_PARTITION_CODEC_VERSION, - vec![projection_output::()], - Vec::new(), - Some(physical_topology("project_chat_messages", 0x21)), - ) - .expect("Chat projection binding"); - let blob_binding = ProjectionBinding::materialize_direct( - BLOB_GAMES.direct(), - source(), - owner("project_blob"), - "distributed-projection-partition", - PROJECTION_PARTITION_CODEC_VERSION, - vec![projection_output::()], - Vec::new(), - Some(physical_topology("project_blob", 0x22)), - ) - .expect("Blob projection binding"); - // Blob projected commands stage the mutation-derived row in the handler - // (`readmodel(row).commit()?.atomic()`). Binding/catalog still own - // ownership, replay, and async projection for BLOB_GAMES. - - let catalog = ProjectionCatalog::try_new(vec![ - todo_binding.clone(), - chat_binding.clone(), - blob_binding.clone(), - ]) - .expect("deployment-wide projection catalog"); - let activation = |binding: &ProjectionBinding, epoch: &str| { - ProjectionBindingActivation::new( - binding.id(), - binding.program_id(), - ProjectionEpoch::new(epoch).expect("canonical projection epoch"), - ProjectionBindingState::Active, - Some( - ProjectionExecutorRoute::local("e2e-ui").expect("canonical local projection route"), - ), - ) - }; - let active = catalog - .activate( - vec![ - activation(&todo_binding, "e2e-ui-todos-v2"), - activation(&chat_binding, "e2e-ui-chat-v2"), - activation(&blob_binding, "e2e-ui-blob-v2"), - ], - None, - ) - .expect("non-overlapping active projection catalog"); - - // Runtime mounts are mutation-backed: descriptor program factories must - // match the mutation rewrite programs (real path, not digest theater). - - - - - ProjectionOwners { - todo: SurfaceProjector::new("project_todos").modeled(modeled_projection( - TODOS, - &catalog, - &active, - &todo_binding, - )), - chat: SurfaceProjector::new("project_chat_messages").modeled(modeled_projection( - CHAT_MESSAGES, - &catalog, - &active, - &chat_binding, - )), - blob: SurfaceDirectProjection::new("project_blob").modeled(modeled_projection( - BLOB_GAMES, - &catalog, - &active, - &blob_binding, - )), - } -} - -fn pool_free_client_surface(application: &str, roles: &[&str]) -> DistributedClientSurfaceExport { - pool_free_client_surface_contract(application, roles, roles) -} - -fn pool_free_client_surface_contract( - application: &str, - eligible_roles: &[&str], - schema_roles: &[&str], -) -> DistributedClientSurfaceExport { - use distributed::graphql::surface_for_application_contract; - - let project = e2e_readmodels::distributed_manifest(); - let repository = InMemoryRepository::new(); - let service = build_service( - repository.clone(), - ClientSurfaceLocks::default(), - repository, - ); - let projections = projection_owners(); - let full = build_surface(&project.tables, &SurfaceOptions::sqlite()) - .expect("e2e-ui client Surface should build") - .with_projection_owners([ - projections.todo.into(), - projections.chat.into(), - projections.blob.into(), - ]) - .expect("e2e-ui projector topology should bind") - .with_service(&service) - .expect("e2e-ui typed Service inventory should bind"); - let eligible = eligible_roles - .iter() - .map(|role| (*role).to_string()) - .collect::>(); - let schema = schema_roles - .iter() - .map(|role| (*role).to_string()) - .collect::>(); - // Schema grants: only schema_roles need entries in the map. - let grants = e2e_readmodels::application_grants(); - let selected = surface_for_application_contract( - &full, - application, - &eligible, - &schema, - &grants, - ) - .expect("e2e-ui application Surface should select"); - DistributedClientSurfaceExport::from_project(&project, selected) - .expect("e2e-ui application Surface should export") -} - -/// Pool-free normal application export consumed by `dctl client-manifest`. -/// -/// **Eligible roles** are `admin` + `user` so multi-role admin principals can -/// open the normal app client. **Schema privilege** is the `user` grant set -/// only so owner-scoped models (`Todos`, `BlobGames`) keep a **client-portable** -/// row policy (`owner_id = claim(x-user-id)`) for optimistic list inserts. -/// -/// Server-side admin GraphQL grants are unchanged — an admin session still -/// receives unrestricted query results via the concrete admin role surface. -/// Elevated all-rows views / force-archive stay on -/// [`distributed_admin_client_surface`]. -pub fn distributed_client_surface() -> DistributedClientSurfaceExport { - pool_free_client_surface_contract( - DISTRIBUTED_CLIENT_SURFACE, - &["admin", "user"], - &["user"], - ) -} - -/// Pool-free elevated application export for admin-only routes. -pub fn distributed_admin_client_surface() -> DistributedClientSurfaceExport { - pool_free_client_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, &["admin"]) -} - -/// Pool-free public (anonymous) application export for unauthenticated lobby peeks. -pub fn distributed_public_client_surface() -> DistributedClientSurfaceExport { - pool_free_client_surface(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, &["anonymous"]) -} - -/// Full service: todo + chat commands and projectors. -pub fn build_service(repo: R, locks: L, read_models: S) -> Service -where - R: EventStore, - L: Locks, - S: ReadStore, - QueuedRepository: Clone - + AggregateBuilder - + HasOutboxStore - + distributed::TransactionalCommit - + Send - + Sync - + 'static, - AggregateRepository, Todo>: - HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, - AggregateRepository, ChatMessage>: - HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, - AggregateRepository, BlobGame>: - HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, -{ - use handlers::commands::{ - blob_move, blob_start, blob_start_level, chat_post, payloads, todo_archive, todo_complete, - todo_create, todo_force_archive, todo_purge, todo_rename, todo_reopen, - }; - - let app_roles = ["user", "admin"]; - let projections = projection_owners(); - let todos = Routes::new() - .with_repo(repo.clone().queued_with(locks.clone()).aggregate::()) - .with_read_model_store(read_models.clone()) - .typed_command( - typed_command::>( - todo_create::COMMAND, - ) - .field_name("todos_create") - .roles(app_roles) - .input_defaults(command_input_defaults! { - input: todo_create::TodoCreateInput; - default input.todo_id = uuid_v7(); - }) - .emits(distributed::events![TodoCreatedDomainEvent]) - .applies(distributed::state_preview! { - TodoCreatedDomainEvent => todo_domain::TodoState { - todo_id: generated.todo_id, - owner_id: trusted("x-user-id", "string"), - title: input.title, - status: "open", - assignee_id: null, - } - }), - ) - .handle(todo_create::handle) - .typed_command( - typed_command::>( - todo_rename::COMMAND, - ) - .field_name("todos_rename") - .roles(app_roles) - .emits(distributed::events![TodoRenamedDomainEvent]) - .applies(distributed::state_preview! { - TodoRenamedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - title: input.title, - ..unknown - } - }), - ) - .handle(todo_rename::handle) - .typed_command( - typed_command::>( - todo_complete::COMMAND, - ) - .field_name("todos_complete") - .roles(app_roles) - .emits(distributed::events![TodoCompletedDomainEvent]) - .applies(distributed::state_preview! { - TodoCompletedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - status: "completed", - ..unknown - } - }), - ) - .handle(todo_complete::handle) - .typed_command( - typed_command::>( - todo_reopen::COMMAND, - ) - .field_name("todos_reopen") - .roles(app_roles) - .emits(distributed::events![TodoReopenedDomainEvent]) - .applies(distributed::state_preview! { - TodoReopenedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - status: "open", - ..unknown - } - }), - ) - .handle(todo_reopen::handle) - .typed_command( - typed_command::>( - todo_archive::COMMAND, - ) - .field_name("todos_archive") - .roles(app_roles) - .emits(distributed::events![TodoArchivedDomainEvent]) - .applies(distributed::state_preview! { - TodoArchivedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - status: "archived", - ..unknown - } - }), - ) - .handle(todo_archive::handle) - .typed_command( - typed_command::< - todo_force_archive::TodoForceArchiveInput, - Eventual, - >(todo_force_archive::COMMAND) - .field_name("todos_force_archive") - .roles(["admin"]) - .emits(distributed::events![TodoForceArchivedDomainEvent]) - .applies(distributed::state_preview! { - TodoForceArchivedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - status: "archived", - ..unknown - } - }), - ) - .handle(todo_force_archive::handle) - .typed_command( - typed_command::>(todo_purge::COMMAND) - .field_name("todos_purge") - .roles(app_roles) - .emits(distributed::events![TodoPurgedDomainEvent]) - .applies( - CommandProjectionPreview::new() - .events(distributed::events![TodoPurgedDomainEvent]) - .envelope( - ProjectionEnvelopeField::AggregateId, - CommandProjectionPreviewSource::input(["todo_id"]), - ), - ), - ) - .handle(todo_purge::handle) - .modeled_projector(projections.todo.clone()) - .handle(handlers::events::project_todos::handle); - - let chat = Routes::new() - .with_repo( - repo.clone() - .queued_with(locks.clone()) - .aggregate::(), - ) - .with_read_model_store(read_models.clone()) - .typed_command( - typed_command::>( - chat_post::COMMAND, - ) - .field_name("chat_messages_post") - .roles(app_roles) - .emits(distributed::events![ChatMessagePostedDomainEvent]) - .applies(distributed::state_preview! { - ChatMessagePostedDomainEvent => chat_domain::ChatMessageState { - message_id: input.message_id, - room_id: input.room_id, - author_id: trusted("x-user-id", "string"), - body: input.body, - created_at: input.created_at, - } - }), - ) - .handle(chat_post::handle) - // Zitadel Action ingress + on-demand scrape remain non-GraphQL - // integration commands. - .command(handlers::ingestors::zitadel::COMMAND) - .guarded( - handlers::ingestors::zitadel::guard, - handlers::ingestors::zitadel::handle, - ) - .command(handlers::ingestors::zitadel_scrape::COMMAND) - .guarded( - handlers::ingestors::zitadel_scrape::guard, - handlers::ingestors::zitadel_scrape::handle, - ) - .modeled_projector(projections.chat.clone()) - .handle(handlers::events::project_chat_messages::handle) - .events(handlers::events::project_auth_user::EVENTS) - .guarded( - handlers::events::project_auth_user::guard, - handlers::events::project_auth_user::handle, - ); - - let blob = Routes::new() - .with_repo(repo.queued_with(locks).aggregate::()) - .with_read_model_store(read_models) - .typed_command( - typed_command::>(blob_start::COMMAND) - .field_name("blob_games_start") - .roles(app_roles) - .emits(distributed::events![BlobStartedDomainEvent]) - // Same mutation IR as eventual. Atomic waits for the handler - // row and returns it (confirmDirectProjection). `.applies` is - // optional pre-network shell — map_json is RNG server-side. - .applies(distributed::state_preview! { - BlobStartedDomainEvent => blob_domain::BlobGameState { - game_id: input.game_id, - owner_id: trusted("x-user-id", "string"), - score: 0, - player_dead: unknown, - current_level: 1, - current_level_completed: unknown, - map_json: "[]", - status: "active", - } - }), - ) - .handle(blob_start::handle) - .typed_command( - typed_command::>(blob_move::COMMAND) - .field_name("blob_games_move") - .roles(app_roles) - .emits(distributed::events![BlobMovedDomainEvent]) - // Same client path as todos/chat: `.applies` maps command input - // into the optimistic layer. Client fills board fields from the - // pure simulate_move twin; server recomputes via domain. - .applies(distributed::state_preview! { - BlobMovedDomainEvent => blob_domain::BlobGameState { - game_id: input.game_id, - owner_id: trusted("x-user-id", "string"), - score: input.score, - player_dead: input.player_dead, - current_level: input.current_level, - current_level_completed: input.current_level_completed, - map_json: input.map_json, - status: input.status, - } - }), - ) - .handle(blob_move::handle) - .typed_command( - typed_command::>( - blob_start_level::COMMAND, - ) - .field_name("blob_games_start_level") - .roles(app_roles) - .emits(distributed::events![BlobLevelStartedDomainEvent]) - .applies(distributed::state_preview! { - BlobLevelStartedDomainEvent => blob_domain::BlobGameState { - game_id: input.game_id, - owner_id: trusted("x-user-id", "string"), - score: unknown, - player_dead: unknown, - current_level: unknown, - current_level_completed: unknown, - map_json: "[]", - status: "active", - } - }), - ) - .handle(blob_start_level::handle); - - // GraphQL-only public write surface (POST /todo.* must 404 — suite T0). - // Zitadel Action ingress still needs HTTP: those commands are registered above and - // re-mounted explicitly in `serve_with_oidc` when HTTP command wildcards are off. - Service::new() - .named("e2e-ui") - .without_http_command_routes() - .routes(todos) - .routes(chat) - .routes(blob) -} - -/// GraphQL over todos (owner-scoped) + chat_messages (shared room, live subscriptions). -/// -/// All write paths are **command mutations** (not read-model writes). Owner/author is -/// always the authenticated session principal. Roles: user, admin. -/// -/// Works with SQLite or Postgres pools through [`GraphqlPoolSource`]. -pub fn build_graphql_engine( - pool: impl Into, - service: &Service, - identity: IdentityConfig, - change_rx: Option>, -) -> Result { - build_graphql_engine_with_graphiql(pool, service, identity, change_rx, graphiql_enabled()) -} - -fn build_graphql_engine_with_graphiql( - pool: impl Into, - service: &Service, - identity: IdentityConfig, - change_rx: Option>, - graphiql: bool, -) -> Result { - let projections = projection_owners(); - let mut b = GraphqlEngine::builder(pool) - .protocol_token_key(E2E_PROTOCOL_TOKEN_KEY) - .roles(&["user", "admin", "anonymous"]) - // e2e-ui: eligible admin+user (multi-role principals may open); schema - // privilege remains user-only so owner-scoped models keep portable row - // policies for optimistic list inserts (see distributed_client_surface). - // e2e-ui-admin: elevated ops / all-rows views (/admin). - // e2e-ui-public: unauthenticated lobby read (anonymous privilege). - .client_application_surface_with_schema_roles( - DISTRIBUTED_CLIENT_SURFACE, - ["admin", "user"], - ["user"], - ) - .client_application_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, ["admin"]) - .client_application_surface(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, ["anonymous"]) - // user: only own rows. admin: all owners (UI: /admin all-notes view). - .model::(Todos::permissions()) - .model::(ChatMessages::permissions()) - .model::(BlobGames::permissions()) - // Imported IdP directory (join target for todo/blob owner and chat author). - // Readable by all authenticated roles; writes only via Zitadel projector. - .model::(AuthUsers::permissions()) - .service(service) - .client_projection_owners([ - projections.todo.into(), - projections.chat.into(), - projections.blob.into(), - ]) - .identity(identity) - // GraphiQL is a local template convenience. Disable with GRAPHIQL=0 - // (never ship a public edge with GraphiQL + DevHeaders). - .graphiql(graphiql); - if let Some(rx) = change_rx { - b = b.change_stream(rx); - } - b.build().map_err(|e| e.to_string()) -} - -pub fn dev_identity() -> IdentityConfig { - IdentityConfig::dev_headers() -} - -/// GraphiQL IDE: on by default for the fixture; set `GRAPHIQL=0` to disable. -pub fn graphiql_enabled() -> bool { - match std::env::var("GRAPHIQL") { - Ok(v) => { - let v = v.trim(); - !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off")) - } - Err(_) => true, - } -} - -/// Peel accidental outer quotes from env values (Make-include / double-wrap pollution). -fn env_clean(name: &str) -> String { - let mut s = std::env::var(name).unwrap_or_default().trim().to_string(); - for _ in 0..2 { - if s.len() >= 2 - && ((s.starts_with('\'') && s.ends_with('\'')) - || (s.starts_with('"') && s.ends_with('"'))) - { - s = s[1..s.len() - 1].trim().to_string(); - } else { - break; - } - } - s -} - -/// Prefer OidcBearer when `OIDC_ISSUER` + `OIDC_AUDIENCE` are set; else DevHeaders. -pub fn identity_from_env() -> IdentityConfig { - let iss = env_clean("OIDC_ISSUER"); - let aud = env_clean("OIDC_AUDIENCE"); - if iss.is_empty() || aud.is_empty() { - eprintln!("e2e-ui: OIDC_* unset — using DevHeaders (local only)"); - return dev_identity(); - } - let jwks = env_clean("OIDC_JWKS_URI"); - eprintln!("e2e-ui: OidcBearer issuer={iss} audience={aud}"); - oidc_bearer_config( - iss, - aud, - if jwks.is_empty() { None } else { Some(jwks) }, - None, - ) -} - -pub fn oidc_bearer_config( - issuer: impl Into, - audience: impl Into, - jwks_uri: Option, - static_jwks: Option, -) -> IdentityConfig { - let mut oidc = OidcConfig::new(issuer, audience); - if let Some(uri) = jwks_uri.filter(|s| !s.is_empty()) { - oidc.jwks_uri = Some(uri); - } - if let Some(jwks) = static_jwks { - oidc = oidc.with_static_jwks(jwks); - } - // Accept client_id as extra audience when present (human OIDC access tokens). - let cid = env_clean("OIDC_CLIENT_ID"); - if !cid.is_empty() { - oidc.extra_audiences = vec![cid]; - } - oidc.claim_map.engine_roles = vec!["user".into(), "admin".into()]; - oidc.claim_map.role_claims = vec![ - "groups".into(), - "roles".into(), - "realm_access.roles".into(), - "urn:zitadel:iam:org:project:roles".into(), - ]; - // Allow empty identity so e2e-ui-public (anonymous) can open without a Bearer. - // Invalid/malformed tokens still 401. - oidc.require_auth = false; - IdentityConfig::oidc_bearer(oidc) -} - -#[cfg(test)] -mod client_surface_tests { - use super::*; - - #[test] - fn pool_free_user_and_admin_exports_compile_real_manifests() { - distributed_client_surface() - .manifest() - .expect("normal application client manifest"); - distributed_admin_client_surface() - .manifest() - .expect("elevated application client manifest"); - } - - #[test] - fn application_todos_keep_portable_owner_row_policy_for_optimistic_list_inserts() { - use distributed::graphql::ClientRowPolicy; - - let manifest = distributed_client_surface().manifest().unwrap(); - let todos = manifest - .models - .iter() - .find(|model| model.typename == "Todos") - .expect("Todos model on application surface"); - match &todos.row_policy { - ClientRowPolicy::Predicate { expression } => { - let text = serde_json::to_string(expression).expect("serialize row policy"); - assert!( - text.contains("x-user-id") && text.contains("owner_id"), - "owner claim predicate must be client-portable: {text}" - ); - } - other => panic!( - "Todos must not collapse to server-only row policy (blocks optimistic create list membership); got {other:?}" - ), - } - - let blob = manifest - .models - .iter() - .find(|model| model.typename == "BlobGames") - .expect("BlobGames model on application surface"); - assert!( - matches!(blob.row_policy, ClientRowPolicy::Predicate { .. }), - "BlobGames should keep portable owner row policy" - ); - } - - #[test] - fn chat_manifest_uses_unit_partition_so_lobby_live_can_stay_active() { - let manifest = distributed_client_surface().manifest().unwrap(); - let program = manifest - .projection_programs - .iter() - .find(|program| program.name == "project_chat_messages") - .expect("Chat projection program should be exported"); - assert!( - program.arms.iter().all(|arm| matches!( - &arm.partition, - distributed::graphql::ClientProjectionPartition::Unit - )), - "lobby chat uses unit partition so the chat_messages live query can advertise \ - supported index evidence (room isolation stays in the GraphQL where clause). \ - Surface-wide live_resume may still be false when owner-scoped models share the surface." - ); - } - - #[test] - fn blob_projection_owner_has_no_async_fact_route() { - let manifest = distributed_client_surface().manifest().unwrap(); - let owner = manifest - .projectors - .iter() - .find(|projector| projector.name == "project_blob") - .expect("Blob direct owner should be exported"); - assert!(owner.facts.is_empty()); - assert!(!owner.causal_confirmation); - - let repository = InMemoryRepository::new(); - let service = build_service( - repository.clone(), - ClientSurfaceLocks::default(), - repository, - ); - let plan = service.subscription_plan(); - for event in [ - "todo.created", - "todo.renamed", - "todo.completed", - "todo.reopened", - "todo.archived", - "todo.force_archived", - "todo.purged", - "chat_message.posted", - ] { - assert!( - plan.events.iter().any(|candidate| candidate == event), - "eventual modeled projection must subscribe to {event}" - ); - } - for fact in [ - "blob.started", - "blob.initialized", - "blob.level_started", - "blob.moved", - ] { - assert!( - !plan.events.iter().any(|event| event == fact), - "direct-only Blob ownership must not register an async route for {fact}" - ); - } - } - - #[tokio::test] - async fn graphiql_does_not_change_the_postgres_runtime_client_manifest() { - let generated = distributed_client_surface().manifest().unwrap(); - let pool = sqlx::postgres::PgPoolOptions::new() - .connect_lazy("postgres://postgres:postgres@localhost/distributed") - .unwrap(); - let repository = distributed::PostgresRepository::new(pool.clone()); - let service = build_service( - repository.clone(), - distributed::PostgresLockManager::new(pool), - repository.clone(), - ); - let engine = - build_graphql_engine_with_graphiql(&repository, &service, dev_identity(), None, true) - .expect("engine"); - let runtime = engine - .client_manifest_for_application(DISTRIBUTED_CLIENT_SURFACE, &["admin", "user"]) - .unwrap(); - - assert_eq!(generated, runtime); - - let make_request = || { - serde_json::from_value(serde_json::json!({ - "query": "{ todos @skip(if: true) { todo_id } }", - "extensions": { - "distributed": { - "client": { - "surface": { - "kind": "application", - "name": DISTRIBUTED_CLIENT_SURFACE, - "roles": ["admin", "user"] - }, - "schemaHash": generated.schema_fingerprint - } - } - } - })) - .expect("generated application request") - }; - let mut session = distributed::microsvc::Session::new(); - session.set("x-roles", "user"); - session.set("x-user-id", "person-1"); - let response = engine.execute(&session, make_request()).await; - assert!( - !response.is_err(), - "the runtime must accept the generated application surface: {:?}", - response.errors - ); - // Multi-role admin principal may open the same portable contract. - let mut admin = session.clone(); - admin.set("x-roles", "admin,user"); - let admin_response = engine.execute(&admin, make_request()).await; - assert!( - !admin_response.is_err(), - "admin with user asserted roles must open e2e-ui: {:?}", - admin_response.errors - ); - let envelope = response - .extensions - .get("distributed") - .expect("distributed protocol envelope"); - let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); - assert_eq!( - envelope["schemaHash"], generated.schema_fingerprint, - "the authoritative response must attest the generated schema" - ); - } - - /// Empty-session open of e2e-ui-public + chat query (anonymous privilege). - /// - /// Bare protocol path for unauthenticated lobby peeks; UI route `/public` - /// documents the same surface name and extension shape. - #[tokio::test] - async fn public_surface_opens_and_queries_chat_without_identity() { - let generated = distributed_public_client_surface().manifest().unwrap(); - assert_eq!( - generated.surface, - distributed::graphql::ClientSurfaceIdentity::application( - DISTRIBUTED_PUBLIC_CLIENT_SURFACE, - ["anonymous"], - ) - ); - let repository = distributed::SqliteRepository::connect_and_migrate("sqlite::memory:") - .await - .expect("sqlite memory repo"); - let registry = e2e_readmodels::distributed_manifest() - .table_registry() - .expect("registry"); - repository - .bootstrap_table_schema_for_dev(®istry) - .await - .expect("bootstrap tables"); - let service = build_service( - repository.clone(), - ClientSurfaceLocks::default(), - repository.clone(), - ); - let engine = - build_graphql_engine_with_graphiql(&repository, &service, dev_identity(), None, false) - .expect("engine"); - let runtime = engine - .client_manifest_for_application(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, &["anonymous"]) - .expect("public surface registered"); - assert_eq!(generated.schema_fingerprint, runtime.schema_fingerprint); - - let request = serde_json::from_value(serde_json::json!({ - "query": "{ chat_messages(limit: 5, offset: 0) { message_id body room_id } }", - "extensions": { - "distributed": { - "client": { - "surface": { - "kind": "application", - "name": DISTRIBUTED_PUBLIC_CLIENT_SURFACE, - "roles": ["anonymous"] - }, - "schemaHash": generated.schema_fingerprint - } - } - } - })) - .expect("public application request"); - - // No x-user-id, no x-roles — unauthenticated principal. - let session = distributed::microsvc::Session::new(); - let response = engine.execute(&session, request).await; - assert!( - !response.is_err(), - "anonymous open + chat query must succeed: {:?}", - response.errors - ); - let data = response.data.into_json().expect("json data"); - assert!( - data.get("chat_messages").and_then(|v| v.as_array()).is_some(), - "expected chat_messages array: {data}" - ); - let envelope = response - .extensions - .get("distributed") - .expect("distributed protocol envelope"); - let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); - assert_eq!(envelope["schemaHash"], generated.schema_fingerprint); - } -} diff --git a/tests/e2e-ui/crates/suite/src/lib.rs b/tests/e2e-ui/crates/suite/src/lib.rs index 41333f72b..7fe9bb553 100644 --- a/tests/e2e-ui/crates/suite/src/lib.rs +++ b/tests/e2e-ui/crates/suite/src/lib.rs @@ -123,23 +123,27 @@ pub async fn wait_ready(base: &str, timeout: Duration) -> bool { } /// Default GraphQL helper: multi-role principals must name a surface. -/// - `user` → e2e-ui (eligible admin+user, privilege user) +/// - `user` → e2e-ui (eligible admin+user, schema privilege user) /// - `admin` → e2e-ui-admin (admin privilege) pub async fn graphql(base: &str, query: &str, user_id: &str, role: &str) -> Result { - let (application, surface_roles, schema_hash) = default_application_surface(role)?; + let (application, eligible_roles, schema_roles, schema_hash) = + default_application_surface(role)?; graphql_for_application( base, query, user_id, role, application, - &surface_roles, + &eligible_roles, + &schema_roles, &schema_hash, ) .await } -fn default_application_surface(role: &str) -> Result<(&'static str, Vec<&'static str>, String), String> { +fn default_application_surface( + role: &str, +) -> Result<(&'static str, Vec<&'static str>, Vec<&'static str>, String), String> { use e2e_service::{ distributed_admin_client_surface, distributed_client_surface, DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, @@ -152,6 +156,7 @@ fn default_application_surface(role: &str) -> Result<(&'static str, Vec<&'static Ok(( DISTRIBUTED_ADMIN_CLIENT_SURFACE, vec!["admin"], + vec!["admin"], manifest.schema_fingerprint, )) } @@ -162,6 +167,7 @@ fn default_application_surface(role: &str) -> Result<(&'static str, Vec<&'static Ok(( DISTRIBUTED_CLIENT_SURFACE, vec!["admin", "user"], + vec!["user"], manifest.schema_fingerprint, )) } @@ -174,7 +180,8 @@ pub async fn graphql_for_application( user_id: &str, role: &str, application: &str, - roles: &[&str], + eligible_roles: &[&str], + schema_roles: &[&str], schema_hash: &str, ) -> Result { graphql_request( @@ -187,7 +194,8 @@ pub async fn graphql_for_application( "surface": { "kind": "application", "name": application, - "roles": roles, + "eligible_roles": eligible_roles, + "schema_roles": schema_roles, }, "schemaHash": schema_hash, } diff --git a/tests/e2e-ui/crates/suite/tests/behavioral.rs b/tests/e2e-ui/crates/suite/tests/behavioral.rs index 8304af6b2..dfaa6565b 100644 --- a/tests/e2e-ui/crates/suite/tests/behavioral.rs +++ b/tests/e2e-ui/crates/suite/tests/behavioral.rs @@ -163,6 +163,7 @@ async fn t1a_application_surface_returns_actual_todo_upsert_and_causal_obligatio "user", DISTRIBUTED_CLIENT_SURFACE, &["admin", "user"], + &["user"], &manifest.schema_fingerprint, ) .await @@ -514,17 +515,18 @@ async fn t5_unauthenticated_rejected() { }} }}"# ); - // DevHeaders with no identity → mutation fails (require_user). + // DevHeaders with no identity → command guard rejects (causal_has_user). let (status, body) = graphql_raw(&base, &doc).await.expect(cases::UNAUTH); - // Prefer GraphQL errors over HTTP 401 depending on identity mode. + // GuardRejected is client-facing (often GraphQL errors / 400); Unauthorized + // remains 401 when identity is checked elsewhere. let has_err = body .get("errors") .and_then(|e| e.as_array()) .map(|a| !a.is_empty()) .unwrap_or(false); assert!( - status == 401 || has_err, - "{}: expected 401 or GraphQL errors, got HTTP {status} {body}", + status == 401 || status == 400 || has_err, + "{}: expected 401/400 or GraphQL errors, got HTTP {status} {body}", cases::UNAUTH ); eprintln!("{} ok", cases::UNAUTH); diff --git a/tests/e2e-ui/crates/todo-domain/src/lib.rs b/tests/e2e-ui/crates/todo-domain/src/lib.rs index 25499f3bf..a2c61c84a 100644 --- a/tests/e2e-ui/crates/todo-domain/src/lib.rs +++ b/tests/e2e-ui/crates/todo-domain/src/lib.rs @@ -9,8 +9,8 @@ pub mod models; pub use models::{ - Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, TodoCreatedDomainEvent, - TodoDomainIdentity, TodoError, TodoForceArchivedDomainEvent, TodoPurgedDomainEvent, - TodoReassignedDomainEvent, TodoRenamedDomainEvent, TodoReopenedDomainEvent, TodoState, - TodoStatus, + domain_commands, Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, + TodoCreatedDomainEvent, TodoDomainIdentity, TodoError, TodoForceArchivedDomainEvent, + TodoPurgedDomainEvent, TodoReassignedDomainEvent, TodoRenamedDomainEvent, + TodoReopenedDomainEvent, TodoState, TodoStatus, }; diff --git a/tests/e2e-ui/crates/todo-domain/src/models/mod.rs b/tests/e2e-ui/crates/todo-domain/src/models/mod.rs index 357d0459c..bca3dacb0 100644 --- a/tests/e2e-ui/crates/todo-domain/src/models/mod.rs +++ b/tests/e2e-ui/crates/todo-domain/src/models/mod.rs @@ -6,9 +6,10 @@ mod todo_state; mod todo_status; pub use todo::{ - Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, TodoCreatedDomainEvent, - TodoDomainIdentity, TodoForceArchivedDomainEvent, TodoPurgedDomainEvent, - TodoReassignedDomainEvent, TodoRenamedDomainEvent, TodoReopenedDomainEvent, + domain_commands, Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, + TodoCreatedDomainEvent, TodoDomainIdentity, TodoForceArchivedDomainEvent, + TodoPurgedDomainEvent, TodoReassignedDomainEvent, TodoRenamedDomainEvent, + TodoReopenedDomainEvent, }; pub use todo_error::TodoError; pub use todo_state::TodoState; 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 6287b149d..0a92fb722 100644 --- a/tests/e2e-ui/crates/todo-domain/src/models/todo.rs +++ b/tests/e2e-ui/crates/todo-domain/src/models/todo.rs @@ -252,6 +252,20 @@ mod tests { ); } + #[test] + fn domain_commands_create_matches_created_event_contract() { + use distributed::domain_event::DomainEventContract; + use distributed::graphql::CommandEventSet; + + let from_transition = domain_commands::Create::command_event_set(); + let from_event = distributed::events![TodoCreatedDomainEvent]; + assert_eq!( + from_transition, from_event, + "Create transition must materialize the same emit set as TodoCreatedDomainEvent" + ); + assert_eq!(TodoCreatedDomainEvent::EVENT_NAME, "todo.created"); + } + #[test] fn create_after_purge_is_rejected_without_emitting_events() { let mut todo = open_todo(); diff --git a/tests/e2e-ui/e2e/blob.user.spec.ts b/tests/e2e-ui/e2e/blob.user.spec.ts index 86988536f..8b2cb2666 100644 --- a/tests/e2e-ui/e2e/blob.user.spec.ts +++ b/tests/e2e-ui/e2e/blob.user.spec.ts @@ -74,50 +74,83 @@ test.describe('blob game (alice)', () => { }); }); - let releaseMove!: () => void; - const releaseMovePromise = new Promise((resolve) => { - releaseMove = resolve; + let releaseFirstMove!: () => void; + const firstMoveRelease = new Promise((resolve) => { + releaseFirstMove = resolve; }); - let moveReachedServer!: () => void; - const moveReachedServerPromise = new Promise((resolve) => { - moveReachedServer = resolve; + let firstMoveBlocked!: () => void; + const firstMoveIsBlocked = new Promise((resolve) => { + firstMoveBlocked = resolve; + }); + let dispatchedMoves = 0; + const moveResponses: import('@playwright/test').Response[] = []; + page.on('response', (response) => { + if ((response.request().postData() ?? '').includes('blob_games_move')) { + moveResponses.push(response); + } }); await page.route('**/graphql', async (route) => { if (!(route.request().postData() ?? '').includes('blob_games_move')) { await route.continue(); return; } + dispatchedMoves += 1; + if (dispatchedMoves === 1) { + firstMoveBlocked(); + // Hold before route.fetch: the server has not seen the first move, + // and the runtime must retain later local layers while serializing + // same-game transport behind it. + await firstMoveRelease; + } const response = await route.fetch(); - moveReachedServer(); - await releaseMovePromise; await route.fulfill({ response }); }); - // Move right (pad or keyboard). Either score increments or stays at an - // edge/wall, but the cached board must remain mounted throughout. - const moveResponsePromise = page.waitForResponse( - (r) => - r.url().includes('/graphql') && - r.request().method() === 'POST' && - (r.request().postData() ?? '').includes('blob_games_move'), - { timeout: 20_000 } - ); + // The first local pure moves right while its request is held before the + // server. A second legal move must stack immediately on that optimistic + // board even though its transport remains ordered behind the first. await page.keyboard.press('ArrowRight'); - await moveReachedServerPromise; - // The generated Atomic preview must paint before the held GraphQL - // response is allowed back to the browser. + await firstMoveIsBlocked; await expect(board.locator('.tile-player')).toHaveAttribute( 'aria-label', 'r0 c1', { timeout: 200 } ); + const rightClass = + (await page + .locator('.blob-board .cell[aria-label="r0 c2"]') + .getAttribute('class')) ?? ''; + const secondMove = rightClass.includes('tile-hole') + ? ({ key: 'ArrowDown', label: 'r1 c1' } as const) + : ({ key: 'ArrowRight', label: 'r0 c2' } as const); + await expect( + page.locator(`.blob-board .cell[aria-label="${secondMove.label}"]`) + ).not.toHaveClass(/tile-hole/); + await page.keyboard.press(secondMove.key); + await expect(board.locator('.tile-player')).toHaveAttribute( + 'aria-label', + secondMove.label, + { timeout: 200 } + ); + expect( + dispatchedMoves, + 'later same-game transport must wait without blocking its optimistic layer' + ).toBe(1); await expect(page.getByTestId('blob-new-game')).toBeEnabled(); for (const button of await page.locator('.pad-btn').all()) { await expect(button).toBeEnabled(); } - releaseMove(); - const moveResp = await moveResponsePromise; - expect(moveResp.ok(), `blob_games_move HTTP ${moveResp.status()}`).toBeTruthy(); + releaseFirstMove(); + await expect + .poll(() => moveResponses.length, { timeout: 20_000 }) + .toBe(2); + for (const response of moveResponses) { + expect(response.ok(), `blob_games_move HTTP ${response.status()}`).toBeTruthy(); + } + await expect(board.locator('.tile-player')).toHaveAttribute( + 'aria-label', + secondMove.label + ); await page.unrouteAll({ behavior: 'wait' }); await expect(board).toBeVisible(); await expect(page.locator('.inline-alert, .blob-empty')).toHaveCount(0); @@ -146,6 +179,53 @@ test.describe('blob game (alice)', () => { ).toBeGreaterThan(0); }); + test('new game replaces a selected game without a page reload', async ({ page }) => { + await page.goto('/blob'); + await expect(page.locator('[data-blob-hydrated="1"]')).toBeVisible({ + timeout: 15_000 + }); + await expect(page.getByTestId('blob-start-game')).toBeEnabled({ timeout: 10_000 }); + + const startResponse = page.waitForResponse( + (response) => + (response.request().postData() ?? '').includes('blob_games_start'), + { timeout: 20_000 } + ); + await page.getByTestId('blob-start-game').click(); + expect((await startResponse).ok()).toBeTruthy(); + await expect(page.locator('.blob-board')).toBeVisible({ timeout: 15_000 }); + const firstGameUrl = page.url(); + + const continuityToken = `blob-new-game-${Date.now()}`; + await page.evaluate((token) => { + Object.assign(globalThis, { __distributedBlobNewGameToken: token }); + }, continuityToken); + const newGameResponse = page.waitForResponse( + (response) => + (response.request().postData() ?? '').includes('blob_games_start'), + { timeout: 20_000 } + ); + await page.getByTestId('blob-new-game').click(); + expect((await newGameResponse).ok()).toBeTruthy(); + + await expect(page).not.toHaveURL(firstGameUrl); + await expect(page.locator('.blob-board .tile-player')).toHaveAttribute( + 'aria-label', + 'r0 c0', + { timeout: 1_000 } + ); + await expect(page.locator('.blob-empty')).toHaveCount(0); + expect( + await page.evaluate( + () => + (globalThis as typeof globalThis & { + __distributedBlobNewGameToken?: string; + }).__distributedBlobNewGameToken + ), + 'New game must preserve the current document while changing routes' + ).toBe(continuityToken); + }); + test('a revalidation started before an atomic move cannot roll it back with later evidence', async ({ page }) => { await page.goto('/blob'); await expect(page.locator('[data-blob-hydrated="1"]')).toBeVisible({ timeout: 15_000 }); @@ -165,6 +245,7 @@ test.describe('blob game (alice)', () => { 'aria-label', 'r0 c0' ); + await expect(page).toHaveURL(/\/blob\/[^/]+$/); const gameId = decodeURIComponent(new URL(page.url()).pathname.split('/').at(-1)!); let releaseHeldQuery!: () => void; diff --git a/tests/e2e-ui/e2e/chat.user.spec.ts b/tests/e2e-ui/e2e/chat.user.spec.ts index b748d07cc..d1cf8c01a 100644 --- a/tests/e2e-ui/e2e/chat.user.spec.ts +++ b/tests/e2e-ui/e2e/chat.user.spec.ts @@ -56,7 +56,7 @@ test.describe('chat (alice)', () => { const fullPageResponse = await expectOptimisticPaint(page, { needle: 'chat_messages_post', holdMs: 1_500, - assertWithinMs: 1_000, + assertWithinMs: 300, act: async () => { await page.locator('#chat-body').fill(fullPageBody); await page.getByRole('button', { name: /send/i }).click(); @@ -150,7 +150,7 @@ test.describe('chat (alice)', () => { await expectOptimisticPaint(page, { needle: 'chat_messages_post', holdMs: 1_500, - assertWithinMs: 1_000, + assertWithinMs: 300, act: async () => { await page.locator('#chat-body').fill(body); await page.getByRole('button', { name: /send/i }).click(); diff --git a/tests/e2e-ui/e2e/helpers/optimism.ts b/tests/e2e-ui/e2e/helpers/optimism.ts index 1cafa88c0..4263c599f 100644 --- a/tests/e2e-ui/e2e/helpers/optimism.ts +++ b/tests/e2e-ui/e2e/helpers/optimism.ts @@ -1,9 +1,9 @@ /** * Browser proof that a command paints optimistically. * - * Hold the GraphQL mutation response past the assert deadline. If the UI - * updates before the held response is fulfilled, the paint came from the - * client optimistic layer — not the wire. + * Hold the GraphQL mutation before it reaches the server. If the UI updates + * before dispatch is released, the paint came from the client optimistic + * layer — not a response or subscription frame. */ import type { Page, Response } from '@playwright/test'; import { expect } from '@playwright/test'; @@ -40,7 +40,7 @@ function mutationMatches(postData: string | null, needle: string): boolean { /** * Install a GraphQL route that continues non-matching requests and delays - * matching mutation responses by holdMs. Returns a disposer. + * matching mutation dispatch by holdMs. Returns a disposer. */ export async function holdGraphqlMutation( page: Page, @@ -52,8 +52,10 @@ export async function holdGraphqlMutation( await route.continue(); return; } - const response = await route.fetch(); + // Hold before route.fetch so neither the command response nor a server + // projection/subscription can produce the state under assertion. await new Promise((resolve) => setTimeout(resolve, holdMs)); + const response = await route.fetch(); await route.fulfill({ response }); }); return async () => { @@ -72,7 +74,7 @@ export type OptimisticPaintOptions = HoldMutationOptions & { /** * Run act under a held mutation and require assertOptimistic before the - * response is fulfilled. + * command reaches the server. */ export async function expectOptimisticPaint( page: Page, @@ -96,8 +98,7 @@ export async function expectOptimisticPaint( try { await options.act(); - // Bound the optimistic assert so a late paint from the delayed response - // cannot satisfy the expectation. + // Bound the optimistic assert so a later server result cannot satisfy it. await expect .poll(async () => { try { diff --git a/tests/e2e-ui/e2e/todos.user.spec.ts b/tests/e2e-ui/e2e/todos.user.spec.ts index a9bfb132f..d3a97a11f 100644 --- a/tests/e2e-ui/e2e/todos.user.spec.ts +++ b/tests/e2e-ui/e2e/todos.user.spec.ts @@ -253,13 +253,14 @@ test.describe('todos (alice)', () => { if ( !body.includes('todos_create') && !body.includes('todos_complete') && - !body.includes('todos_reopen') + !body.includes('todos_reopen') && + !body.includes('todos_archive') ) { await route.continue(); return; } const response = await route.fetch(); - await new Promise((resolve) => setTimeout(resolve, 700)); + await new Promise((resolve) => setTimeout(resolve, 1_500)); await route.fulfill({ response }); }); let completeRequests = 0; @@ -280,10 +281,16 @@ test.describe('todos (alice)', () => { .locator('.panel') .filter({ has: page.getByRole('heading', { name: /^open$/i }) }) .locator('.item', { hasText: title }); - // Create must paint list membership optimistically under the delayed route - // (first-page offset insert + truncate). Assert before the wire returns. - await expect(openItem).toBeVisible({ timeout: 400 }); + // The sourced transition supplies `status = Open`; the Todo projection + // turns that event value into the upsert that must paint before the held + // Eventual response. + await expect(openItem).toBeVisible({ timeout: 1_000 }); + expect( + await page.locator('.board button:disabled').count(), + 'routine command concurrency guards must not flash Todo row controls disabled' + ).toBe(0); await createResponse; + await expect(openItem).toBeVisible(); expect( await page.locator('.board button:disabled').count(), 'routine command concurrency guards must not flash Todo row controls disabled' @@ -296,25 +303,22 @@ test.describe('todos (alice)', () => { (response) => (response.request().postData() ?? '').includes('todos_complete') ); - await openItem.getByRole('button', { name: /^done$/i }).evaluate((button) => { - button.click(); - button.click(); - }); + await openItem.getByRole('button', { name: /^done$/i }).click(); const doneItem = page .locator('.panel') .filter({ has: page.getByRole('heading', { name: /^done$/i }) }) .locator('.item', { hasText: title }); - await expect(doneItem).toBeVisible({ timeout: 400 }); + // The projection maps the transition's `Completed` constant to a loaded-row + // patch, so filtered membership changes before the wire is released. + await expect(doneItem).toBeVisible({ timeout: 1_000 }); expect( await page.locator('.board button:disabled').count(), 'routine command concurrency guards must not flash Todo row controls disabled' ).toBe(0); - expectBinarySorted(await visibleTodoOrders(page)); await completeResponse; - expect( - completeRequests, - 'optimistic state must suppress a duplicate action without disabling controls' - ).toBe(1); + await expect(doneItem).toBeVisible(); + expectBinarySorted(await visibleTodoOrders(page)); + expect(completeRequests, 'complete must reach the server once').toBe(1); await expect(doneItem).toBeVisible(); expectBinarySorted(await visibleTodoOrders(page)); await page.waitForTimeout(750); @@ -337,13 +341,13 @@ test.describe('todos (alice)', () => { .locator('.panel') .filter({ has: page.getByRole('heading', { name: /^open$/i }) }) .locator('.item', { hasText: title }); - // Reopen must paint the row back into Open before the delayed wire returns. - await expect(reopenedItem).toBeVisible({ timeout: 400 }); + await expect(reopenedItem).toBeVisible({ timeout: 1_000 }); expect( await page.locator('.board button:disabled').count(), 'routine command concurrency guards must not flash Todo row controls disabled' ).toBe(0); await reopenResponse; + await expect(reopenedItem).toBeVisible(); await page.waitForTimeout(750); const reopenOrderFrames = await stopTodoOrderTrace(page); expect( @@ -364,6 +368,7 @@ test.describe('todos (alice)', () => { (response.request().postData() ?? '').includes('todos_complete') ); await reopenedItem.getByRole('button', { name: /^done$/i }).click(); + await expect(doneItem).toBeVisible({ timeout: 1_000 }); await authoritativeCompleteResponse; await page.waitForTimeout(750); const authoritativeCompleteFrames = await stopTodoOrderTrace(page); @@ -383,6 +388,7 @@ test.describe('todos (alice)', () => { (response.request().postData() ?? '').includes('todos_reopen') ); await doneItem.getByRole('button', { name: /^reopen$/i }).click(); + await expect(reopenedItem).toBeVisible({ timeout: 1_000 }); await authoritativeReopenResponse; await page.waitForTimeout(750); const authoritativeReopenFrames = await stopTodoOrderTrace(page); @@ -398,6 +404,19 @@ test.describe('todos (alice)', () => { ).toBe(true); await expect(reopenedItem).toBeVisible(); expectBinarySorted(await visibleTodoOrders(page)); + + const archiveResponse = page.waitForResponse((response) => + (response.request().postData() ?? '').includes('todos_archive') + ); + await reopenedItem.getByRole('button', { name: /^archive$/i }).click(); + const archivedItem = page.locator('.archive .item', { hasText: title }); + await expect(archivedItem).toBeAttached({ timeout: 1_000 }); + await page.locator('details.archive').evaluate((details) => { + (details as HTMLDetailsElement).open = true; + }); + await expect(archivedItem).toBeVisible({ timeout: 1_000 }); + await archiveResponse; + await expect(archivedItem).toBeVisible(); await page.unrouteAll({ behavior: 'wait' }); }); }); diff --git a/tests/e2e-ui/gitops/README.md b/tests/e2e-ui/gitops/README.md new file mode 100644 index 000000000..45cf822d5 --- /dev/null +++ b/tests/e2e-ui/gitops/README.md @@ -0,0 +1,26 @@ +# e2e-ui local gitops + +```text +gitops/ + cluster/ # fixture CP tree (real meta repos: put this at meta root) + envs/ + local/ # app Applications → worktree namespaces +``` + +```bash +# Terminal 1: cluster apply/watch +hops local gitops cluster ./gitops/cluster \ + --cluster-provider kind --docker-provider dory \ + --cluster-name hops --context kind-hops + +# Terminal 2: per-workspace apps apply/watch +hops local gitops worktree ./gitops/envs/local --name alice \ + --cluster-provider kind --docker-provider dory \ + --cluster-name hops --context kind-hops + +# One-shot (CI / scripts): add --once +# hops local gitops cluster ./gitops/cluster --once +``` + +On multi-project metas: put `gitops/cluster` at the **meta root**; projects keep +`.gitops/deploy` charts; env Application YAMLs point at those charts. diff --git a/tests/e2e-ui/gitops/cluster/README.md b/tests/e2e-ui/gitops/cluster/README.md new file mode 100644 index 000000000..26df8c24c --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/README.md @@ -0,0 +1,80 @@ +# `gitops/cluster` — local control-plane configuration + +> **Real meta repos:** put this tree at the **meta root** (`gitops/cluster/`), +> not inside a single project. One local CP (dory) serves every worktree; apps +> only get different **namespaces**. This e2e-ui copy is a self-contained +> fixture — same files, nested under the test app. Point hops with +> `hops local gitops cluster ./gitops/cluster` or `HOPS_LOCAL_CLUSTER` when +> applying a meta-root cluster tree. + +Platform / CP resources for the **shared** local control plane: + +```text +/ + gitops/cluster/ # ← this tree (AuthStack, PSQLStack, configurations) + gitops/envs/local/ # Application YAMLs → namespace = --name + clients/foo/.gitops/deploy/ + platform/api/.gitops/deploy/ +``` + +| Path | Contents | +|------|----------| +| `configurations/` | Crossplane `Configuration` installs (psql-stack, auth-stack, secret-stack) | +| `stacks/psql.yaml` | **`PSQLStack` XR** — CNPG + SC `psql` (local-path) | +| `stacks/auth.yaml` | **`AuthStack` XR** — Zitadel + embedded Postgres | +| `secrets/` | **`SecretStack`** + vault-auth-delegator CRB (ESO + Vault; k8s auth via Helm postStart) | +| `auth/` | Auth residuals (e.g. masterkey ExternalSecret), not the XR claim | +| `providers/` | Provider installs + per-provider DRCs (`helm.yaml` / `helm-drc.yaml`, …) | +| `providerconfigs/` | ProviderConfig shapes (`secretRef` only) | +| `e2e-identity/` | **Moved** — worktree UI chart `identity.*` values (pointer README only) | + +## Apply + watch (local gitops vibes) + +This fixture pins the auth stack's content-derived local dev image because its +Grant reference support is newer than the published package. Build and push +that package once before the cluster watcher. Re-run the source install and +update `configurations/auth-stack.yaml` when the auth package content changes. + +```bash +# One-time package build/push for the pin in configurations/auth-stack.yaml +hops config install --path /xrs/stacks/k8s/auth \ + --cluster-provider kind --docker-provider dory \ + --cluster-name hops --context kind-hops + +# Terminal 1: shared control-plane tree (apply + watch) +hops local gitops cluster ./gitops/cluster \ + --cluster-provider kind --docker-provider dory \ + --cluster-name hops --context kind-hops + +# Terminal 2: per-workspace Applications + charts (apply + watch) +hops local gitops worktree ./gitops/envs/local --name alice \ + --cluster-provider kind --docker-provider dory \ + --cluster-name hops --context kind-hops +``` + +Crossplane reconciles XRs (`PSQLStack`, `AuthStack`, `SecretStack`, …) after each apply. +Edit `stacks/auth.yaml`, `stacks/psql.yaml`, or `secrets/stack.yaml` → saved → applied → CP converges. + +**SecretStack (local):** install the Configuration from source first +(`hops config install --path …/xrs/stacks/aws/secret`), then apply this cluster tree +so `secrets/stack.yaml` is not stuck Unpacking. + +## vs `gitops/envs/` + +| Tree | Purpose | +|------|---------| +| **cluster/** | CP packages + platform XRs (PSQLStack, AuthStack) | +| **envs/\-web` — redirects + Login V2 baseUri) | + +| Secret | Purpose | +|--------|---------| +| `e2e-human-passwords` | Keys `alice`, `bob`, `admin` (cluster-shared; local chart seeds when `local: true`) | +| `zitadel-credentials` | ProviderConfig / ClusterProviderConfig (`hops local zitadel`) | +| Oidc connection Secret | Oidc MR `writeConnectionSecretToRef` → `attribute.client_id` / `attribute.client_secret` (UI mounts these) | +| `e2e-ui-oidc` | `AUTH_SECRET` (+ optional `ZITADEL_SERVICE_USER_TOKEN` from AuthStack `auth/login-client`) | + +```bash +# Humans (if not using chart local seed): +kubectl -n default create secret generic e2e-human-passwords \ + --from-literal=alice=Password1! \ + --from-literal=bob=Password1! \ + --from-literal=admin=Password1! + +# Login V2 service user (after AuthStack Ready): +kubectl -n create secret generic e2e-ui-oidc \ + --from-literal=AUTH_SECRET='local-workbench-dev-auth-secret-not-for-prod' \ + --from-literal=ZITADEL_SERVICE_USER_TOKEN="$(kubectl -n auth get secret login-client -o jsonpath='{.data.pat}' | base64 -d)" \ + --dry-run=client -o yaml | kubectl apply -f - +# OIDC_CLIENT_ID/SECRET: do not hand-copy — Deployment reads Oidc connection Secret. +``` + +Demo login (every env): **alice / bob / admin · Password1!** + +Do **not** commit live `orgId`, Project external ids, or OIDC client ids into Application values. MRs use `projectIdRef` and ProviderConfig org default. +## e2e-ui app (`ns: --name`) + +| Secret | Keys | Used by | +|--------|------|---------| +| `e2e-ui-oidc` | `AUTH_SECRET`, `ZITADEL_SERVICE_USER_TOKEN` | UI session/login residuals | +| `e2e-ui--oidc-conn` | `attribute.client_id`, `attribute.client_secret` | Oidc MR connection secret; UI client env | + +```bash +# Login-client PAT: AuthStack residual secret auth/login-client key pat +kubectl -n dogfood create secret generic e2e-ui-oidc \ + --from-literal=AUTH_SECRET='local-workbench-dogfood-auth-secret-not-for-prod' \ + --from-literal=ZITADEL_SERVICE_USER_TOKEN="$(kubectl -n auth get secret login-client -o jsonpath='{.data.pat}' | base64 -d)" +``` + +Do not copy OIDC client IDs or secrets into this Secret. The Oidc managed +resource writes them to `e2e-ui--oidc-conn`, and the Deployment +references `attribute.client_id` / `attribute.client_secret` there. + +Non-secret OIDC config (issuer, AUTH_URL) stays in `ui/.gitops/deploy/values.yaml`. + +### Login V2 (required for browser sign-in) + +Gitops (UI chart when `identity.enabled`): + +1. **Oidc** app MR — redirects + `loginVersion.loginV2.baseUri` from release ns +2. **Features** MR (`instance.zitadel…/Features`) — instance `loginV2.required` + + `baseUri` so authorize redirects to this UI’s `/login` + +Both use `http://e2e-ui-ui..svc.cluster.local:5180`. Instance Features +is global per AuthStack (last applied worktree wins). Do not hand-`PUT` +`/v2/features/instance` — re-reconcile the UI chart instead. + +Prefer app-level baseUri over instance-wide +defaults so multiple worktrees can coexist. + +## SecretStack / ESO + Vault (`secrets/`) + +Local fixture uses **`backend: vault`** with **`vault.install: true`** (in-cluster +Vault Helm + ESO). No AWS PodIdentity on dory. + +| Resource | Purpose | +|----------|---------| +| `secrets/stack.yaml` | SecretStack XR — ESO + Vault Helm + ClusterSecretStore | +| `secrets/vault-auth-delegator.yaml` | CRB: Vault SA → `system:auth-delegator` (TokenReview) | +| Helm `vault` `server.postStart` | Init/unseal (once), KV mount, k8s auth + ESO role | +| PVC (`dataStorage` 1Gi) | File storage under `/vault/data` (survives pod restarts) | +| ClusterSecretStore `vault` | ESO → Vault (Ready after postStart + CRB) | + +### Persistence + +`server.dev` is **off**. Vault runs standalone with `storage "file"` on a PVC +(default StorageClass, dory: `local-path`). First start initializes and writes: + +```text +/vault/data/.hops-init # Unseal Key 1 + Initial Root Token (chmod 600) +``` + +Later starts: unseal from that file, re-apply k8s auth (idempotent). **KV data +survives pod restarts**; deleting the PVC is a full reset. + +Root token is **not** the fixed string `root` (that was dev-only). For CLI writes: + +```bash +export VAULT_TOKEN="$( + kubectl -n vault exec vault-0 -- \ + awk '/^Initial Root Token:/{print $NF}' /vault/data/.hops-init +)" +export VAULT_ADDR=http://127.0.0.1:8200 +# kubectl port-forward -n vault svc/vault 8200:8200 # if needed +hops secrets sync vault -y +``` + +### Declarative trust path + +1. Apply cluster gitops (SecretStack + CRB). +2. postStart: init (once) → unseal → `secret/` KV v2 → k8s auth for ESO. +3. ClusterSecretStore `vault` Ready. +4. **Write** with `hops secrets sync vault`. +5. **Read** via ExternalSecrets (auth masterkey, UI oidc/humans). + +Install package from source before first apply: + +```bash +hops config install --path /xrs/stacks/aws/secret --context dory +hops local gitops cluster ./gitops/cluster +``` + +Switching an existing **dev/inmem** Vault release to file storage may need a +one-time recreate of the Helm release / PVC (data was ephemeral anyway). + +Do **not** put the SecretStack claim under meta `local/` — that folder is for +ad-hoc colima-style one-off claims, not the workbench cluster gitops path. + +## Rules + +1. **Never** put passwords, PATs, client secrets, or masterkeys in committed YAML. +2. Gitops may name Secrets and keys (`secretRef` / `secretEnv`). +3. Create Secrets before the XRs/Deployments that need them (or accept CrashLoop until present). +4. Cloud: prefer ESO / SOPS; local: `kubectl create` or `hops local zitadel|aws|github`. +5. Platform secrets engine for local CP lives in **`gitops/cluster/secrets/`**, not `local/`. diff --git a/tests/e2e-ui/gitops/cluster/auth/README.md b/tests/e2e-ui/gitops/cluster/auth/README.md new file mode 100644 index 000000000..7da694c52 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/auth/README.md @@ -0,0 +1,57 @@ +# Auth (local) + +| File | Kind | +|------|------| +| [`../stacks/auth.yaml`](../stacks/auth.yaml) | `AuthStack` XR — Zitadel + embedded `PSQLCluster` | +| `external-secret-masterkey.yaml` | Optional ESO materialize of `zitadel-masterkey` from Vault | + +## Local addressing (no ingress) + +| | Value | +|--|--------| +| Install namespace | **`auth`** | +| AuthStack XR name | **`zitadel`** | +| Helm Service (default fullname) | **`zitadel-zitadel.auth.svc.cluster.local:8080`** | +| OIDC issuer / `domain` | `http://zitadel-zitadel.auth.svc.cluster.local:8080` | +| Gateway / ingress | disabled — ClusterIP only for now | + +## Apply order + +```bash +hops local start --cluster-provider kind --docker-provider dory \ + --cluster-name hops --gitops ./gitops/cluster +# or after configurations: +hops local gitops cluster ./gitops/cluster + +kubectl get authstack zitadel -n default +kubectl get svc -n auth +# expect: zitadel-zitadel (API) — not a separate short-name alias +``` + +## e2e-ui identity (worktree) + +App Project / humans / OIDC app render from the **UI** chart when +`gitops/envs/local/ui.yaml` sets `identity.enabled: true` (not cluster gitops). + +ProviderConfig residual (cluster): + +```bash +hops local zitadel --source-context dory --source-namespace auth \ + --domain zitadel-zitadel.auth.svc.cluster.local --port 8080 --insecure +``` + +## Secrets + +Masterkey is **not** inline — `secretRef` to `zitadel-masterkey` in ns `auth`: + +```bash +kubectl -n auth create secret generic zitadel-masterkey \ + --from-literal=masterkey='hops-like-basketball-but-for-ops' # exactly 32 bytes +``` + +Create **before** AuthStack becomes Ready (or re-apply after). Full table: +[../SECRETS.md](../SECRETS.md). + +## Residual + +- Host browser access without ingress is later (gateway / map / promote) diff --git a/tests/e2e-ui/gitops/cluster/auth/external-secret-masterkey.yaml b/tests/e2e-ui/gitops/cluster/auth/external-secret-masterkey.yaml new file mode 100644 index 000000000..3ed7a6996 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/auth/external-secret-masterkey.yaml @@ -0,0 +1,27 @@ +# Materialize AuthStack masterkey from Vault so it survives ns/worktree churn. +# Requires ClusterSecretStore vault Ready (Vault Helm postStart + vault-auth-delegator) +# and path auth/zitadel-masterkey written via: hops secrets sync vault +# See SECRETS.md. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: zitadel-masterkey + namespace: auth + labels: + hops.ops.com.ai/fixture: e2e-ui + hops.ops.com.ai/secret: zitadel-masterkey +spec: + refreshInterval: 1m + secretStoreRef: + name: vault + kind: ClusterSecretStore + target: + name: zitadel-masterkey + creationPolicy: Owner + template: + type: Opaque + data: + - secretKey: masterkey + remoteRef: + key: auth/zitadel-masterkey + property: masterkey diff --git a/tests/e2e-ui/gitops/cluster/configurations/auth-stack.yaml b/tests/e2e-ui/gitops/cluster/configurations/auth-stack.yaml new file mode 100644 index 000000000..81732ea27 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/configurations/auth-stack.yaml @@ -0,0 +1,13 @@ +# Crossplane Configuration for AuthStack (Zitadel + auth XRDs). +# This fixture exercises Grant references that are newer than the published +# package. Build/push this content-derived dev tag before starting cluster +# GitOps (see ../README.md). The watched pin then matches the source install +# instead of replacing it with an older package. +# Depends on: provider-helm, provider-kubernetes, (optional) provider-upjet-zitadel. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: auth-stack +spec: + package: registry.crossplane-system.svc.cluster.local:5000/hops-ops/auth-stack:dev-15db94d94e7b + packagePullPolicy: Always diff --git a/tests/e2e-ui/gitops/cluster/configurations/psql-stack.yaml b/tests/e2e-ui/gitops/cluster/configurations/psql-stack.yaml new file mode 100644 index 000000000..2427526f0 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/configurations/psql-stack.yaml @@ -0,0 +1,12 @@ +# Crossplane Configuration for the PostgreSQL platform stack. +# Prefer building/pushing from source into the local registry: +# hops config install /xrs/stacks/k8s/psql +# Or set package to a published tag when available. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: psql-stack +spec: + package: ghcr.io/hops-ops/psql-stack:v0.9.1 + # Local registry after hops local start + config install: + # package: registry.crossplane-system.svc.cluster.local:5000/hops-ops/psql-stack: diff --git a/tests/e2e-ui/gitops/cluster/configurations/secret-stack.yaml b/tests/e2e-ui/gitops/cluster/configurations/secret-stack.yaml new file mode 100644 index 000000000..a69929cee --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/configurations/secret-stack.yaml @@ -0,0 +1,11 @@ +# Crossplane Configuration for platform SecretStack (ESO + AWS|Vault backends). +# Build and publish the source package to the control plane's local registry first: +# hops config install --path /xrs/stacks/aws/secret \ +# --cluster-provider kind --docker-provider dory \ +# --cluster-name hops --context kind-hops +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: secret-stack +spec: + package: registry.crossplane-system.svc.cluster.local:5000/hops-ops/secret-stack:dev-29d20a9a9aa7 diff --git a/tests/e2e-ui/gitops/cluster/providerconfigs/helm.yaml b/tests/e2e-ui/gitops/cluster/providerconfigs/helm.yaml new file mode 100644 index 000000000..148cb4fc9 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providerconfigs/helm.yaml @@ -0,0 +1,8 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: InjectedIdentity diff --git a/tests/e2e-ui/gitops/cluster/providerconfigs/kubernetes.yaml b/tests/e2e-ui/gitops/cluster/providerconfigs/kubernetes.yaml new file mode 100644 index 000000000..95029bccc --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providerconfigs/kubernetes.yaml @@ -0,0 +1,8 @@ +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: InjectedIdentity diff --git a/tests/e2e-ui/gitops/cluster/providerconfigs/zitadel.yaml b/tests/e2e-ui/gitops/cluster/providerconfigs/zitadel.yaml new file mode 100644 index 000000000..b601e2b93 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providerconfigs/zitadel.yaml @@ -0,0 +1,26 @@ +apiVersion: zitadel.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: Secret + secretRef: + namespace: default + name: zitadel-credentials + key: credentials +--- +# Namespaced identity MRs (project/humans) and worktree Oidc/Features use +# ClusterProviderConfig so all namespaces share one credential secret. +apiVersion: zitadel.m.crossplane.io/v1beta1 +kind: ClusterProviderConfig +metadata: + name: default +spec: + credentials: + source: Secret + secretRef: + namespace: default + name: zitadel-credentials + key: credentials diff --git a/tests/e2e-ui/gitops/cluster/providers/helm-drc.yaml b/tests/e2e-ui/gitops/cluster/providers/helm-drc.yaml new file mode 100644 index 000000000..c9953f169 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/helm-drc.yaml @@ -0,0 +1,28 @@ +# Per-provider DeploymentRuntimeConfig for provider-helm (local dev). +# Colocated with providers/helm.yaml (runtimeConfigRef: local-dev-helm). +# +# Each provider gets its OWN uniquely-named DRC + cluster-admin ServiceAccount. +# Providers must NOT share a DRC: a shared runtime config lets one provider's +# image/SA silently clobber the other's pod (and makes drift impossible to +# attribute). +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: local-dev-helm +spec: + serviceAccountTemplate: + metadata: + name: local-dev-helm +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: local-dev-helm-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: local-dev-helm + namespace: crossplane-system diff --git a/tests/e2e-ui/gitops/cluster/providers/helm.yaml b/tests/e2e-ui/gitops/cluster/providers/helm.yaml new file mode 100644 index 000000000..44d33b640 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/helm.yaml @@ -0,0 +1,9 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-helm +spec: + # Renovate: github-releases crossplane-contrib/provider-helm (cli/renovate.json) + package: xpkg.crossplane.io/crossplane-contrib/provider-helm:v1.3.0 + runtimeConfigRef: + name: local-dev-helm diff --git a/tests/e2e-ui/gitops/cluster/providers/kubernetes-drc.yaml b/tests/e2e-ui/gitops/cluster/providers/kubernetes-drc.yaml new file mode 100644 index 000000000..76f6f5a6e --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/kubernetes-drc.yaml @@ -0,0 +1,28 @@ +# Per-provider DeploymentRuntimeConfig for provider-kubernetes (local dev). +# Colocated with providers/kubernetes.yaml (runtimeConfigRef: local-dev-kubernetes). +# +# Each provider gets its OWN uniquely-named DRC + cluster-admin ServiceAccount. +# Providers must NOT share a DRC: a shared runtime config lets one provider's +# image/SA silently clobber the other's pod (and makes drift impossible to +# attribute). +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: local-dev-kubernetes +spec: + serviceAccountTemplate: + metadata: + name: local-dev-kubernetes +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: local-dev-kubernetes-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: local-dev-kubernetes + namespace: crossplane-system diff --git a/tests/e2e-ui/gitops/cluster/providers/kubernetes.yaml b/tests/e2e-ui/gitops/cluster/providers/kubernetes.yaml new file mode 100644 index 000000000..c400c3878 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/kubernetes.yaml @@ -0,0 +1,9 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-kubernetes +spec: + # Renovate: github-releases crossplane-contrib/provider-kubernetes (cli/renovate.json) + package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.2.1 + runtimeConfigRef: + name: local-dev-kubernetes diff --git a/tests/e2e-ui/gitops/cluster/providers/zitadel.yaml b/tests/e2e-ui/gitops/cluster/providers/zitadel.yaml new file mode 100644 index 000000000..41d45afdb --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/zitadel.yaml @@ -0,0 +1,6 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-upjet-zitadel +spec: + package: xpkg.crossplane.io/crossplane-contrib/provider-upjet-zitadel:v0.1.1 diff --git a/tests/e2e-ui/gitops/cluster/secrets/stack.yaml b/tests/e2e-ui/gitops/cluster/secrets/stack.yaml new file mode 100644 index 000000000..33870beab --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/secrets/stack.yaml @@ -0,0 +1,148 @@ +# Platform SecretStack for local CP (dory workbench). +# +# Install Configuration from source first (breaking API: hops.ops.com.ai): +# hops config install --path /xrs/stacks/aws/secret --context dory +# +# Then: hops local gitops cluster ./gitops/cluster +# +# backend=vault + vault.install for local (no AWS PodIdentity required). +# +# Persistence: file storage on a PVC (not server.dev / inmem). postStart +# init+unseal once, stores root token + unseal key on the volume: +# /vault/data/.hops-init +# K8s auth for ESO is re-applied every start. Seed KV with: +# export VAULT_TOKEN=$(kubectl -n vault exec vault-0 -- \ +# awk '/^Initial Root Token:/{print $NF}' /vault/data/.hops-init) +# hops secrets sync vault +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: external-secrets + namespace: default +spec: + clusterName: dory + backend: vault + namespace: external-secrets + helmProviderConfigRef: + name: default + kubernetesProviderConfigRef: + name: default + labels: + team: platform + hops.ops.com.ai/fixture: e2e-ui + secretStore: + enabled: true + scope: Cluster + name: vault + vault: + install: true + namespace: vault + path: secret + version: v2 + auth: + method: kubernetes + mountPath: kubernetes + role: external-secrets + # Merged into official Vault Helm chart (SecretStack composition defaults + # use server.dev + no PVC; we override for durable local dogfood). + values: + server: + # Leave dev mode — inmem cannot use dataStorage. + dev: + enabled: false + standalone: + enabled: true + config: | + ui = true + disable_mlock = true + listener "tcp" { + tls_disable = 1 + address = "[::]:8200" + cluster_address = "[::]:8201" + } + storage "file" { + path = "/vault/data" + } + dataStorage: + enabled: true + size: 1Gi + # omit storageClass → cluster default (dory: local-path) + postStart: + - /bin/sh + - -c + - | + # NOTE: do not use bare `set -e` around `vault status` — sealed/uninit + # returns exit 2 and would abort the hook (killing the container). + set -u + export VAULT_ADDR=http://127.0.0.1:8200 + INIT_FILE=/vault/data/.hops-init + + vault_status_rc() { + vault status >/dev/null 2>&1 + echo $? + } + + # API is up when status returns 0 (unsealed) or 2 (sealed / not init). + i=0 + while [ "$i" -lt 90 ]; do + rc=$(vault_status_rc) + if [ "$rc" -eq 0 ] || [ "$rc" -eq 2 ]; then + break + fi + i=$((i + 1)) + sleep 1 + done + rc=$(vault_status_rc) + if [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ]; then + echo "vault postStart: API not ready after wait (rc=$rc)" >&2 + exit 1 + fi + + if [ ! -f "$INIT_FILE" ]; then + echo "vault postStart: initializing (1 share / threshold 1)" + vault operator init -key-shares=1 -key-threshold=1 >"$INIT_FILE" + chmod 600 "$INIT_FILE" || true + fi + + UNSEAL_KEY=$(awk '/Unseal Key 1:/{print $NF}' "$INIT_FILE") + ROOT_TOKEN=$(awk '/Initial Root Token:/{print $NF}' "$INIT_FILE") + if [ -z "${UNSEAL_KEY}" ] || [ -z "${ROOT_TOKEN}" ]; then + echo "vault postStart: failed to parse $INIT_FILE" >&2 + cat "$INIT_FILE" >&2 || true + exit 1 + fi + + # Unseal when sealed (status exit 2). + rc=$(vault_status_rc) + if [ "$rc" -ne 0 ]; then + vault operator unseal "$UNSEAL_KEY" >/dev/null + fi + export VAULT_TOKEN="$ROOT_TOKEN" + + # Non-dev installs do not auto-mount secret/ — enable KV v2 for ESO. + if ! vault secrets list -format=json 2>/dev/null | grep -q '"secret/"'; then + vault secrets enable -path=secret kv-v2 + fi + + if ! vault auth list -format=json 2>/dev/null | grep -q '"kubernetes/"'; then + vault auth enable kubernetes + fi + + vault write auth/kubernetes/config \ + kubernetes_host="https://kubernetes.default.svc" \ + token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + disable_iss_validation=true + + printf '%s\n' \ + 'path "secret/data/*" { capabilities = ["read", "list"] }' \ + 'path "secret/metadata/*" { capabilities = ["read", "list"] }' \ + | vault policy write external-secrets - + + vault write auth/kubernetes/role/external-secrets \ + bound_service_account_names=external-secrets \ + bound_service_account_namespaces=external-secrets \ + policies=external-secrets \ + ttl=24h + + echo "vault postStart: unsealed + kubernetes auth ready (init file $INIT_FILE)" diff --git a/tests/e2e-ui/gitops/cluster/secrets/vault-auth-delegator.yaml b/tests/e2e-ui/gitops/cluster/secrets/vault-auth-delegator.yaml new file mode 100644 index 000000000..7b9e772a6 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/secrets/vault-auth-delegator.yaml @@ -0,0 +1,17 @@ +# Allow the Vault server SA to call TokenReview (required for kubernetes auth). +# Independent of the Vault pod lifecycle — apply with cluster gitops. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vault-auth-delegator + labels: + hops.ops.com.ai/fixture: e2e-ui + hops.ops.com.ai/component: vault +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: vault + namespace: vault diff --git a/tests/e2e-ui/gitops/cluster/stacks/auth.yaml b/tests/e2e-ui/gitops/cluster/stacks/auth.yaml new file mode 100644 index 000000000..ca70689db --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/stacks/auth.yaml @@ -0,0 +1,50 @@ +# Local AuthStack: Zitadel + embedded PSQLCluster (owned by this XR). +# +# No ingress/gateway for local first step — Service is ClusterIP only. +# Helm chart fullname is release+chart → Service **zitadel-zitadel** (default). +# Issuer / ExternalDomain must match that FQDN (Option A): +# http://zitadel-zitadel.auth.svc.cluster.local:8080 +# (Later: chartValues.fullnameOverride: zitadel if we want a shorter name.) +# +# Shared cluster GitOps owns this XR after Configuration bootstrap. +apiVersion: hops.ops.com.ai/v1alpha1 +kind: AuthStack +metadata: + name: zitadel + namespace: default +spec: + clusterName: dory + helmProviderConfigRef: + name: default + kubernetesProviderConfigRef: + name: default + + # Install namespace + namespace: auth + + # Must match Helm Service FQDN (default fullname = zitadel-zitadel) + domain: zitadel-zitadel.auth.svc.cluster.local + externalSecure: false + + gateway: + enabled: false + + externalSecrets: + enabled: false + + firstInstance: + org: hops-ops + masterkey: + # Live Secret in install ns (auth) — not inline in gitops. + # kubectl -n auth create secret generic zitadel-masterkey \ + # --from-literal=masterkey='hops-like-basketball-but-for-ops' + # Chart expects the key material under the secret name; see AuthStack XRD. + secretRef: + name: zitadel-masterkey + + # Embedded PG lands in the same install namespace (auth). + database: + embedded: + storage: + size: 2Gi + class: psql diff --git a/tests/e2e-ui/gitops/cluster/stacks/psql.yaml b/tests/e2e-ui/gitops/cluster/stacks/psql.yaml new file mode 100644 index 000000000..dc1a13789 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/stacks/psql.yaml @@ -0,0 +1,25 @@ +# Platform layer: CNPG + Atlas + StorageClass named `psql`. +# Install package first: `hops config install ` (or apply configurations/psql-stack.yaml). +# +# Dory/k3s uses rancher.io/local-path (default SC). Snapshot class off (no CSI snapshotter). +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLStack +metadata: + name: psql + namespace: default +spec: + clusterName: dory + helmProviderConfigRef: + name: default + kubernetesProviderConfigRef: + name: default + storageClass: + enabled: true + name: psql + provisioner: rancher.io/local-path + allowVolumeExpansion: false + parameters: {} + snapshotClass: + enabled: false + scaleToZeroPlugin: + enabled: false diff --git a/tests/e2e-ui/gitops/envs/local/api.yaml b/tests/e2e-ui/gitops/envs/local/api.yaml new file mode 100644 index 000000000..358c46a35 --- /dev/null +++ b/tests/e2e-ui/gitops/envs/local/api.yaml @@ -0,0 +1,26 @@ +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-api +spec: + source: + path: ../../../api/.gitops/deploy + helm: + values: + local: true + appRuntime: cluster-dev + clusterDev: + sourceGeneration: 1 + database: + enabled: false + # No live project/client UUIDs. The API chart derives the matching + # workspace Secret and reads the generated client id as its audience. + identity: + enabled: true + oidcGeneration: 1 + secretEnv: + ZITADEL_SERVICE_USER_TOKEN: + secretName: e2e-ui-oidc + key: ZITADEL_SERVICE_USER_TOKEN + syncPolicy: + prune: true diff --git a/tests/e2e-ui/gitops/envs/local/ui.yaml b/tests/e2e-ui/gitops/envs/local/ui.yaml new file mode 100644 index 000000000..204baf992 --- /dev/null +++ b/tests/e2e-ui/gitops/envs/local/ui.yaml @@ -0,0 +1,31 @@ +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-ui +spec: + source: + path: ../../../ui/.gitops/deploy + helm: + values: + local: true + appRuntime: cluster-dev + clusterDev: + sourceGeneration: 1 + # Chart identity MRs: Project/roles/humans + Oidc. + # - projectIdRef / labels (no live UUIDs) + # - writeConnectionSecretToRef for client id/secret + # - orgId omitted (ProviderConfig PAT org) + identity: + enabled: true + # Declarative client-secret rotation; bump and let prune replace OIDC. + oidcGeneration: 1 + projectName: e2e-ui + projectNamespace: default + humansNamespace: default + instanceLoginV2: true + demoUsers: true + providerConfigRef: + name: default + kind: ClusterProviderConfig + syncPolicy: + prune: true diff --git a/tests/e2e-ui/scripts/dual-worktree-hmr.mjs b/tests/e2e-ui/scripts/dual-worktree-hmr.mjs new file mode 100644 index 000000000..c72c06699 --- /dev/null +++ b/tests/e2e-ui/scripts/dual-worktree-hmr.mjs @@ -0,0 +1,238 @@ +/** + * Dual-workspace HMR check for cluster-dev UIs. + * + * Expects both UIs already serving (suite shell brings them up). Opens each + * FQDN, edits `HmrBeacon.svelte` in that worktree, copies **only that file** + * into the live pod (full-tree tar causes Vite SSR reload / full navigation), + * and asserts the beacon updates with **zero** main-frame navigations. + * + * Usage: + * node scripts/dual-worktree-hmr.mjs \ + * --alice-url http://e2e-ui-ui.alice.svc.cluster.local:5180 \ + * --bob-url http://e2e-ui-ui.bob.svc.cluster.local:5180 \ + * --alice-root /path/to/wt-alice/tests/e2e-ui \ + * --bob-root /path/to/wt-bob/tests/e2e-ui + */ +import { chromium } from 'playwright'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +function arg(name, fallback) { + const i = process.argv.indexOf(`--${name}`); + if (i === -1) return fallback; + return process.argv[i + 1]; +} + +const aliceUrl = arg('alice-url'); +const bobUrl = arg('bob-url'); +const aliceRoot = arg('alice-root'); +const bobRoot = arg('bob-root'); +const timeoutMs = Number(arg('timeout-ms', '120000')); +const kubeconfig = process.env.KUBECONFIG || `${process.env.HOME}/.kube/dory-config`; + +if (!aliceUrl || !bobUrl || !aliceRoot || !bobRoot) { + console.error('missing required args'); + process.exit(2); +} + +const beaconRel = 'ui/src/lib/components/HmrBeacon.svelte'; +const remoteBeacon = '/workspace/tests/e2e-ui/ui/src/lib/components/HmrBeacon.svelte'; + +const beaconFile = (root) => resolve(root, beaconRel); + +function setBeacon(root, marker) { + const path = beaconFile(root); + let src = readFileSync(path, 'utf8'); + // Match with or without existing data-hmr attribute. + const re = + /(([^<]*)(<\/span>)/; + if (!re.test(src)) { + throw new Error(`no data-hmr-beacon span in ${path}`); + } + src = src.replace(re, `$1 data-hmr="${marker}">${marker}$3`); + writeFileSync(path, src); + return path; +} + +function kubectlBase() { + const ctx = process.env.HOPS_KUBE_CONTEXT || ''; + return [ + 'kubectl', + ...(ctx ? ['--context', ctx] : []), + ...(kubeconfig ? ['--kubeconfig', kubeconfig] : []), + ]; +} + +function runningUiPod(namespace) { + const base = kubectlBase(); + const get = spawnSync( + base[0], + [ + ...base.slice(1), + 'get', + 'pod', + '-n', + namespace, + '-l', + 'hops.ops.com.ai/local-app=e2e-ui-ui', + '--field-selector=status.phase=Running', + '-o', + 'jsonpath={.items[0].metadata.name}', + ], + { encoding: 'utf8' } + ); + const pod = (get.stdout || '').trim(); + if (!pod) { + throw new Error(`no Running e2e-ui-ui pod in ${namespace}: ${get.stderr || get.status}`); + } + return pod; +} + +/** + * Pause hops continuous full-tree tar watchers. Full monorepo re-tar after an + * edit rewrites many files and forces Vite SSR page reload (full navigation), + * which is not the HMR path under test. + */ +function pauseDeliveryWatchers() { + // Match the multi-app tar watch script body hops spawns (sync_one / needs_marker). + const out = spawnSync( + 'bash', + [ + '-c', + `pgrep -f 'sync_one\\(\\)|needs_marker\\(\\)' 2>/dev/null || true`, + ], + { encoding: 'utf8' } + ); + const pids = (out.stdout || '') + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => /^\d+$/.test(s)); + for (const pid of pids) { + spawnSync('kill', [pid], { encoding: 'utf8' }); + console.log(`paused delivery watcher pid=${pid}`); + } +} + +/** + * Single-file delivery: host worktree beacon → pod path Vite watches. + * Avoid full-tree tar (causes SSR reload / full navigation). + */ +function forceBeaconSync(namespace, worktreeRoot) { + const pod = runningUiPod(namespace); + const hostPath = beaconFile(worktreeRoot); + const base = kubectlBase(); + // kubectl cp does not support -c for all versions; use tar of one file. + const script = ` +set -euo pipefail +export COPYFILE_DISABLE=1 +tar cf - -C ${JSON.stringify(resolve(worktreeRoot, 'ui/src/lib/components'))} HmrBeacon.svelte \ + | ${base.join(' ')} exec -i -n ${namespace} ${pod} -c ui -- \ + tar xf - -C /workspace/tests/e2e-ui/ui/src/lib/components +${base.join(' ')} exec -n ${namespace} ${pod} -c ui -- touch ${remoteBeacon} +`; + const tar = spawnSync('bash', ['-c', script], { + encoding: 'utf8', + timeout: 30000, + }); + if (tar.status !== 0) { + throw new Error( + `beacon sync ${namespace}/${pod} failed: ${tar.stderr || tar.stdout || tar.status}` + ); + } + console.log(`[${namespace}] single-file sync ${hostPath} → ${pod}:${remoteBeacon}`); + return pod; +} + +async function assertHmrBeacon(page, url, root, namespace, marker) { + const navigations = []; + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) { + navigations.push({ url: frame.url(), t: Date.now() }); + } + }); + + await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs }); + await page.waitForSelector('[data-hmr-beacon]', { timeout: timeoutMs }); + // Give Vite HMR websocket time to connect before we edit. + await page.waitForTimeout(1500); + const baselineNav = navigations.length; + + const stamp = `HMR-${marker}-${Date.now()}`; + const path = setBeacon(root, stamp); + console.log(`[${marker}] wrote beacon ${stamp} → ${path}`); + forceBeaconSync(namespace, root); + + const deadline = Date.now() + timeoutMs; + let seen = false; + while (Date.now() < deadline) { + // Fail immediately if a full navigation already happened after the edit. + const extraSoFar = navigations.length - baselineNav; + if (extraSoFar > 0) { + throw new Error( + `[${marker}] full navigation during beacon update (extra=${extraSoFar}); HMR required (hmr-no-nav)` + ); + } + try { + // CSS may text-transform: uppercase the kicker; compare case-insensitively. + seen = await page.evaluate((expected) => { + const el = document.querySelector('[data-hmr-beacon]'); + if (!el) return false; + const t = (el.textContent || el.innerText || '').toUpperCase(); + return t.includes(String(expected).toUpperCase()); + }, stamp); + } catch (e) { + const msg = e && e.message ? e.message : String(e); + // Context destroyed = navigation/reload — fail (do not swallow). + if (/Execution context was destroyed|navigation/i.test(msg)) { + throw new Error( + `[${marker}] page reloaded during HMR wait (context destroyed): ${msg}` + ); + } + throw e; + } + if (seen) break; + forceBeaconSync(namespace, root); + await page.waitForTimeout(1000); + } + if (!seen) { + throw new Error(`[${marker}] beacon never showed stamp ${stamp} within ${timeoutMs}ms`); + } + + const after = await page.locator('[data-hmr-beacon]').innerText(); + const extraNav = navigations.length - baselineNav; + if (extraNav > 0) { + throw new Error( + `[${marker}] full navigation during beacon update (extra=${extraNav}); text=${JSON.stringify(after)}` + ); + } + if (!after.toUpperCase().includes(stamp.toUpperCase())) { + throw new Error(`[${marker}] beacon missing stamp: ${after}`); + } + console.log(`[${marker}] OK beacon=${JSON.stringify(after)} (hmr-no-nav)`); + return stamp; +} + +// Continuous full-tree delivery watchers fight single-file HMR (mass rewrite → SSR reload). +pauseDeliveryWatchers(); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext(); +try { + const alice = await context.newPage(); + const bob = await context.newPage(); + const aliceStamp = await assertHmrBeacon(alice, aliceUrl, aliceRoot, 'alice', 'alice'); + const bobStamp = await assertHmrBeacon(bob, bobUrl, bobRoot, 'bob', 'bob'); + const aliceText = await alice.locator('[data-hmr-beacon]').innerText(); + const bobText = await bob.locator('[data-hmr-beacon]').innerText(); + const aU = aliceText.toUpperCase(); + const bU = bobText.toUpperCase(); + if (aU.includes(bobStamp.toUpperCase()) || bU.includes(aliceStamp.toUpperCase())) { + throw new Error( + `workspace isolation broken: alice=${JSON.stringify(aliceText)} bob=${JSON.stringify(bobText)}` + ); + } + console.log('dual-worktree-hmr: OK (hmr-no-nav + isolation)'); +} finally { + await browser.close(); +} diff --git a/tests/e2e-ui/scripts/dual-worktree-suite.sh b/tests/e2e-ui/scripts/dual-worktree-suite.sh new file mode 100755 index 000000000..d64b64585 --- /dev/null +++ b/tests/e2e-ui/scripts/dual-worktree-suite.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Local dual-workspace suite: two git worktrees, hops gitops for each, HMR title check. +# +# Prerequisites: +# - local CP with hops (dory/colima/kind) and kubectl context +# - AuthStack optional for identity; HMR does not require login +# - hops binary on PATH or HOPS=path +# - helm, git, node, npm +# +# cd tests/e2e-ui && ./scripts/dual-worktree-suite.sh +# +# Policy: alice owns instance Login V2 Features; bob sets instanceLoginV2=false. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIST_ROOT="$(cd "$ROOT/../.." && pwd)" +SCRATCH="${DUAL_SCRATCH:-${SCRATCH_DIR:-$(pwd)/.dual-worktree-scratch}}" +HOPS="${HOPS:-hops}" +KUBECONFIG="${KUBECONFIG:-${HOME}/.kube/config}" +export KUBECONFIG + +ALICE_NAME="${ALICE_NAME:-alice}" +BOB_NAME="${BOB_NAME:-bob}" +SKIP_GITOPS="${SKIP_GITOPS:-0}" +CLUSTER_PROVIDER="${CLUSTER_PROVIDER:-kind}" +DOCKER_PROVIDER="${DOCKER_PROVIDER:-dory}" +CLUSTER_NAME="${CLUSTER_NAME:-hops}" +CONTEXT="${CONTEXT:-kind-hops}" + +log() { printf '%s\n' "$*"; } +die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } + +need() { command -v "$1" >/dev/null 2>&1 || die "missing $1"; } +need git +need helm +need node +need npm +need kubectl + +if ! command -v "$HOPS" >/dev/null 2>&1; then + # Prefer worktree-built hops-cli + if [[ -x "$DIST_ROOT/../cli/target/release/hops-cli" ]]; then + HOPS="$DIST_ROOT/../cli/target/release/hops-cli" + elif [[ -x "$DIST_ROOT/../../cli/target/release/hops-cli" ]]; then + HOPS="$DIST_ROOT/../../cli/target/release/hops-cli" + else + die "hops binary not found (set HOPS=)" + fi +fi + +mkdir -p "$SCRATCH" +log "scratch=$SCRATCH hops=$HOPS" + +# --- helm contract (always) --- +chmod +x "$ROOT/scripts/helm-contract-test.sh" +"$ROOT/scripts/helm-contract-test.sh" | tee "$SCRATCH/helm-contract.log" + +# --- two worktrees of distributed --- +ALICE_WT="$SCRATCH/wt-${ALICE_NAME}" +BOB_WT="$SCRATCH/wt-${BOB_NAME}" +BRANCH_ALICE="suite/dual-${ALICE_NAME}-$$" +BRANCH_BOB="suite/dual-${BOB_NAME}-$$" + +cleanup_worktrees() { + git -C "$DIST_ROOT" worktree remove --force "$ALICE_WT" 2>/dev/null || true + git -C "$DIST_ROOT" worktree remove --force "$BOB_WT" 2>/dev/null || true + git -C "$DIST_ROOT" branch -D "$BRANCH_ALICE" "$BRANCH_BOB" 2>/dev/null || true +} +trap cleanup_worktrees EXIT + +cleanup_worktrees +git -C "$DIST_ROOT" worktree add -b "$BRANCH_ALICE" "$ALICE_WT" HEAD +git -C "$DIST_ROOT" worktree add -b "$BRANCH_BOB" "$BOB_WT" HEAD + +# Overlay working-tree e2e-ui (includes uncommitted chart/script fixes) so the +# suite exercises the same tree under development, not only last commit. +overlay_e2e() { + local dest="$1" + rsync -a --delete \ + --exclude node_modules --exclude .svelte-kit --exclude dist \ + --exclude playwright-report --exclude test-results --exclude target \ + "$ROOT/" "$dest/tests/e2e-ui/" +} +overlay_e2e "$ALICE_WT" +overlay_e2e "$BOB_WT" + +# Bob: do not own instance Features +BOB_UI_APP="$BOB_WT/tests/e2e-ui/gitops/envs/local/ui.yaml" +if [[ -f "$BOB_UI_APP" ]]; then + if rg -q 'instanceLoginV2:' "$BOB_UI_APP"; then + sed -i.bak 's/instanceLoginV2: true/instanceLoginV2: false/' "$BOB_UI_APP" || true + else + # inject under identity: + python3 - </dev/null || echo 000) + if [[ "$code" == "200" ]]; then + log "ready $label $url ($i)" + return 0 + fi + sleep 3 + done + die "timeout waiting for $label at $url (last=$code)" +} + +if [[ "$SKIP_GITOPS" != "1" ]]; then + if ! kubectl get ns >/dev/null 2>&1; then + die "kubectl cannot talk to a cluster (set KUBECONFIG); dual gitops requires local CP" + fi + log "reconciling workspace $ALICE_NAME from $ALICE_E2E" + (cd "$ALICE_E2E" && "$HOPS" local gitops worktree ./gitops/envs/local \ + --name "$ALICE_NAME" --cluster-provider "$CLUSTER_PROVIDER" \ + --docker-provider "$DOCKER_PROVIDER" --cluster-name "$CLUSTER_NAME" \ + --context "$CONTEXT" --once) \ + | tee "$SCRATCH/gitops-alice.log" \ + || die "workspace gitops alice failed — see $SCRATCH/gitops-alice.log" + log "reconciling workspace $BOB_NAME from $BOB_E2E" + (cd "$BOB_E2E" && "$HOPS" local gitops worktree ./gitops/envs/local \ + --name "$BOB_NAME" --cluster-provider "$CLUSTER_PROVIDER" \ + --docker-provider "$DOCKER_PROVIDER" --cluster-name "$CLUSTER_NAME" \ + --context "$CONTEXT" --once) \ + | tee "$SCRATCH/gitops-bob.log" \ + || die "workspace gitops bob failed — see $SCRATCH/gitops-bob.log" +fi + +wait_http "$ALICE_URL" alice 120 +wait_http "$BOB_URL" bob 120 + +# Assert gitops isolation: distinct OIDC names if present +if kubectl get oidc.application.zitadel.m.crossplane.io -A >/dev/null 2>&1; then + kubectl get oidc.application.zitadel.m.crossplane.io -A -o wide 2>/dev/null | tee "$SCRATCH/oidc-apps.log" || true + kubectl get project.project.zitadel.m.crossplane.io -A 2>/dev/null | tee "$SCRATCH/projects.log" || true +fi + +# Playwright HMR — fail the suite if this fails (do not swallow via bare pipe). +( + cd "$ROOT" + if [[ ! -d node_modules/playwright ]]; then + npm install --no-fund --no-audit 2>&1 | tail -5 + fi + npx playwright install chromium 2>&1 | tail -5 + node scripts/dual-worktree-hmr.mjs \ + --alice-url "$ALICE_URL" \ + --bob-url "$BOB_URL" \ + --alice-root "$ALICE_E2E" \ + --bob-root "$BOB_E2E" \ + --timeout-ms 180000 +) 2>&1 | tee "$SCRATCH/dual-worktree-hmr.log" +hmr_rc=${PIPESTATUS[0]} +if [[ "$hmr_rc" -ne 0 ]]; then + die "HMR check failed (exit $hmr_rc) — see $SCRATCH/dual-worktree-hmr.log" +fi + +# Prove dory desktop name was not rewritten by workspace --name +dory_name_file="${HOME}/.hops/local/dory-name" +if [[ -f "$dory_name_file" ]]; then + dn=$(tr -d '[:space:]' <"$dory_name_file") + if [[ "$dn" == "alice" || "$dn" == "bob" ]]; then + die "dory-name was corrupted to workspace name ($dn) — --dory-name / --name isolation broken" + fi + log "dory-name ok: $dn" +fi + +log "dual-worktree-suite: OK" +log " alice: $ALICE_URL" +log " bob: $BOB_URL" diff --git a/tests/e2e-ui/scripts/helm-contract-test.sh b/tests/e2e-ui/scripts/helm-contract-test.sh new file mode 100755 index 000000000..4cf5b798a --- /dev/null +++ b/tests/e2e-ui/scripts/helm-contract-test.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# Chart contract tests for multi-workspace identity scopes. +# No cluster required — helm template only. +# +# cd tests/e2e-ui && ./scripts/helm-contract-test.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CHART="$ROOT/ui/.gitops/deploy" +fail=0 + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "missing required tool: $1" >&2 + exit 2 + } +} +need helm +need rg + +render() { + local ns="$1" + local features="${2:-true}" + local oidc_generation="${3:-0}" + local source_generation="${4:-0}" + helm template "e2e-ui-ui-${ns}" "$CHART" \ + --namespace "$ns" \ + --set local=true \ + --set appRuntime=cluster-dev \ + --set "namespace=${ns}" \ + --set "clusterDev.sourceGeneration=${source_generation}" \ + --set identity.enabled=true \ + --set "identity.oidcGeneration=${oidc_generation}" \ + --set identity.projectName=e2e-ui \ + --set identity.projectNamespace=default \ + --set identity.humansNamespace=default \ + --set identity.mrNamespace= \ + --set "identity.instanceLoginV2=${features}" \ + --set identity.providerConfigRef.name=default \ + --set identity.providerConfigRef.kind=ClusterProviderConfig +} + +assert_contains() { + local hay="$1" needle="$2" msg="$3" + if ! printf '%s' "$hay" | rg -q --fixed-strings "$needle"; then + echo "FAIL: $msg (missing: $needle)" >&2 + fail=1 + else + echo "ok: $msg" + fi +} + +assert_not_contains() { + local hay="$1" needle="$2" msg="$3" + if printf '%s' "$hay" | rg -q --fixed-strings "$needle"; then + echo "FAIL: $msg (unexpected: $needle)" >&2 + fail=1 + else + echo "ok: $msg" + fi +} + +check_workspace() { + local ns="$1" + local features="$2" + echo "=== workspace ${ns} (instanceLoginV2=${features}) ===" + local out + out="$(render "$ns" "$features")" + + assert_contains "$out" "name: e2e-ui-${ns}-web" "OIDC app name is worktree-scoped" + assert_contains "$out" "namespace: ${ns}" "worktree resources reference ${ns}" + assert_contains "$out" "baseUri: \"http://e2e-ui-ui.${ns}.svc.cluster.local:5180\"" \ + "Login V2 / OIDC baseUri uses release namespace" + assert_contains "$out" "http://e2e-ui-ui.${ns}.svc.cluster.local:5180/auth/callback/oidc" \ + "OIDC redirect uses release FQDN" + assert_contains "$out" "namespace: default" "shared identity keeps default namespace" + assert_contains "$out" "name: e2e-ui" "shared Project name e2e-ui" + assert_contains "$out" "projectRoleCheck: true" \ + "Project requires a GitOps-owned role grant" + assert_contains "$out" "name: e2e-role-user" "shared role e2e-role-user" + assert_contains "$out" "name: e2e-alice" "shared human e2e-alice" + assert_contains "$out" "name: e2e-alice-e2e-ui" "shared Grant for alice" + assert_contains "$out" "name: e2e-bob-e2e-ui" "shared Grant for bob" + assert_contains "$out" "name: e2e-admin-e2e-ui" "shared Grant for admin" + assert_contains "$out" "userIdRef:" "Grant resolves HumanUser by reference" + assert_contains "$out" "projectIdRef:" "Grant resolves Project by reference" + assert_contains "$out" 'roles: ["user","admin"]' "admin receives user and admin roles" + assert_not_contains "$out" "oidc-local-seed" \ + "GitOps does not overwrite the residual OIDC session/PAT Secret" + assert_contains "$out" "name: \"e2e-ui-${ns}-oidc-conn\"" \ + "OIDC connection secret is worktree-scoped" + assert_contains "$out" "key: attribute.client_id" \ + "UI reads client id from Oidc connection secret" + assert_contains "$out" "key: attribute.client_secret" \ + "UI reads client secret from Oidc connection secret" + assert_contains "$out" "namespace: default" "projectIdRef targets shared Project ns" + assert_contains "$out" "projectIdRef:" "OIDC references Project" + # Project MR must declare default ns (not worktree) + if ! printf '%s' "$out" | rg -U -q 'kind: Project\nmetadata:\n name: e2e-ui\n namespace: default'; then + # tolerate key reorder from helm + if ! printf '%s' "$out" | awk '/kind: Project/{p=1} p&&/namespace: default/{found=1} p&&/^---/{exit} END{exit !found}'; then + echo "FAIL: Project not clearly in default namespace" >&2 + fail=1 + else + echo "ok: Project in default namespace" + fi + else + echo "ok: Project in default namespace" + fi + + if [ "$features" = "true" ]; then + assert_contains "$out" "kind: Features" "Features MR rendered when instanceLoginV2" + assert_contains "$out" "name: e2e-ui-login-v2" "Features name stable" + else + assert_not_contains "$out" "kind: Features" "no Features when instanceLoginV2=false" + fi + + assert_not_contains "$out" "hops-wt-" "no legacy hops-wt- prefix" +} + +check_workspace alice true +check_workspace bob false + +# Distinct OIDC names across workspaces +alice_out="$(render alice true)" +bob_out="$(render bob false)" +assert_contains "$alice_out" "e2e-ui-alice-web" "alice OIDC name" +assert_contains "$bob_out" "e2e-ui-bob-web" "bob OIDC name" +assert_not_contains "$alice_out" "e2e-ui-bob-web" "alice render has no bob OIDC" +assert_not_contains "$bob_out" "e2e-ui-alice-web" "bob render has no alice OIDC" + +# Rotation changes the managed OIDC resource and connection Secret generation. +# The new UI pod cannot start until Crossplane publishes that generation's +# credentials, avoiding a race with stale data under a stable Secret name. +rotated_out="$(render alice true 1)" +assert_contains "$rotated_out" "name: e2e-ui-alice-web-g1" "OIDC generation rotates MR name" +assert_contains "$rotated_out" 'hops.ops.com.ai/oidc-generation: "1"' \ + "OIDC generation rolls UI pod template" +assert_contains "$rotated_out" 'name: "e2e-ui-alice-oidc-conn-g1"' \ + "OIDC rotation uses matching connection secret generation" + +ui_source_out="$(render alice true 1 7)" +assert_contains "$ui_source_out" 'hops.ops.com.ai/source-generation: "7"' \ + "UI source generation rolls the Vite dev process" + +# API derives the same connection Secret and uses the generated client id as +# both the JWT audience and client id. No Zitadel UUID belongs in values. +api_out="$(helm template e2e-ui-api-alice "$ROOT/api/.gitops/deploy" \ + --namespace alice \ + --set local=true \ + --set appRuntime=cluster-dev \ + --set namespace=alice \ + --set clusterDev.sourceGeneration=7 \ + --set identity.enabled=true \ + --set identity.oidcGeneration=1)" +assert_contains "$api_out" 'hops.ops.com.ai/source-generation: "7"' \ + "API source generation rolls the one-shot dev process" +assert_contains "$api_out" 'hops.ops.com.ai/oidc-generation: "1"' \ + "API OIDC generation rolls pod template" +assert_contains "$api_out" 'name: "e2e-ui-alice-oidc-conn-g1"' \ + "API uses matching generated OIDC connection secret" +assert_contains "$api_out" "name: OIDC_AUDIENCE" \ + "API audience comes from the generated connection secret" +assert_contains "$api_out" "key: attribute.client_id" \ + "API audience/client id use the generated client id key" + +if [ "$fail" -ne 0 ]; then + echo "helm-contract-test: FAILED" >&2 + exit 1 +fi +echo "helm-contract-test: OK" diff --git a/tests/e2e-ui/ui/.gitops/deploy/Chart.yaml b/tests/e2e-ui/ui/.gitops/deploy/Chart.yaml new file mode 100644 index 000000000..88e9cbc96 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: e2e-ui-ui +description: Local workbench dogfood chart for e2e-ui SvelteKit UI +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/tests/e2e-ui/ui/.gitops/deploy/README.md b/tests/e2e-ui/ui/.gitops/deploy/README.md new file mode 100644 index 000000000..0ce8c35ba --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/README.md @@ -0,0 +1,76 @@ +# e2e-ui UI chart (workbench) + +Renders: + +- **Deployment / Service** (`appRuntime: cluster-dev` \| `package`) +- **Optional identity** when `identity.enabled: true`: + - **Cluster-shared:** Project, roles (`user` / `admin`), demo humans, grants + - **Per worktree:** OIDC web app only (redirects + Login V2 `baseUri`) + +The OIDC web app + Login V2 `baseUri` belong with the UI (browser sign-in), +not the API. + +## Identity scope + +| Resource | Scope | K8s name example | +|----------|--------|------------------| +| Project | **Cluster** | `e2e-ui` | +| Roles | **Cluster** | `e2e-role-user`, `e2e-role-admin` | +| HumanUsers | **Cluster** | `e2e-alice` (login `alice`) | +| Grants | **Cluster** | `e2e-alice-e2e-ui` (`user`) | +| Password secret | **Cluster** | `e2e-human-passwords` | +| OIDC app | **Worktree** | `e2e-ui-dogfood-web` | + +Demo login (every local env): **alice / bob / admin · Password1!** + +## Values + +| Value | Purpose | +|-------|---------| +| `identity.enabled` | Gate identity templates | +| `identity.projectName` | Shared Project name (default `e2e-ui`) | +| `identity.workspace` | Worktree id for OIDC app names only | +| `identity.oidcGeneration` | Bump to rotate generated OIDC client credentials through GitOps prune | +| `identity.demoUsers` | Cluster-shared alice / bob / admin | +| `identity.projectNamespace` | Namespace for Project + Role MRs | +| `identity.humansNamespace` | Namespace for HumanUser MRs | +| `identity.mrNamespace` | OIDC app ns (empty = release / hops `--name`) | +| `identity.instanceLoginV2` | Gitops instance Features (global; primary worktree only) | +| `identity.seedLocalOidcSecret` | Optional explicit residual seed; disabled by default so GitOps cannot erase the login PAT | +| `identity.uiBaseURL` | Optional; else `http://e2e-ui-ui..svc…:5180` | +| `identity.passwordSecret.name` | Shared password secret (default `e2e-human-passwords`) | + +```yaml +# gitops/envs/local/ui.yaml +identity: + enabled: true + demoUsers: true + providerConfigRef: + name: default + kind: ClusterProviderConfig +``` + +The Project keeps `projectRoleCheck` enabled. Grant XRs assign `user` to alice +and bob, and `user + admin` to admin. Each Grant resolves its HumanUser and +Project by name from `status.atProvider`; the referenced resources and Grant +must share `identity.projectNamespace`. The chart adds the auth-stack's stable +reference-name label to the Project and HumanUsers. Prefer a +**ClusterProviderConfig** so all identity resources share credentials. + +Project, role, human, Grant, and OIDC resources omit live +org/project/user/client UUIDs. +The provider credential selects the organization; `projectIdRef` selects the +shared Project. The OIDC client id/secret come from the per-workspace connection +secret written by the Oidc managed resource. + +If a generated OIDC client secret becomes stale, bump +`identity.oidcGeneration`. With the Application's `syncPolicy.prune: true`, +worktree GitOps deletes the previous OIDC MR from its exact-object inventory, +creates the new generation, and rolls the UI onto the matching +`e2e-ui--oidc-conn-g` Secret. The generation-specific +name makes the replacement pod wait for Crossplane's new credentials instead +of starting with data from the previous generation's Secret. + +`OIDC_AUDIENCE` stays empty for generic Zitadel role scopes. The residual +`e2e-ui-oidc` secret contains only `AUTH_SECRET` and +`ZITADEL_SERVICE_USER_TOKEN`. diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/_helpers.tpl b/tests/e2e-ui/ui/.gitops/deploy/templates/_helpers.tpl new file mode 100644 index 000000000..50e247e85 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/_helpers.tpl @@ -0,0 +1,94 @@ +{{- define "e2e-ui-ui.name" -}} +{{- default .Chart.Name .Values.name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "e2e-ui-ui.labels" -}} +app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Workspace id for multi-worktree OIDC apps (redirects / Login V2 baseUri). +Prefers identity.workspace; else release/workspace namespace (= hops --name). +*/}} +{{- define "e2e-ui-ui.workspace" -}} +{{- if .Values.identity.workspace -}} +{{- .Values.identity.workspace -}} +{{- else -}} +{{- include "e2e-ui-ui.releaseNamespace" . -}} +{{- end -}} +{{- end -}} + +{{/* App namespace: hops injects values.namespace; helm --namespace is Release.Namespace. */}} +{{- define "e2e-ui-ui.releaseNamespace" -}} +{{- coalesce .Values.namespace .Release.Namespace "default" -}} +{{- end -}} + +{{/* Worktree-scoped OIDC app name prefix: e2e-ui- */}} +{{- define "e2e-ui-ui.identityPrefix" -}} +{{- printf "e2e-ui-%s" (include "e2e-ui-ui.workspace" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +OIDC MR name. Bump identity.oidcGeneration to rotate provider-generated client +credentials. Worktree GitOps prune deletes the previous generation by inventory. +*/}} +{{- define "e2e-ui-ui.oidcResourceName" -}} +{{- $prefix := include "e2e-ui-ui.identityPrefix" . -}} +{{- $generation := int (.Values.identity.oidcGeneration | default 0) -}} +{{- if gt $generation 0 -}} +{{- printf "%s-web-g%d" $prefix $generation | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-web" $prefix | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{/* Cluster-shared Zitadel Project MR name (one per control plane). */}} +{{- define "e2e-ui-ui.clusterProjectName" -}} +{{- default "e2e-ui" .Values.identity.projectName -}} +{{- end -}} + +{{- define "e2e-ui-ui.identityLabels" -}} +app.kubernetes.io/name: e2e-ui +app.kubernetes.io/component: identity +hops.ops.com.ai/app: e2e-ui +hops.ops.com.ai/workspace: {{ include "e2e-ui-ui.workspace" . | quote }} +{{- end -}} + +{{- define "e2e-ui-ui.identityClusterLabels" -}} +app.kubernetes.io/name: e2e-ui +app.kubernetes.io/component: identity +hops.ops.com.ai/app: e2e-ui +hops.ops.com.ai/identity-scope: cluster +{{- end -}} + +{{/* +UI public base for OIDC redirects + app Login V2 baseUri. +Uses the release/workspace namespace so --name dogfood → e2e-ui-ui.dogfood.svc… +*/}} +{{- define "e2e-ui-ui.uiBaseURL" -}} +{{- if .Values.identity.uiBaseURL -}} +{{- .Values.identity.uiBaseURL | trimSuffix "/" -}} +{{- else -}} +{{- $ns := include "e2e-ui-ui.releaseNamespace" . -}} +{{- $svc := default (include "e2e-ui-ui.name" .) .Values.identity.uiService -}} +{{- $port := default .Values.service.port .Values.identity.uiPort -}} +{{- printf "http://%s.%s.svc.cluster.local:%v" $svc $ns $port -}} +{{- end -}} +{{- end -}} + +{{/* +Crossplane connection secret written by the worktree Oidc MR +(writeConnectionSecretToRef). Holds attribute.client_id / attribute.client_secret +— never commit live client credentials to git. +*/}} +{{- define "e2e-ui-ui.oidcConnectionSecretName" -}} +{{- $prefix := include "e2e-ui-ui.identityPrefix" . -}} +{{- $generation := int (.Values.identity.oidcGeneration | default 0) -}} +{{- if gt $generation 0 -}} +{{- printf "%s-oidc-conn-g%d" $prefix $generation | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-oidc-conn" $prefix | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-cluster-dev.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-cluster-dev.yaml new file mode 100644 index 000000000..2bc1481e5 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-cluster-dev.yaml @@ -0,0 +1,125 @@ +{{- if eq .Values.appRuntime "cluster-dev" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "e2e-ui-ui.name" . }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/runtime: cluster-dev +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + template: + metadata: + annotations: + hops.ops.com.ai/source-generation: {{ .Values.clusterDev.sourceGeneration | default 0 | quote }} + {{- if .Values.identity.enabled }} + hops.ops.com.ai/oidc-generation: {{ .Values.identity.oidcGeneration | default 0 | quote }} + {{- end }} + labels: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + hops.ops.com.ai/runtime: cluster-dev + spec: + containers: + - name: ui + image: {{ .Values.clusterDev.image | quote }} + workingDir: {{ .Values.clusterDev.workingDir | quote }} + {{- if eq .Values.sourceDelivery.mode "sync" }} + # Wait for tar/mutagen delivery, then run the same clusterDev command. + command: + - /bin/bash + - -c + - | + set -e + echo "waiting for source sync at {{ .Values.sourceDelivery.mountPath }}..." + waited=0 + while [ ! -f {{ .Values.sourceDelivery.mountPath }}/.hops-synced ] \ + && [ ! -f {{ .Values.clusterDev.workingDir }}/package.json ]; do + sleep 2 + waited=$((waited + 2)) + if [ $((waited % 30)) -eq 0 ]; then + echo "still waiting for hops source sync (${waited}s) — is tar watcher running?" + fi + if [ "$waited" -ge 600 ]; then + echo "ERROR: source sync timeout after ${waited}s (no .hops-synced / package.json)" + exit 1 + fi + done + echo "source present; starting dev process" + {{- /* Unwrap Values.clusterDev.command ["/bin/bash","-c","script"] */ -}} + {{- $cmd := .Values.clusterDev.command -}} + {{- if and (kindIs "slice" $cmd) (ge (len $cmd) 3) }} + {{ index $cmd 2 | nindent 14 }} + {{- else }} + cd /workspace/js && npm install && npm run build + cd {{ .Values.clusterDev.workingDir }} + npm install + export DISTRIBUTED_SKIP_CLIENT_COMPILE=1 + export E2E_API_ORIGIN="${E2E_API_ORIGIN:-http://e2e-ui-api:8791}" + npm run dev -- --host 0.0.0.0 --port {{ .Values.service.targetPort }} + {{- end }} + {{- else }} + command: + {{- toYaml .Values.clusterDev.command | nindent 12 }} + {{- end }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http + env: + {{- range $k, $v := .Values.env }} + {{- if and (eq $k "AUTH_URL") $.Values.namespace }} + - name: AUTH_URL + value: {{ printf "http://e2e-ui-ui.%s.svc.cluster.local:5180" $.Values.namespace | quote }} + {{- else if and $v (ne $v "") }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- end }} + {{- /* Client creds from Oidc writeConnectionSecretToRef (attribute.*) */ -}} + {{- $oidcConn := include "e2e-ui-ui.oidcConnectionSecretName" . -}} + {{- range $k, $ref := .Values.secretEnv }} + {{- if and $.Values.identity.enabled (or (eq $k "OIDC_CLIENT_ID") (eq $k "OIDC_CLIENT_SECRET")) }} + - name: {{ $k }} + valueFrom: + secretKeyRef: + name: {{ $oidcConn | quote }} + key: {{ if eq $k "OIDC_CLIENT_ID" }}attribute.client_id{{ else }}attribute.client_secret{{ end }} + {{- else }} + - name: {{ $k }} + valueFrom: + secretKeyRef: + name: {{ $ref.secretName | quote }} + key: {{ $ref.key | quote }} + {{- end }} + {{- end }} + {{- with .Values.envFromSecrets }} + envFrom: + {{- range . }} + - secretRef: + name: {{ . | quote }} + {{- end }} + {{- end }} + volumeMounts: + - name: source + mountPath: {{ .Values.sourceDelivery.mountPath }} + - name: node-modules + mountPath: {{ .Values.clusterDev.workingDir }}/node_modules + # Keep monorepo js deps across restarts within the same pod lifecycle. + - name: js-node-modules + mountPath: /workspace/js/node_modules + volumes: + - name: source + {{- if eq .Values.sourceDelivery.mode "hostPath" }} + hostPath: + path: {{ .Values.sourceDelivery.hostPath | quote }} + type: Directory + {{- else }} + emptyDir: {} + {{- end }} + - name: node-modules + emptyDir: {} + - name: js-node-modules + emptyDir: {} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-package.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-package.yaml new file mode 100644 index 000000000..403b1cbc9 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-package.yaml @@ -0,0 +1,27 @@ +{{- if and (not .Values.local) (ne .Values.appRuntime "cluster-dev") (ne .Values.appRuntime "host") }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "e2e-ui-ui.name" . }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/runtime: package +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + hops.ops.com.ai/runtime: package + spec: + containers: + - name: ui + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-human-passwords.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-human-passwords.yaml new file mode 100644 index 000000000..3d4dd4b4c --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-human-passwords.yaml @@ -0,0 +1,41 @@ +{{- if and .Values.externalSecrets .Values.externalSecrets.enabled .Values.externalSecrets.humanPasswords .Values.externalSecrets.humanPasswords.enabled }} +{{- $path := .Values.externalSecrets.humanPasswords.vaultPath | default "e2e-ui/shared/human-passwords" -}} +{{- $target := .Values.externalSecrets.humanPasswords.targetName | default "e2e-human-passwords" -}} +{{- $ns := .Values.identity.humansNamespace | default .Values.identity.mrNamespace | default "default" -}} +# Cluster-shared password secret for demo HumanUsers (not per-worktree). +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ $target }} + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/secret: human-passwords +spec: + refreshInterval: {{ .Values.externalSecrets.refreshInterval | default "1m" | quote }} + secretStoreRef: + name: {{ .Values.externalSecrets.secretStoreRef.name | default "vault" | quote }} + kind: {{ .Values.externalSecrets.secretStoreRef.kind | default "ClusterSecretStore" | quote }} + target: + name: {{ $target | quote }} + creationPolicy: Owner + template: + type: Opaque + metadata: + labels: + hops.ops.com.ai/app: e2e-ui + data: + - secretKey: alice + remoteRef: + key: {{ $path | quote }} + property: alice + - secretKey: bob + remoteRef: + key: {{ $path | quote }} + property: bob + - secretKey: admin + remoteRef: + key: {{ $path | quote }} + property: admin +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-oidc.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-oidc.yaml new file mode 100644 index 000000000..b4b21ef7e --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-oidc.yaml @@ -0,0 +1,37 @@ +{{- if and .Values.externalSecrets .Values.externalSecrets.enabled }} +{{- $ws := include "e2e-ui-ui.workspace" . -}} +{{- $path := .Values.externalSecrets.oidc.vaultPath | default (printf "e2e-ui/%s/oidc" $ws) -}} +{{- $target := .Values.externalSecrets.oidc.targetName | default "e2e-ui-oidc" -}} +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ $target }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/workspace: {{ $ws | quote }} + hops.ops.com.ai/secret: oidc +spec: + refreshInterval: {{ .Values.externalSecrets.refreshInterval | default "1m" | quote }} + secretStoreRef: + name: {{ .Values.externalSecrets.secretStoreRef.name | default "vault" | quote }} + kind: {{ .Values.externalSecrets.secretStoreRef.kind | default "ClusterSecretStore" | quote }} + target: + name: {{ $target | quote }} + creationPolicy: Owner + template: + type: Opaque + metadata: + labels: + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/workspace: {{ $ws | quote }} + data: + - secretKey: AUTH_SECRET + remoteRef: + key: {{ $path | quote }} + property: AUTH_SECRET + - secretKey: ZITADEL_SERVICE_USER_TOKEN + remoteRef: + key: {{ $path | quote }} + property: ZITADEL_SERVICE_USER_TOKEN +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-grants.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-grants.yaml new file mode 100644 index 000000000..0dd90871f --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-grants.yaml @@ -0,0 +1,45 @@ +{{- /* + Cluster-shared role assignments for the demo humans. + + Grant XRs resolve HumanUser + Project IDs from status.atProvider, keeping + live Zitadel UUIDs out of Git. All three resources must share a namespace + because Crossplane ExtraResources name lookups are namespace-local. +*/ -}} +{{- if and .Values.identity.enabled .Values.identity.demoUsers }} +{{- $project := include "e2e-ui-ui.clusterProjectName" . -}} +{{- $projectNs := .Values.identity.projectNamespace | default .Values.identity.mrNamespace | default "default" -}} +{{- $humansNs := .Values.identity.humansNamespace | default $projectNs -}} +{{- if ne $projectNs $humansNs -}} +{{- fail "identity grants require identity.projectNamespace and identity.humansNamespace to match" -}} +{{- end -}} +{{- $pc := .Values.identity.providerConfigRef.name | default "default" -}} +{{- $pck := .Values.identity.providerConfigRef.kind | default "ProviderConfig" -}} +{{- $grants := list + (dict "user" "alice" "roles" (list "user")) + (dict "user" "bob" "roles" (list "user")) + (dict "user" "admin" "roles" (list "user" "admin")) +-}} +{{- range $grant := $grants }} +--- +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: Grant +metadata: + name: {{ printf "e2e-%s-e2e-ui" $grant.user }} + namespace: {{ $projectNs }} + labels: + app.kubernetes.io/name: e2e-ui + app.kubernetes.io/component: identity + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/identity-scope: cluster + hops.ops.com.ai/demo-user: {{ $grant.user | quote }} +spec: + userIdRef: + name: {{ printf "e2e-%s" $grant.user }} + projectIdRef: + name: {{ $project }} + roles: {{ $grant.roles | toJson }} + providerConfigRef: + name: {{ $pc | quote }} + kind: {{ $pck | quote }} +{{- end }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-humans.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-humans.yaml new file mode 100644 index 000000000..316a705db --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-humans.yaml @@ -0,0 +1,52 @@ +{{- /* + Cluster-shared demo humans (alice / bob / admin). + + One set per local control plane — not per worktree. Login names stay plain + so every env uses the same credentials. Project + roles are also cluster- + shared; only the OIDC web app is worktree-scoped. +*/ -}} +{{- /* + orgId intentionally omitted: provider-upjet-zitadel defaults to the + organization of the ProviderConfig credentials (AuthStack firstInstance org). + Never hardcode live org ids in Application values — they change every AuthStack. +*/ -}} +{{- if and .Values.identity.enabled .Values.identity.demoUsers }} +{{- $ns := .Values.identity.humansNamespace | default .Values.identity.projectNamespace | default .Values.identity.mrNamespace | default "default" -}} +{{- $pc := .Values.identity.providerConfigRef.name | default "default" -}} +{{- $pck := .Values.identity.providerConfigRef.kind | default "ProviderConfig" -}} +{{- $pwSecret := .Values.identity.passwordSecret.name | default "e2e-human-passwords" -}} +{{- range $user := list "alice" "bob" "admin" }} +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: HumanUser +metadata: + # Stable cluster-scoped names (no worktree prefix). + name: e2e-{{ $user }} + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: e2e-ui + app.kubernetes.io/component: identity + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/identity-scope: cluster + hops.ops.com.ai/demo-user: {{ $user | quote }} + auth.hops.ops.com.ai/reference-name: {{ printf "e2e-%s" $user }} +spec: + forProvider: + {{- if $.Values.identity.orgId }} + orgId: {{ $.Values.identity.orgId | quote }} + {{- end }} + userName: {{ $user | quote }} + firstName: {{ $user | quote }} + lastName: E2E + displayName: {{ $user | quote }} + email: {{ printf "%s@e2e.local" $user | quote }} + isEmailVerified: true + initialSkipPasswordChange: true + initialPasswordSecretRef: + name: {{ $pwSecret | quote }} + key: {{ $user | quote }} + providerConfigRef: + name: {{ $pc | quote }} + kind: {{ $pck | quote }} +{{- end }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-instance-features.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-instance-features.yaml new file mode 100644 index 000000000..2b7f8833a --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-instance-features.yaml @@ -0,0 +1,29 @@ +{{- /* + Instance Login V2 features (gitops). + + Zitadel honors instance loginV2.baseUri when required=true. That is what + redirects authorize → custom e2e-ui /login (not stock zitadel-login). + + baseUri tracks the UI release namespace (hops --name / helm --namespace). + Note: Features is instance-global in Zitadel — last applied worktree wins if + several UIs enable this on one AuthStack. +*/ -}} +{{- if and .Values.identity.enabled (ne .Values.identity.instanceLoginV2 false) }} +{{- $uiBase := include "e2e-ui-ui.uiBaseURL" . -}} +{{- $ns := include "e2e-ui-ui.releaseNamespace" . -}} +apiVersion: instance.zitadel.m.crossplane.io/v1alpha1 +kind: Features +metadata: + name: e2e-ui-login-v2 + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityLabels" . | nindent 4 }} +spec: + forProvider: + loginV2: + - required: true + baseUri: {{ $uiBase | quote }} + providerConfigRef: + name: {{ .Values.identity.providerConfigRef.name | default "default" | quote }} + kind: {{ .Values.identity.providerConfigRef.kind | default "ProviderConfig" | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-oidc.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-oidc.yaml new file mode 100644 index 000000000..6fabad830 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-oidc.yaml @@ -0,0 +1,61 @@ +{{- /* + Worktree OIDC web app. + + - projectIdRef → shared Project MR (by name + labels), no live UUID in git + - writeConnectionSecretToRef → attribute.client_id / attribute.client_secret + - orgId omitted → ProviderConfig PAT org +*/ -}} +{{- if .Values.identity.enabled }} +{{- $prefix := include "e2e-ui-ui.identityPrefix" . -}} +{{- $project := include "e2e-ui-ui.clusterProjectName" . -}} +{{- $uiBase := include "e2e-ui-ui.uiBaseURL" . -}} +{{- $ns := .Values.identity.mrNamespace | default (include "e2e-ui-ui.releaseNamespace" .) -}} +{{- $projectNs := .Values.identity.projectNamespace | default "default" -}} +{{- $conn := include "e2e-ui-ui.oidcConnectionSecretName" . -}} +{{- $oidcName := include "e2e-ui-ui.oidcResourceName" . -}} +apiVersion: application.zitadel.m.crossplane.io/v1alpha1 +kind: Oidc +metadata: + name: {{ $oidcName }} + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityLabels" . | nindent 4 }} +spec: + forProvider: + name: {{ $oidcName | quote }} + projectIdRef: + name: {{ $project }} + namespace: {{ $projectNs | quote }} + appType: OIDC_APP_TYPE_WEB + authMethodType: OIDC_AUTH_METHOD_TYPE_BASIC + responseTypes: + - OIDC_RESPONSE_TYPE_CODE + grantTypes: + - OIDC_GRANT_TYPE_AUTHORIZATION_CODE + - OIDC_GRANT_TYPE_REFRESH_TOKEN + accessTokenType: OIDC_TOKEN_TYPE_JWT + accessTokenRoleAssertion: true + idTokenRoleAssertion: true + idTokenUserinfoAssertion: true + devMode: true + version: OIDC_VERSION_1_0 + redirectUris: + - {{ printf "%s/auth/callback/oidc" $uiBase | quote }} + - {{ printf "%s/auth/callback" $uiBase | quote }} + {{- range .Values.identity.extraRedirectUris }} + - {{ . | quote }} + {{- end }} + postLogoutRedirectUris: + - {{ printf "%s/" $uiBase | quote }} + {{- range .Values.identity.extraPostLogoutRedirectUris }} + - {{ . | quote }} + {{- end }} + loginVersion: + - loginV2: + - baseUri: {{ $uiBase | quote }} + writeConnectionSecretToRef: + name: {{ $conn | quote }} + providerConfigRef: + name: {{ .Values.identity.providerConfigRef.name | default "default" | quote }} + kind: {{ .Values.identity.providerConfigRef.kind | default "ProviderConfig" | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-project.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-project.yaml new file mode 100644 index 000000000..248b68ec8 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-project.yaml @@ -0,0 +1,28 @@ +{{- /* + Cluster-shared Zitadel Project (one e2e-ui project for the whole CP). + + Roles and demo humans attach here. OIDC apps stay worktree-scoped and + projectIdRef this name. +*/ -}} +{{- if .Values.identity.enabled }} +{{- $name := include "e2e-ui-ui.clusterProjectName" . -}} +{{- $ns := .Values.identity.projectNamespace | default .Values.identity.mrNamespace | default "default" -}} +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Project +metadata: + name: {{ $name }} + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityClusterLabels" . | nindent 4 }} + auth.hops.ops.com.ai/reference-name: {{ $name }} +spec: + forProvider: + name: {{ $name | quote }} + projectRoleAssertion: true + # Reject authentication unless a GitOps-owned Grant assigns a project role. + projectRoleCheck: true + hasProjectCheck: true + providerConfigRef: + name: {{ .Values.identity.providerConfigRef.name | default "default" | quote }} + kind: {{ .Values.identity.providerConfigRef.kind | default "ProviderConfig" | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-roles.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-roles.yaml new file mode 100644 index 000000000..26b65d64a --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-roles.yaml @@ -0,0 +1,51 @@ +{{- /* + Cluster-shared project roles (user / admin) on the shared e2e-ui Project. + orgId omitted — defaults to ProviderConfig credential org (see humans template). +*/ -}} +{{- if .Values.identity.enabled }} +{{- $project := include "e2e-ui-ui.clusterProjectName" . -}} +{{- $ns := .Values.identity.projectNamespace | default .Values.identity.mrNamespace | default "default" -}} +{{- $pc := .Values.identity.providerConfigRef.name | default "default" -}} +{{- $pck := .Values.identity.providerConfigRef.kind | default "ProviderConfig" -}} +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Role +metadata: + name: e2e-role-user + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityClusterLabels" . | nindent 4 }} + hops.ops.com.ai/demo-role: user +spec: + forProvider: + {{- if .Values.identity.orgId }} + orgId: {{ .Values.identity.orgId | quote }} + {{- end }} + roleKey: user + displayName: user + projectIdRef: + name: {{ $project }} + providerConfigRef: + name: {{ $pc | quote }} + kind: {{ $pck | quote }} +--- +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Role +metadata: + name: e2e-role-admin + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityClusterLabels" . | nindent 4 }} + hops.ops.com.ai/demo-role: admin +spec: + forProvider: + {{- if .Values.identity.orgId }} + orgId: {{ .Values.identity.orgId | quote }} + {{- end }} + roleKey: admin + displayName: admin + projectIdRef: + name: {{ $project }} + providerConfigRef: + name: {{ $pc | quote }} + kind: {{ $pck | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/local-human-passwords.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/local-human-passwords.yaml new file mode 100644 index 000000000..83af5e9c8 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/local-human-passwords.yaml @@ -0,0 +1,22 @@ +{{- /* + Cluster-shared demo human passwords (alice/bob/admin). + Declarative local seed so HumanUser MRs can create without manual kubectl. + Not for production — ExternalSecrets when externalSecrets.humanPasswords.enabled. +*/ -}} +{{- if and .Values.local .Values.identity.enabled .Values.identity.demoUsers (ne .Values.identity.seedLocalHumanPasswords false) }} +{{- $name := .Values.identity.passwordSecret.name | default "e2e-human-passwords" -}} +{{- $ns := .Values.identity.humansNamespace | default .Values.identity.projectNamespace | default "default" -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name | quote }} + namespace: {{ $ns | quote }} + labels: + {{- include "e2e-ui-ui.identityClusterLabels" . | nindent 4 }} + hops.ops.com.ai/secret: e2e-human-passwords +type: Opaque +stringData: + alice: {{ .Values.identity.localHumanPasswords.alice | default "Password1!" | quote }} + bob: {{ .Values.identity.localHumanPasswords.bob | default "Password1!" | quote }} + admin: {{ .Values.identity.localHumanPasswords.admin | default "Password1!" | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/local-oidc-secret.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/local-oidc-secret.yaml new file mode 100644 index 000000000..17aa5992d --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/local-oidc-secret.yaml @@ -0,0 +1,32 @@ +{{- /* + Local residual Secret for non-MR credentials only. + + OIDC client id/secret come from the Oidc MR connection Secret + (identity-oidc writeConnectionSecretToRef) — not placeholders here. + ZITADEL_SERVICE_USER_TOKEN is a residual copied from the AuthStack + login-client PAT (or materialized by ExternalSecrets when enabled). + + AUTH_SECRET is a stable local session key (not from Zitadel). +*/ -}} +{{- /* + Local residual Secret for session/login PAT only. + OIDC client id/secret: Oidc MR writeConnectionSecretToRef (attribute.*). + Never put live client ids or org/project ids in git. +*/ -}} +{{- if and .Values.local (ne .Values.identity.seedLocalOidcSecret false) }} +apiVersion: v1 +kind: Secret +metadata: + name: e2e-ui-oidc + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/secret: oidc-local-seed +type: Opaque +stringData: + AUTH_SECRET: {{ .Values.identity.localOidcSeed.authSecret | default "local-workbench-dev-auth-secret-not-for-prod" | quote }} + {{- /* Optional explicit seed; prefer the residual workflow in SECRETS.md. */ -}} + {{- if .Values.identity.localOidcSeed.serviceUserToken }} + ZITADEL_SERVICE_USER_TOKEN: {{ .Values.identity.localOidcSeed.serviceUserToken | quote }} + {{- end }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/service.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/service.yaml new file mode 100644 index 000000000..1bc71f236 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "e2e-ui-ui.name" . }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} +spec: + selector: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/values.yaml b/tests/e2e-ui/ui/.gitops/deploy/values.yaml new file mode 100644 index 000000000..b137a2d02 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/values.yaml @@ -0,0 +1,149 @@ +# Local workbench values for e2e-ui UI. +# Secrets live in K8s Secrets (secretEnv / envFromSecrets) — not here. + +local: false +appRuntime: package # host | cluster-dev | package + +name: e2e-ui-ui +image: + repository: ghcr.io/hops-ops/e2e-ui-ui + tag: latest + pullPolicy: IfNotPresent + +# Source tree is the *distributed* monorepo root (needs tests/e2e-ui/ui + js/). +# Application deliveryPath points there; workingDir is the Svelte app. +clusterDev: + image: node:22-bookworm + # Bump in the local Application after rebuilding shared JS framework output. + # This restarts the Vite process through the watched GitOps deployment. + sourceGeneration: 0 + # Use bash -c (not -lc) so container ENV/PATH stay intact. + command: + - /bin/bash + - -c + - | + set -e + cd /workspace/js && npm install && npm run build + cd /workspace/tests/e2e-ui/ui + npm install + # Skip dctl/cargo: generated clients are committed under src/lib/generated. + export DISTRIBUTED_SKIP_CLIENT_COMPILE=1 + # In-cluster API service (not host localhost). + export E2E_API_ORIGIN="${E2E_API_ORIGIN:-http://e2e-ui-api:8791}" + export E2E_BASE_URL="$E2E_API_ORIGIN" + npm run dev -- --host 0.0.0.0 --port 5180 + workingDir: /workspace/tests/e2e-ui/ui + sourcePath: /workspace + +service: + port: 5180 + targetPort: 5180 + +# Non-secret env only (safe in git). +env: + HOST: "0.0.0.0" + PORT: "5180" + DISTRIBUTED_SKIP_CLIENT_COMPILE: "1" + E2E_API_ORIGIN: "http://e2e-ui-api:8791" + E2E_BASE_URL: "http://e2e-ui-api:8791" + PUBLIC_E2E_API_ORIGIN: "http://e2e-ui-api:8791" + # Overridden at render when hops injects namespace (= --name). + AUTH_URL: "http://e2e-ui-ui.default.svc.cluster.local:5180" + AUTH_TRUST_HOST: "true" + AUTH_USE_SECURE_COOKIES: "false" + # AuthStack issuer (Helm default Service fullname) + OIDC_ISSUER: "http://zitadel-zitadel.auth.svc.cluster.local:8080" + # Optional project id for role-claim scopes. Prefer empty in git — use + # Project status.atProvider.id only if you must pin audience (not in MR git). + OIDC_AUDIENCE: "" + ZITADEL_PROJECT_ID: "" + +# Secret-backed env (never live client ids in git). +# - OIDC_CLIENT_* → Oidc writeConnectionSecretToRef (attribute.client_id/secret) +# - AUTH_SECRET / login PAT → residual e2e-ui-oidc (see SECRETS.md) +secretEnv: + AUTH_SECRET: + secretName: e2e-ui-oidc + key: AUTH_SECRET + # Overridden to Oidc connection secret when identity.enabled. + OIDC_CLIENT_ID: + secretName: e2e-ui-oidc + key: OIDC_CLIENT_ID + OIDC_CLIENT_SECRET: + secretName: e2e-ui-oidc + key: OIDC_CLIENT_SECRET + # login-client PAT (IAM_LOGIN_CLIENT) for custom /login Session API + CreateCallback + ZITADEL_SERVICE_USER_TOKEN: + secretName: e2e-ui-oidc + key: ZITADEL_SERVICE_USER_TOKEN + +# Optional: mount entire secrets as env (unused by default) +envFromSecrets: [] + +# Zitadel identity on the UI chart (OIDC web client + Login V2 host). +# +# Scope (all gitops when identity.enabled): +# - Project + roles + demo humans — cluster-shared (projectNamespace) +# - OIDC web app — per worktree (redirects + app loginV2.baseUri) +# - Instance Features loginV2 — points authorize → this UI's /login +# (instance-global in Zitadel; last applied worktree wins on one AuthStack) +# +# Enable from Application values when the env needs OIDC dogfood. +identity: + enabled: false + # Worktree id for OIDC app name (e2e-ui--web). Default: release ns. + workspace: "" + # Bump to rotate provider-generated client credentials. Requires Application + # syncPolicy.prune=true so the previous OIDC MR is removed by GitOps inventory. + oidcGeneration: 0 + # Shared Project MR name — Roles/Oidc use projectIdRef, not live UUIDs. + projectName: e2e-ui + projectNamespace: default + humansNamespace: default + demoUsers: true + instanceLoginV2: true + # Intentionally disabled: the chart cannot know the AuthStack login-client PAT, + # and must not overwrite the residual Secret on every GitOps reconcile. + seedLocalOidcSecret: false + localOidcSeed: {} + seedLocalHumanPasswords: true + localHumanPasswords: {} + uiService: "" + uiPort: "" + uiBaseURL: "" + extraRedirectUris: + - http://127.0.0.1:5180/auth/callback/oidc + - http://localhost:5180/auth/callback/oidc + - http://127.0.0.1:18101/auth/callback/oidc + - http://localhost:18101/auth/callback/oidc + extraPostLogoutRedirectUris: + - http://127.0.0.1:5180/ + - http://localhost:5180/ + - http://127.0.0.1:18101/ + - http://localhost:18101/ + passwordSecret: + name: e2e-human-passwords + providerConfigRef: + name: default + kind: ClusterProviderConfig + +# Materialize residual secrets from Vault (ClusterSecretStore "vault"). +# Enable after SecretStack CSS is Ready + hops secrets sync vault. +externalSecrets: + enabled: false + refreshInterval: 1m + secretStoreRef: + name: vault + kind: ClusterSecretStore + oidc: + vaultPath: "" # default e2e-ui//oidc + targetName: e2e-ui-oidc + humanPasswords: + enabled: true + vaultPath: "" # default e2e-ui//human-passwords + targetName: "" # default e2e-human-passwords- + +sourceDelivery: + mode: none # none | hostPath | sync + hostPath: "" + mountPath: /workspace diff --git a/tests/e2e-ui/ui/distributed.clients.json b/tests/e2e-ui/ui/distributed.clients.json new file mode 100644 index 000000000..d144d40ff --- /dev/null +++ b/tests/e2e-ui/ui/distributed.clients.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "clients": [ + { + "module": "$distributed", + "surface": "e2e-ui", + "documents": [ + "src/routes/blob/*/+page.graphql", + "src/routes/chat/+page.graphql", + "src/routes/todos/+page.graphql" + ], + "output": "src/lib/generated/user" + }, + { + "module": "$distributed/admin", + "surface": "e2e-ui-admin", + "documents": ["src/routes/admin/+page.graphql"], + "output": "src/lib/generated/admin", + "manifest_entrypoint": "e2e_service::distributed_admin_client_surface" + }, + { + "module": "$distributed/public", + "surface": "e2e-ui-public", + "documents": ["src/routes/chat/+page.graphql"], + "output": "src/lib/generated/public", + "manifest_entrypoint": "e2e_service::distributed_public_client_surface" + } + ] +} diff --git a/tests/e2e-ui/ui/distributed.config.js b/tests/e2e-ui/ui/distributed.config.js index 3cd48e887..04b709003 100644 --- a/tests/e2e-ui/ui/distributed.config.js +++ b/tests/e2e-ui/ui/distributed.config.js @@ -1,9 +1,264 @@ +// @ts-nocheck +// Config script for the Distributed SvelteKit plugin inventory. Checked by +// Node at load time; not part of the app type graph. `// @ts-nocheck` keeps +// `svelte-check` (checkJs) from requiring annotations on every helper. import { dirname, resolve } from 'node:path'; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync +} from 'node:fs'; import { fileURLToPath } from 'node:url'; const uiRoot = dirname(fileURLToPath(import.meta.url)); const e2eRoot = resolve(uiRoot, '..'); const distributedRoot = resolve(uiRoot, '../../..'); +const clientInventoryPath = resolve(uiRoot, 'distributed.clients.json'); + +const CLIENT_INVENTORY_SCHEMA_VERSION = 1; +const MAX_CLIENT_INVENTORY_BYTES = 1024 * 1024; +const MAX_CLIENT_JSON_DEPTH = 24; +const MAX_CLIENT_JSON_BRACKET_DEPTH = MAX_CLIENT_JSON_DEPTH + 1; +const MAX_CLIENT_STRING_BYTES = 4 * 1024; +const CLIENT_MODULE = /^\$distributed(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/; +const CLIENT_INVENTORY_KEYS = new Set(['schema_version', 'clients']); +const SECRET_LIKE = /(?:postgres(?:ql)?:\/\/|mysql:\/\/|mongodb:\/\/|bearer |password=|token=|secret=|-----begin )/i; +const textEncoder = new TextEncoder(); + +function readBoundedInventory(filePath) { + const preflight = lstatSync(filePath); + if (preflight.isSymbolicLink()) { + throw new TypeError('distributed.clients.json must not be a symlink'); + } + if (!preflight.isFile()) { + throw new TypeError('distributed.clients.json must be a regular file'); + } + if (preflight.size > MAX_CLIENT_INVENTORY_BYTES) { + throw new TypeError( + `distributed.clients.json exceeds maximum size ${MAX_CLIENT_INVENTORY_BYTES} bytes` + ); + } + + let descriptor; + try { + const noFollow = constants.O_NOFOLLOW ?? 0; + descriptor = openSync(filePath, constants.O_RDONLY | noFollow); + } catch (error) { + if (error?.code === 'ELOOP') { + throw new TypeError('distributed.clients.json must not be a symlink'); + } + throw error; + } + try { + const metadata = fstatSync(descriptor); + if (!metadata.isFile()) { + throw new TypeError('distributed.clients.json must be a regular file'); + } + if (metadata.size > MAX_CLIENT_INVENTORY_BYTES) { + throw new TypeError( + `distributed.clients.json exceeds maximum size ${MAX_CLIENT_INVENTORY_BYTES} bytes` + ); + } + + const buffer = Buffer.allocUnsafe(MAX_CLIENT_INVENTORY_BYTES + 1); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > MAX_CLIENT_INVENTORY_BYTES) { + throw new TypeError( + `distributed.clients.json exceeds maximum size ${MAX_CLIENT_INVENTORY_BYTES} bytes` + ); + } + return new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, offset)); + } finally { + closeSync(descriptor); + } +} + +function assertJsonNestingDepth(source) { + let depth = 0; + let inString = false; + let escaped = false; + for (const character of source) { + if (inString) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + } else if (character === '{' || character === '[') { + depth += 1; + if (depth > MAX_CLIENT_JSON_BRACKET_DEPTH) { + throw new TypeError( + `distributed.clients.json exceeds maximum JSON nesting depth ${MAX_CLIENT_JSON_DEPTH}` + ); + } + } else if (character === '}' || character === ']') { + depth -= 1; + } + } +} + +function assertPortablePath(value, label, { allowGlob = false } = {}) { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.trim() || + textEncoder.encode(value).length > MAX_CLIENT_STRING_BYTES || + value.includes('\0') || + value.includes('\\') || + SECRET_LIKE.test(value) || + value.startsWith('/') || + value.startsWith('~') || + textEncoder.encode(value)[1] === 0x3a || + value.split('/').some((part) => part === '..' || part === '.') || + (!allowGlob && /[*?\[\]{]/.test(value)) + ) { + throw new TypeError(`${label} must be a portable repository-relative path`); + } +} + +function assertClientIdentifier(value, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.trim() || + textEncoder.encode(value).length > MAX_CLIENT_STRING_BYTES || + value.includes('..') || + SECRET_LIKE.test(value) + ) { + throw new TypeError(`${label} must be a portable identifier`); + } +} + +export function validateClientInventory(value) { + if ( + value === null || + typeof value !== 'object' || + value.schema_version !== CLIENT_INVENTORY_SCHEMA_VERSION || + !Array.isArray(value.clients) || + value.clients.length === 0 || + value.clients.length > 64 + ) { + throw new TypeError( + `distributed.clients.json must be schema version ${CLIENT_INVENTORY_SCHEMA_VERSION} with 1..=64 clients` + ); + } + for (const key of Object.keys(value)) { + if (!CLIENT_INVENTORY_KEYS.has(key)) { + throw new TypeError(`distributed.clients.json contains unsupported field ${key}`); + } + } + const modules = new Set(); + const surfaces = new Set(); + const outputs = new Set(); + const allowedKeys = new Set([ + 'module', + 'surface', + 'documents', + 'output', + 'manifest_entrypoint' + ]); + return Object.freeze( + value.clients.map((client, index) => { + if (client === null || typeof client !== 'object') { + throw new TypeError(`distributed client declaration ${index} must be an object`); + } + for (const key of Object.keys(client)) { + if (!allowedKeys.has(key)) { + throw new TypeError(`distributed client ${index} contains unsupported field ${key}`); + } + } + if ( + typeof client.module !== 'string' || + !CLIENT_MODULE.test(client.module) || + client.module.includes('..') || + textEncoder.encode(client.module).length > MAX_CLIENT_STRING_BYTES || + SECRET_LIKE.test(client.module) + ) { + throw new TypeError(`distributed client ${index} has an invalid module`); + } + if ( + typeof client.surface !== 'string' || + !/^[A-Za-z0-9._:/-]+$/.test(client.surface) || + client.surface.trim().length === 0 + ) { + throw new TypeError(`distributed client ${client.module} has an invalid surface`); + } + assertClientIdentifier(client.surface, `${client.module} surface`); + if (!Array.isArray(client.documents) || client.documents.length === 0 || client.documents.length > 64) { + throw new TypeError(`distributed client ${client.module} must declare 1..=64 documents`); + } + const documents = client.documents.map((document, documentIndex) => { + assertPortablePath(document, `${client.module} documents[${documentIndex}]`, { allowGlob: true }); + if (!document.endsWith('.graphql') && !document.endsWith('.gql')) { + throw new TypeError(`${client.module} documents[${documentIndex}] must end in .graphql or .gql`); + } + if (document.includes('**') || /^[*?\[\]{]/.test(document)) { + throw new TypeError(`${client.module} document glob is unbounded`); + } + return document; + }); + if (new Set(documents).size !== documents.length) { + throw new TypeError(`distributed client ${client.module} contains duplicate documents`); + } + assertPortablePath(client.output, `${client.module} output`); + const manifestEntrypoint = client.manifest_entrypoint ?? undefined; + if (typeof manifestEntrypoint === 'string') { + if ( + manifestEntrypoint.length === 0 || + textEncoder.encode(manifestEntrypoint).length > MAX_CLIENT_STRING_BYTES || + manifestEntrypoint.split('::').some( + (segment) => !/^[A-Za-z0-9_]+$/.test(segment) + ) + ) { + throw new TypeError(`${client.module} manifest_entrypoint is invalid`); + } + } else if (client.manifest_entrypoint !== undefined && client.manifest_entrypoint !== null) { + throw new TypeError(`${client.module} manifest_entrypoint must be a string`); + } + if (modules.has(client.module)) throw new TypeError(`duplicate client module ${client.module}`); + if (surfaces.has(client.surface)) throw new TypeError(`duplicate client surface ${client.surface}`); + if (outputs.has(client.output)) throw new TypeError(`duplicate client output ${client.output}`); + modules.add(client.module); + surfaces.add(client.surface); + outputs.add(client.output); + return Object.freeze({ + module: client.module, + surface: client.surface, + documents: Object.freeze(documents), + output: client.output, + manifest_entrypoint: manifestEntrypoint + }); + }) + ); +} + +export function loadClientInventory(filePath = clientInventoryPath) { + const source = readBoundedInventory(filePath); + assertJsonNestingDepth(source); + let value; + try { + value = JSON.parse(source); + } catch { + throw new TypeError('distributed.clients.json is invalid JSON; check its syntax'); + } + return validateClientInventory(value); +} + +const clientDeclarations = loadClientInventory(); const manifestArgs = [ 'client-manifest', @@ -14,47 +269,25 @@ const manifestArgs = [ '--distributed-path', distributedRoot ]; - -/** App-owned service/surface configuration; all generated behavior is package-owned. */ -export const distributedClients = Object.freeze([ - Object.freeze({ - module: '$distributed', - manifest: Object.freeze({ args: Object.freeze(manifestArgs) }), - surface: 'e2e-ui', - documents: Object.freeze([ - 'src/routes/todos/+page.graphql', - 'src/routes/chat/+page.graphql', - 'src/routes/blob/*/+page.graphql' - ]), - out: 'src/lib/generated/user' - }), - Object.freeze({ - module: '$distributed/admin', - manifest: Object.freeze({ - args: Object.freeze([ - ...manifestArgs, - '--entrypoint', - 'e2e_service::distributed_admin_client_surface' - ]) - }), - surface: 'e2e-ui-admin', - documents: Object.freeze(['src/routes/admin/+page.graphql']), - out: 'src/lib/generated/admin' - }), - Object.freeze({ - module: '$distributed/public', - manifest: Object.freeze({ - args: Object.freeze([ - ...manifestArgs, - '--entrypoint', - 'e2e_service::distributed_public_client_surface' - ]) - }), - surface: 'e2e-ui-public', - documents: Object.freeze(['src/routes/chat/+page.graphql']), - out: 'src/lib/generated/public' - }) -]); +/** App-owned declarations come from distributed.clients.json; this file adds executable and local paths. */ +export const distributedClients = Object.freeze( + clientDeclarations.map((client) => + Object.freeze({ + module: client.module, + manifest: Object.freeze({ + args: Object.freeze([ + ...manifestArgs, + ...(client.manifest_entrypoint === undefined + ? [] + : ['--entrypoint', client.manifest_entrypoint]) + ]) + }), + surface: client.surface, + documents: client.documents, + out: client.output + }) + ) +); export const distributedViteOptions = Object.freeze({ cwd: uiRoot, @@ -67,7 +300,7 @@ export const distributedViteOptions = Object.freeze({ '-p', 'distributed_cli', '--bin', - 'dctl', + 'distributed', '--' ]), clients: distributedClients diff --git a/tests/e2e-ui/ui/scripts/distributed-client.mjs b/tests/e2e-ui/ui/scripts/distributed-client.mjs index 87783da56..e7cd2b9a2 100644 --- a/tests/e2e-ui/ui/scripts/distributed-client.mjs +++ b/tests/e2e-ui/ui/scripts/distributed-client.mjs @@ -8,10 +8,10 @@ import { distributedViteOptions } from '../distributed.config.js'; const mode = process.argv[2]; if (mode === 'generate') { await generateDistributedSvelteKit(distributedViteOptions); - console.log('Generated Distributed user/admin clients from distributed.config.js'); + console.log('Generated Distributed clients from distributed.clients.json'); } else if (mode === 'check') { await checkDistributedSvelteKit(distributedViteOptions); - console.log('Distributed user/admin clients are current'); + console.log('Distributed clients are current'); } else { throw new Error('usage: node scripts/distributed-client.mjs '); } diff --git a/tests/e2e-ui/ui/src/lib/blob/board.ts b/tests/e2e-ui/ui/src/lib/blob/board.ts new file mode 100644 index 000000000..1aa0995d5 --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/blob/board.ts @@ -0,0 +1,36 @@ +/** + * Board JSON helpers for rendering query data. Not game rules — the domain + * owns move outcomes on the server. + */ + +export const TILE = { + hole: 0, + unvisited: 1, + visited: 2, + deadBySuicide: 3, + deadByHole: 4, + player: 9 +} as const; + +export type Direction = 'up' | 'down' | 'left' | 'right'; + +export class BoardParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'BoardParseError'; + } +} + +/** Parse a `map_json` board from the read model. */ +export function parseBoard(mapJson: string): number[][] { + const value = JSON.parse(mapJson || '[]') as unknown; + if ( + !Array.isArray(value) || + !value.every( + (row) => Array.isArray(row) && row.every((cell) => typeof cell === 'number') + ) + ) { + throw new BoardParseError('invalid map_json'); + } + return value as number[][]; +} diff --git a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts deleted file mode 100644 index a590c2b2e..000000000 --- a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Pure move rules — byte-identical twin of `blob_domain::simulate_move`. - * - * Used only to fill command **input** fields for the same `.applies` / - * optimistic-layer path as chat/todos (not a page-local board overlay). - * The server still recomputes authoritatively from `game_id` + `direction`. - */ - -export const TILE = { - hole: 0, - unvisited: 1, - visited: 2, - deadBySuicide: 3, - deadByHole: 4, - player: 9 -} as const; - -export type Direction = 'up' | 'down' | 'left' | 'right'; - -export type MovePreview = { - readonly map: number[][]; - readonly score: number; - readonly player_dead: boolean; - readonly level_complete: boolean; - readonly status: string; - readonly map_json: string; -}; - -export class SimulateMoveError extends Error { - constructor(message: string) { - super(message); - this.name = 'SimulateMoveError'; - } -} - -function statusOf(playerDead: boolean, levelComplete: boolean): string { - if (playerDead) return 'dead'; - if (levelComplete) return 'level_complete'; - return 'active'; -} - -function playerPos(map: number[][]): { r: number; c: number } { - for (let r = 0; r < map.length; r += 1) { - const row = map[r]!; - for (let c = 0; c < row.length; c += 1) { - if (row[c] === TILE.player) return { r, c }; - } - } - throw new SimulateMoveError('no active level'); -} - -/** - * Apply one direction to a map + score. Mirrors `blob_domain::simulate_move`. - */ -export function simulateMove( - map: number[][], - score: number, - direction: Direction -): MovePreview { - if (map.length === 0 || (map[0]?.length ?? 0) === 0) { - throw new SimulateMoveError('no active level'); - } - const { r, c } = playerPos(map); - let nr: number; - let nc: number; - switch (direction) { - case 'up': - if (r === 0) throw new SimulateMoveError('row already 0'); - nr = r - 1; - nc = c; - break; - case 'down': - if (r + 1 >= map.length) throw new SimulateMoveError('already at bottom edge'); - nr = r + 1; - nc = c; - break; - case 'left': - if (c === 0) throw new SimulateMoveError('column already 0'); - nr = r; - nc = c - 1; - break; - case 'right': - if (c + 1 >= (map[r]?.length ?? 0)) { - throw new SimulateMoveError('already at right edge'); - } - nr = r; - nc = c + 1; - break; - default: { - const _exhaustive: never = direction; - throw new SimulateMoveError(`invalid direction: ${_exhaustive}`); - } - } - - const nextMap = map.map((row) => [...row]); - let nextScore = score; - let playerDead = false; - let levelComplete = false; - - nextMap[r]![c] = TILE.visited; - const landing = nextMap[nr]![nc]!; - if (landing === TILE.hole) { - nextMap[nr]![nc] = TILE.deadByHole; - } else if (landing === TILE.visited) { - nextMap[nr]![nc] = TILE.deadBySuicide; - } else if (landing === TILE.unvisited || landing === TILE.player) { - nextScore += 1; - nextMap[nr]![nc] = TILE.player; - } else { - nextMap[nr]![nc] = TILE.deadBySuicide; - } - - for (const row of nextMap) { - if (row.includes(TILE.deadByHole) || row.includes(TILE.deadBySuicide)) { - playerDead = true; - levelComplete = false; - break; - } - } - if (!playerDead) { - levelComplete = !nextMap.some((row) => row.includes(TILE.unvisited)); - } - - return Object.freeze({ - map: nextMap, - score: nextScore, - player_dead: playerDead, - level_complete: levelComplete, - status: statusOf(playerDead, levelComplete), - map_json: JSON.stringify(nextMap) - }); -} - -/** Parse a `map_json` board; throws if shape is not `number[][]`. */ -export function parseBoard(mapJson: string): number[][] { - const value = JSON.parse(mapJson || '[]') as unknown; - if ( - !Array.isArray(value) || - !value.every( - (row) => Array.isArray(row) && row.every((cell) => typeof cell === 'number') - ) - ) { - throw new SimulateMoveError('invalid map_json'); - } - return value as number[][]; -} diff --git a/tests/e2e-ui/ui/src/lib/chat/lobby-log.ts b/tests/e2e-ui/ui/src/lib/chat/lobby-log.ts index 0d053c221..7436f1c7b 100644 --- a/tests/e2e-ui/ui/src/lib/chat/lobby-log.ts +++ b/tests/e2e-ui/ui/src/lib/chat/lobby-log.ts @@ -70,6 +70,14 @@ export function needsHistoryFill(m: ChatLogMetrics): boolean { return m.scrollHeight <= m.clientHeight + 1; } +/** A complete newest window shorter than its limit proves there is no older page. */ +export function liveWindowProvesHistoryExhausted( + messageCount: number, + pageSize: number +): boolean { + return messageCount < pageSize; +} + export type HistoryPageResult = Readonly<{ /** Ascending rows to prepend (already reversed from server desc). */ fresh: readonly T[]; diff --git a/tests/e2e-ui/ui/src/lib/components/HmrBeacon.svelte b/tests/e2e-ui/ui/src/lib/components/HmrBeacon.svelte new file mode 100644 index 000000000..7f4af7d4b --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/components/HmrBeacon.svelte @@ -0,0 +1,2 @@ + +End to end · Rust · TypeScript diff --git a/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte b/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte index f675cd327..2775100e3 100644 --- a/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte +++ b/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte @@ -14,6 +14,8 @@ let { demo, defaultOpen = false }: Props = $props(); + // Intentionally seed once from the prop (deep-link); toggles use `open` only. + // svelte-ignore state_referenced_locally let open = $state(defaultOpen); let activeTab = $state(0); @@ -51,7 +53,7 @@ {/if} -

+