diff --git a/CHANGELOG.md b/CHANGELOG.md index cf60f391..b09c1d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,17 @@ This file is the authoritative user-facing release chronology. The former [release-notes ledger](docs/archive/2026/graphql-orm-release-notes.md) is retained for historical context. +## 0.43.0 (unreleased) + +- Add opt-in typed runtime GraphQL mutations delegating to guarded B transactions, + with atomic host journals and independently authorized selected outputs. +- Add an object-safe, default-deny post-commit record-release callback, explicit + deleted preimages and sanitized committed-outcome denial/failure/timeout errors. +- Execute repeated runtime GraphQL mutation response-key groups once, merging + payload projections, preserving distinct-alias order and sharing terminal + outcomes after failure or cancellation. Budgets charge each group once. +- Align ORM/macros at 0.43.0; static APIs/SDL and default features remain unchanged. + ## 0.42.0 (unreleased) - Fix runtime GraphQL alias swaps and scalar/relation output collisions; retain requested filter capabilities before empty-list constant lowering so authorization cannot be bypassed. diff --git a/Cargo.lock b/Cargo.lock index fc345171..6002939a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.42.0" +version = "0.43.0" dependencies = [ "agql-auth", "async-graphql", @@ -3247,7 +3247,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.42.0" +version = "0.43.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 3a090bda..f9c2c131 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.42.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.43.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.15.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.2", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.0" } diff --git a/MIGRATION.md b/MIGRATION.md index ca646652..42d7d317 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,23 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## 0.42.0 to 0.43.0: opt-in runtime GraphQL mutations + +No data migration or static API/SDL change. Mutation registration requires an +explicit options builder, checked mutation root, certified environment and host +write authority. Existing read authorities remain source-compatible; releasing +mutation records now requires an explicit post-commit callback override. Default +denial leaves committed writes/journals intact and reports a committed outcome. +Repeated mutation fields with the same validated response key now execute once +with a merged projection, including fragment repeats. Distinct aliases retain +separate transactions in document order. Repeated occurrences reuse terminal +payloads/errors and never restart canceled work. A host extension continuing +execution after resolver cancellation receives nonretryable committed or unknown +outcome errors according to commit confirmation; whole-request cancellation can +still prevent delivery. +See [runtime GraphQL](docs/reference/graphql-orm/runtime-graphql.md) for callback, +null/default/key behavior, cancellation uncertainty and compiled examples. + ## 0.41.0 to 0.42.0: opt-in runtime GraphQL reads Enable `runtime-graphql` alongside an explicit SQLite or PostgreSQL feature. No diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 02875bd4..70a10925 100644 --- a/crates/graphql-orm-macros/Cargo.toml +++ b/crates/graphql-orm-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-macros" -version = "0.42.0" +version = "0.43.0" edition = "2024" authors = ["Toby Martin"] description = "Procedural macros for async-graphql and ORM-backed entities, relations, and CRUD operations." diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index b2916bc1..0ed70a53 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.42.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.43.0", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.42.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.43.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -183,6 +183,6 @@ See [host timestamp inputs, migration compatibility and cancellation limits](../ Complete SQLite/PostgreSQL text-group pages and SQL-visible ordinary aggregates: see [typed aggregates](../../docs/reference/graphql-orm/typed-aggregates.md). -Version 0.42.0 aligns with the schema-bound transactional runtime mutation addition. Released host-managed +Version 0.43.0 aligns with the schema-bound transactional runtime mutation addition. Released host-managed timestamps, framework-neutral repository aggregate helpers and static GraphQL SDL remain unchanged. diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index eb09b35e..7942ee11 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1028,7 +1028,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.42.0" +version = "0.43.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.42.0" +version = "0.43.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 2efa9b65..7cefb12a 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.42.0" +version = "0.43.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -71,7 +71,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.42.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.43.0", default-features = false } graphql-orm-operation-catalog = { workspace = true } rust_decimal = { workspace = true } serde = { version = "1", features = ["derive"] } diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index 6053d369..0b3e7d4a 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.42.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.43.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the diff --git a/crates/graphql-orm/examples/runtime_graphql_reads.rs b/crates/graphql-orm/examples/runtime_graphql_reads.rs index c9a76a4e..58dcc150 100644 --- a/crates/graphql-orm/examples/runtime_graphql_reads.rs +++ b/crates/graphql-orm/examples/runtime_graphql_reads.rs @@ -33,10 +33,98 @@ mod example { #[graphql_orm(auto_generated = false)] id: String, exact: i64, + #[filterable] label: String, } + #[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] + #[repository_entity(table = "example_journal", plural = "ExampleJournal")] + struct ExampleJournal { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + action: String, + } + struct JournalHook; + impl RuntimeMutationHook for JournalHook { + fn before_commit<'a>( + &'a self, + tx: &'a mut MutationContext<'_, Backend>, + effect: &'a RuntimeMutationEffect, + ) -> BoxFuture<'a, Result<(), RuntimeMutationError>> { + Box::pin(async move { + tx.insert::(CreateExampleJournalInput { + id: graphql_orm::uuid::Uuid::new_v4().to_string(), + action: format!("{:?}", effect.action()), + }) + .await + .map_err(|_| RuntimeMutationError::new(RuntimeMutationErrorCode::HookFailed))?; + Ok(()) + }) + } + } + struct PublicWrite(Arc); + impl RuntimeWriteAuthority for PublicWrite { + fn authorize_intent<'a>( + &'a self, + check: RuntimeWriteIntent<'a>, + _: &'a mut MutationContext<'_, Backend>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + // Explicitly public disposable fixture; production hosts pin policy/revision here. + let metadata = self + .0 + .schema() + .collections + .iter() + .find(|c| &c.id == check.collection().id()) + .ok_or_else(|| RuntimeMutationError::new(RuntimeMutationErrorCode::Denied))?; + let fields = metadata + .fields + .iter() + .map(|f| self.0.resolve_field(check.collection(), &f.id)) + .collect::, _>>()?; + RuntimeWriteGrant::new( + self.0.resolve_projection(check.collection(), &fields)?, + None, + ) + }) + } + fn authorize_preimage<'a>( + &'a self, + _: RuntimePreimageCheck<'a>, + _: &'a mut MutationContext<'_, Backend>, + ) -> BoxFuture<'a, Result<(), RuntimeMutationError>> { + Box::pin(async { Ok(()) }) + } + fn authorize_result<'a>( + &'a self, + check: RuntimeResultCheck<'a>, + _: &'a mut MutationContext<'_, Backend>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(RuntimeReturnGrant::new(check.intent.returning().cloned())) }) + } + } struct PublicRead; impl RuntimeReadAuthority for PublicRead { + fn authorize_mutation_result<'a>( + &'a self, + check: RuntimeMutationOutputCheck<'a>, + ) -> BoxFuture<'a, Result<(), RuntimeGraphqlError>> { + Box::pin(async move { + // Explicit public-data release, including immutable deleted preimages. + // Policy-required values must be loaded; no hidden field fallback. + for field in check.projection.fields() { + if matches!( + check.record.state(field), + Ok(RuntimeFieldState::Unloaded) | Err(_) + ) { + return Err(RuntimeGraphqlError::denied()); + } + } + Ok(()) + }) + } + fn authorize<'a>( &'a self, _: RuntimeReadCheck<'a>, @@ -108,8 +196,10 @@ mod example { // Typed repository setup is independent of GraphQL registration. let schema = Arc::new(RuntimeSchema::from_static_entities(&[PublicNote::metadata()])?.validate()?); - let target = schema.physical_schema::(Default::default())?; - let ownership = ManagedTableSet::new(["public_notes".into()])?; + let target = schema + .physical_schema::(Default::default())? + .with_static_entities(&[ExampleJournal::metadata()])?; + let ownership = ManagedTableSet::new(["public_notes".into(), "example_journal".into()])?; let plan = database .schema() .plan_owned_migration( @@ -133,11 +223,21 @@ mod example { }, ) .await?; - let module = RuntimeGraphqlModule::compile(schema.clone(), Default::default())?; + let environment = Arc::new( + database + .schema() + .runtime_mutation_environment(schema.clone(), &target, &ownership) + .await?, + ); + let module = RuntimeGraphqlModule::compile( + schema.clone(), + RuntimeGraphqlOptions::default().with_mutations(Default::default()), + )?; let cipher = HostCipher(ChaCha20Poly1305::new(&ChaCha20Poly1305::generate_key( &mut OsRng, ))); - let api = RuntimeGraphqlComposer::new(database, "Query", Default::default())? + let api = RuntimeGraphqlComposer::new(database.clone(), "Query", Default::default())? + .mutation_root("Mutation")? .cursor_protection( Arc::new(cipher), RuntimeCursorAudience::new("upstream-example")?, @@ -161,7 +261,35 @@ mod example { "9007199254740993" ); assert_eq!(data["publicNotes"]["totalCount"], "1"); - println!("authorized runtime GraphQL read/count and protected cursor passed"); + for operation in [ + r#"createPublicNote(input:{id:"note-two",label:"draft",exact:"9007199254740993"},expectedSchema:$schema)"#, + r#"updatePublicNote(key:{id:"note-two"},input:{label:"published"},expected:{label:{eq:"draft"}},expectedSchema:$schema)"#, + r#"deletePublicNote(key:{id:"note-two"},expectedSchema:$schema)"#, + ] { + let request = Request::new(format!( + "mutation($schema:String!){{{operation}{{record{{label exact}}}}}}" + )) + .variables(graphql_orm::async_graphql::Variables::from_json( + graphql_orm::serde_json::json!({"schema":schema.fingerprint().to_string()}), + )) + .data( + RuntimeGraphqlRequest::::new( + schema.fingerprint(), + Arc::new(PublicRead), + None, + Default::default(), + ) + .with_mutation_environment(environment.clone()) + .with_write_authority(Arc::new(PublicWrite(schema.clone()))) + .with_mutation_hook(Arc::new(JournalHook)), + ); + let response = api.execute(request).await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + } + assert_eq!(ExampleJournal::count_all(&database).await?, 3); + println!( + "authorized runtime GraphQL reads, confidential cursors and committed typed mutations/journals passed" + ); Ok(()) } } diff --git a/crates/graphql-orm/src/graphql/orm/runtime_mutation.rs b/crates/graphql-orm/src/graphql/orm/runtime_mutation.rs index 5dd1f3e3..536ce995 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_mutation.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_mutation.rs @@ -240,6 +240,25 @@ pub struct RuntimeWriteGrant { predicate: Option, } impl RuntimeWriteGrant { + #[cfg(feature = "runtime-graphql")] + pub(crate) fn graphql_restrict( + self, + schema: &ValidatedRuntimeSchema, + collection: &RuntimeCollectionHandle, + visibility: Option, + limits: RuntimeQueryLimits, + ) -> Result { + let predicate = match (self.predicate, visibility) { + (Some(a), Some(b)) => Some( + schema + .runtime_and(collection, vec![a, b], limits) + .map_err(|e| err(RuntimeMutationErrorCode::InvalidInput).with_source(e))?, + ), + (a, b) => a.or(b), + }; + Self::new(self.projection, predicate) + } + /// Reject cross-schema/collection predicates; this does not narrow input approval. pub fn new( projection: RuntimeProjection, diff --git a/crates/graphql-orm/src/graphql/runtime/execution.rs b/crates/graphql-orm/src/graphql/runtime/execution.rs index 6c68418c..c6943596 100644 --- a/crates/graphql-orm/src/graphql/runtime/execution.rs +++ b/crates/graphql-orm/src/graphql/runtime/execution.rs @@ -32,7 +32,7 @@ pub struct RuntimeReadCheck<'a> { } /// Explicit host grant. No provider or error means denial. pub struct RuntimeReadGrant { - predicate: Option, + pub(super) predicate: Option, } impl RuntimeReadGrant { pub fn new(predicate: Option) -> Self { @@ -41,6 +41,15 @@ impl RuntimeReadGrant { } /// Hosts authorize every field, operator, traversal and count in the check. pub trait RuntimeReadAuthority: Send + Sync + 'static { + /// Independently release a transaction-captured record after confirmed commit. + /// Missing policy-required values must deny; this callback cannot roll back work. + fn authorize_mutation_result<'a>( + &'a self, + _check: RuntimeMutationOutputCheck<'a>, + ) -> BoxFuture<'a, Result<(), RuntimeGraphqlError>> { + Box::pin(async { Err(RuntimeGraphqlError::denied()) }) + } + fn authorize<'a>( &'a self, check: RuntimeReadCheck<'a>, @@ -48,11 +57,14 @@ pub trait RuntimeReadAuthority: Send + Sync + 'static { } /// Trusted request data. Reuse does not reuse execution caches or grants. pub struct RuntimeGraphqlRequest { - fingerprint: SchemaFingerprint, - authority: Arc, - auth: Option, - budget: RuntimeGraphqlLimits, + pub(super) fingerprint: SchemaFingerprint, + pub(super) authority: Arc, + pub(super) auth: Option, + pub(super) budget: RuntimeGraphqlLimits, scope: Option, + pub(super) mutation_environment: Option>, + pub(super) write_authority: Option>, + pub(super) mutation_hook: Option>, backend: PhantomData, } impl RuntimeGraphqlRequest { @@ -68,6 +80,9 @@ impl RuntimeGraphqlRequest { auth, budget, scope: None, + mutation_environment: None, + write_authority: None, + mutation_hook: None, backend: PhantomData, } } @@ -83,7 +98,10 @@ pub(super) struct Protection { pub audience: RuntimeCursorAudience, pub limits: RuntimeCursorProtectionLimits, } -pub(super) struct Results(pub BTreeMap); +pub(super) struct Results( + pub BTreeMap, + pub BTreeMap, +); pub(super) struct GuardFactory { pub database: Database, pub modules: Vec, @@ -324,6 +342,23 @@ impl Guard { expanded: 0, cost: Default::default(), }; + if operation.node.ty == async_graphql::parser::types::OperationType::Mutation { + let root = ctx + .schema_env + .registry + .mutation_type + .as_deref() + .ok_or_else(invalid)?; + return super::mutation::prepare( + &self.modules, + request, + &mut planner, + &operation.node.selection_set.node, + root, + &ctx.schema_env.registry, + ) + .await; + } let root_type = ctx.schema_env.registry.query_type.as_str(); let selected = planner.selections(&operation.node.selection_set.node, root_type, 0)?; let mut plans = Vec::new(); @@ -350,7 +385,7 @@ impl Guard { } } if plans.is_empty() { - return Ok(Results(BTreeMap::new())); + return Ok(Results(BTreeMap::new(), BTreeMap::new())); } let request = request.ok_or_else(|| RuntimeGraphqlError::new("authorization_missing"))?; // Complete all layers' grants and cursor framing checks before any ORM I/O. @@ -399,7 +434,7 @@ impl Guard { limits.max_response_bytes, )?; } - Ok(Results(values)) + Ok(Results(values, BTreeMap::new())) } } fn authorize<'a, B: RuntimeReadBackend>( @@ -840,11 +875,12 @@ fn attach(nodes: &mut [Value], key: &str, values: Vec) -> Result<(), Runt Ok(()) } -struct OutputShape { +#[derive(Clone)] +pub(super) struct OutputShape { fields: Vec<(String, String, OutputShape)>, } impl OutputShape { - fn compile( + pub(super) fn compile( planner: &mut Planner<'_>, registry: &async_graphql::registry::Registry, set: &async_graphql::parser::types::SelectionSet, @@ -878,7 +914,7 @@ impl OutputShape { } Ok(Self { fields }) } - fn measure( + pub(super) fn measure( &self, value: &Value, bytes: &mut usize, diff --git a/crates/graphql-orm/src/graphql/runtime/mod.rs b/crates/graphql-orm/src/graphql/runtime/mod.rs index 08210a04..8829ffab 100644 --- a/crates/graphql-orm/src/graphql/runtime/mod.rs +++ b/crates/graphql-orm/src/graphql/runtime/mod.rs @@ -26,6 +26,12 @@ impl RuntimeGraphqlError { code: "authorization_denied", } } + /// Safe host authorization-service failure, without underlying details. + pub const fn unavailable() -> Self { + Self { + code: "authorization_unavailable", + } + } pub(crate) const fn new(code: &'static str) -> Self { Self { code } } @@ -44,3 +50,8 @@ pub use execution::{ RuntimeGraphqlRequest, RuntimeReadAuthority, RuntimeReadCheck, RuntimeReadGrant, }; pub use module::*; + +mod mutation; +pub use mutation::{ + RuntimeGraphqlMutationOptions, RuntimeMutationOutputCheck, RuntimeMutationRecordKind, +}; diff --git a/crates/graphql-orm/src/graphql/runtime/module.rs b/crates/graphql-orm/src/graphql/runtime/module.rs index 318383e6..d704c4eb 100644 --- a/crates/graphql-orm/src/graphql/runtime/module.rs +++ b/crates/graphql-orm/src/graphql/runtime/module.rs @@ -17,8 +17,15 @@ pub struct RuntimeGraphqlOptions { pub type_prefix: String, pub root_names: BTreeMap, pub cursor_profile: RuntimeCursorProfile, + pub(super) mutations: Option, } impl RuntimeGraphqlOptions { + /// Enable typed runtime mutations explicitly; reads remain the default. + pub fn with_mutations(mut self, options: RuntimeGraphqlMutationOptions) -> Self { + self.mutations = Some(options); + self + } + /// Select cursor privacy explicitly. The default requires authenticated encryption. pub fn with_cursor_profile(mut self, profile: RuntimeCursorProfile) -> Self { self.cursor_profile = profile; @@ -285,6 +292,17 @@ impl RuntimeGraphqlModule { } } } + for c in &module.schema.schema().collections { + for name in super::mutation::type_names(&module, c) { + if !valid_name(&name, true) + || !Arc::make_mut(&mut module.descriptor).names.insert(name) + { + return Err(RuntimeGraphqlError::new("name_collision").into()); + } + } + } + // Validate representable input objects before publication. + let _ = super::mutation::types(&module)?; if module.options.root_names.keys().any(|id| { !module .schema @@ -320,8 +338,8 @@ impl RuntimeGraphqlModule { /// Root field whose actual name is checked by the composer. pub struct RuntimeHostField { - name: String, - field: Field, + pub(super) name: String, + pub(super) field: Field, arguments: BTreeSet, } impl RuntimeHostField { @@ -357,12 +375,16 @@ impl RuntimeHostField { } /// Checked dynamic composition. It never accepts an opaque raw builder callback. pub struct RuntimeGraphqlComposer { - database: Database, + pub(super) database: Database, + pub(super) mutation: Option, + pub(super) mutation_name: Option, + pub(super) mutation_roots: BTreeSet, + pub(super) mutation_fields: Option, query: Object, query_name: String, subscription_name: Option, actions: Vec SchemaBuilder + Send>>, - names: BTreeSet, + pub(super) names: BTreeSet, roots: BTreeSet, modules: Vec, limits: RuntimeGraphqlLimits, @@ -390,6 +412,10 @@ impl RuntimeGraphqlComposer { } Ok(Self { database, + mutation: None, + mutation_name: None, + mutation_roots: BTreeSet::new(), + mutation_fields: None, query: Object::new(&query_name), query_name, subscription_name: None, @@ -445,6 +471,27 @@ impl RuntimeGraphqlComposer { self = self.register(ty)?; } } + if module.options.mutations.is_some() { + let factory = self + .mutation_fields + .as_ref() + .ok_or_else(|| RuntimeGraphqlError::new("invalid_composition"))?; + let fields = factory(module.clone())?; + for (name, field) in fields { + if !self.mutation_roots.insert(name) { + return Err(RuntimeGraphqlError::new("name_collision").into()); + } + self.mutation = Some( + self.mutation + .take() + .ok_or_else(|| RuntimeGraphqlError::new("invalid_composition"))? + .field(field), + ); + } + for ty in super::mutation::types(&module)? { + self = self.register(ty)?; + } + } for ty in super::schema::types(&module) { self = self.register(ty)?; } @@ -528,7 +575,11 @@ impl RuntimeGraphqlComposer { self } - pub fn finish(self) -> Result { + pub fn finish(mut self) -> Result { + if let Some(root) = self.mutation.take() { + self.actions + .push(Box::new(move |builder| builder.register(root))); + } if self.protection.is_none() && self .modules @@ -546,9 +597,13 @@ impl RuntimeGraphqlComposer { self.actions .into_iter() .fold( - Schema::build(&self.query_name, None, self.subscription_name.as_deref()) - .extension(guard) - .limit_directives(self.limits.max_selections), + Schema::build( + &self.query_name, + self.mutation_name.as_deref(), + self.subscription_name.as_deref(), + ) + .extension(guard) + .limit_directives(self.limits.max_selections), |builder, action| action(builder), ) .register(self.query) diff --git a/crates/graphql-orm/src/graphql/runtime/mutation.rs b/crates/graphql-orm/src/graphql/runtime/mutation.rs new file mode 100644 index 00000000..8dc2fd33 --- /dev/null +++ b/crates/graphql-orm/src/graphql/runtime/mutation.rs @@ -0,0 +1,819 @@ +//! Checked dynamic mutation registration over the runtime mutation engine. +use super::*; +use crate::graphql::orm::*; +use async_graphql::dynamic::*; + +/// Explicit opt-in; each enabled action uses a separate state-machine transaction. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct RuntimeGraphqlMutationOptions { + pub create: bool, + pub update: bool, + pub delete: bool, + pub limits: RuntimeMutationLimits, + /// Bounds only post-commit record authorization, not the transaction itself. + pub output_authorization_timeout: std::time::Duration, +} +impl Default for RuntimeGraphqlMutationOptions { + fn default() -> Self { + Self { + create: true, + update: true, + delete: true, + limits: Default::default(), + output_authorization_timeout: std::time::Duration::from_secs(5), + } + } +} +pub(super) fn actions(module: &RuntimeGraphqlModule) -> Vec<(&'static str, RuntimeMutationAction)> { + let Some(options) = &module.options.mutations else { + return vec![]; + }; + [ + (options.create, "create", RuntimeMutationAction::Create), + (options.update, "update", RuntimeMutationAction::Update), + (options.delete, "delete", RuntimeMutationAction::Delete), + ] + .into_iter() + .filter_map(|(enabled, prefix, action)| enabled.then_some((prefix, action))) + .collect() +} +pub(super) fn type_names(module: &RuntimeGraphqlModule, c: &RuntimeCollection) -> Vec { + let name = module.object_name(c); + let Some(options) = &module.options.mutations else { + return vec![]; + }; + let mut names = vec![format!("{name}MutationPayload")]; + if options.create { + names.push(format!("Create{name}Input")); + } + if options.update { + names.push(format!("Update{name}Input")); + } + if options.update || options.delete { + names.push(format!("{name}KeyInput")); + } + names +} +pub(super) fn types(module: &RuntimeGraphqlModule) -> Result, RuntimeGraphqlError> { + let Some(options) = &module.options.mutations else { + return Ok(vec![]); + }; + let mut types = vec![]; + for c in &module.schema.schema().collections { + let name = module.object_name(c); + types.push( + Object::new(format!("{name}MutationPayload")) + .field(super::schema::output("record", TypeRef::named(&name))) + .into(), + ); + if options.create { + let mut input = InputObject::new(format!("Create{name}Input")); + let mut count = 0; + for f in c.fields.iter().filter(|f| !f.generated) { + let required = + !f.nullable && (f.default.is_none() || c.primary_key.contains(&f.id)); + input = input.field(InputValue::new( + &f.api_name, + if required { + TypeRef::named_nn(super::scalar::name(f.value_kind)) + } else { + TypeRef::named(super::scalar::name(f.value_kind)) + }, + )); + count += 1; + } + if count == 0 { + return Err(RuntimeGraphqlError::new("unsupported_capability")); + } + types.push(input.into()); + } + if options.update { + let mut input = InputObject::new(format!("Update{name}Input")); + let mut count = 0; + for f in c + .fields + .iter() + .filter(|f| !f.generated && !c.primary_key.contains(&f.id)) + { + input = input.field(InputValue::new( + &f.api_name, + TypeRef::named(super::scalar::name(f.value_kind)), + )); + count += 1; + } + if count == 0 { + return Err(RuntimeGraphqlError::new("unsupported_capability")); + } + types.push(input.into()); + } + if options.update || options.delete { + let mut input = InputObject::new(format!("{name}KeyInput")); + for id in &c.primary_key { + let f = c + .fields + .iter() + .find(|f| &f.id == id) + .ok_or_else(|| RuntimeGraphqlError::new("invalid_composition"))?; + input = input.field(InputValue::new( + &f.api_name, + TypeRef::named_nn(super::scalar::name(f.value_kind)), + )); + } + types.push(input.into()); + } + } + Ok(types) +} + +use crate::db::Database; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; +pub(super) type FieldFactory = Box< + dyn Fn(RuntimeGraphqlModule) -> Result, RuntimeGraphqlError> + Send + Sync, +>; + +impl RuntimeGraphqlRequest { + pub fn with_mutation_environment( + mut self, + environment: Arc, + ) -> Self { + self.mutation_environment = Some(environment); + self + } + pub fn with_write_authority(mut self, authority: Arc>) -> Self { + self.write_authority = Some(Arc::new(authority)); + self + } + pub fn with_mutation_hook(mut self, hook: Arc>) -> Self { + self.mutation_hook = Some(Arc::new(hook)); + self + } +} +impl RuntimeGraphqlComposer { + pub fn mutation_root(mut self, name: impl Into) -> Result { + let name = name.into(); + if !B::RUNTIME_MUTATIONS_SUPPORTED { + return Err(RuntimeGraphqlError::new("unsupported_backend")); + } + if self.mutation.is_some() + || !super::module::valid_name(&name, true) + || !self.names.insert(name.clone()) + { + return Err(RuntimeGraphqlError::new("name_collision")); + } + self.mutation = Some(Object::new(&name)); + self.mutation_name = Some(name); + let database = self.database.clone(); + self.mutation_fields = Some(Box::new(move |module| fields(database.clone(), module))); + Ok(self) + } + pub fn mutation_field(mut self, field: RuntimeHostField) -> Result { + if !super::module::valid_name(&field.name, false) || !self.mutation_roots.insert(field.name) + { + return Err(RuntimeGraphqlError::new("name_collision")); + } + self.mutation = Some( + self.mutation + .take() + .ok_or_else(|| RuntimeGraphqlError::new("invalid_composition"))? + .field(field.field), + ); + Ok(self) + } +} +fn fields( + database: Database, + module: RuntimeGraphqlModule, +) -> Result, RuntimeGraphqlError> { + let mut fields = vec![]; + for c in &module.schema.schema().collections { + let object = module.object_name(c); + for (prefix, action) in actions(&module) { + let name = format!("{prefix}{object}"); + if !super::module::valid_name(&name, false) { + return Err(RuntimeGraphqlError::new("name_collision")); + } + let database = database.clone(); + let mut field = Field::new( + &name, + TypeRef::named_nn(format!("{object}MutationPayload")), + move |ctx| { + let database = database.clone(); + FieldFuture::new(async move { execute(database, ctx).await.map(Some) }) + }, + ) + .argument(InputValue::new( + "expectedSchema", + TypeRef::named_nn("String"), + )); + if action != RuntimeMutationAction::Create { + field = field + .argument(InputValue::new( + "key", + TypeRef::named_nn(format!("{object}KeyInput")), + )) + .argument(InputValue::new( + "expected", + TypeRef::named(format!("{object}WhereInput")), + )); + } + if action != RuntimeMutationAction::Delete { + let kind = if action == RuntimeMutationAction::Create { + "Create" + } else { + "Update" + }; + field = field.argument(InputValue::new( + "input", + TypeRef::named_nn(format!("{kind}{object}Input")), + )); + } + fields.push((name, field)); + } + } + Ok(fields) +} + +use super::plan::{Planner, invalid}; +use async_graphql::{Value, parser::types::SelectionSet}; +#[derive(Clone)] +pub(super) struct Prepared { + module: RuntimeGraphqlModule, + collection: RuntimeCollectionHandle, + request: RuntimeMutationRequest, + returning: Option, + visibility: Option, + limits: RuntimeGraphqlLimits, + shape: super::execution::OutputShape, + response_bytes: Arc, + // Shared by occurrences of this response key, fresh for every execution. + outcome: Arc>, +} + +enum GroupOutcome { + Ready, + Running, + Finished(async_graphql::Result), +} +enum GroupStart { + Cached(async_graphql::Result), + Execute(GroupExecution), +} +/// Owns the only execution attempt. Dropping an unfinished future is terminal too. +struct GroupExecution { + outcome: Arc>, + committed: bool, + finished: bool, +} +impl GroupExecution { + fn start(outcome: &Arc>) -> async_graphql::Result { + let mut state = outcome.lock().map_err(|_| uncertain_error())?; + match &*state { + GroupOutcome::Finished(result) => Ok(GroupStart::Cached(result.clone())), + // The locked executor is serial at mutation roots. Fail closed if a host + // extension reenters an in-progress resolver; never start a second attempt. + GroupOutcome::Running => Ok(GroupStart::Cached(Err(uncertain_error()))), + GroupOutcome::Ready => { + *state = GroupOutcome::Running; + Ok(GroupStart::Execute(Self { + outcome: outcome.clone(), + committed: false, + finished: false, + })) + } + } + } + fn finish(&mut self, result: &async_graphql::Result) { + if let Ok(mut state) = self.outcome.lock() { + *state = GroupOutcome::Finished(result.clone()); + } + self.finished = true; + } +} +impl Drop for GroupExecution { + fn drop(&mut self) { + if !self.finished { + let error = if self.committed { + committed_error("mutation_payload_canceled") + } else { + // Cancellation may occur during COMMIT. Do not promise rollback or + // invite automatic retries when the outcome cannot be confirmed. + uncertain_error() + }; + if let Ok(mut state) = self.outcome.lock() { + *state = GroupOutcome::Finished(Err(error)); + } + } + } +} +fn uncertain_error() -> async_graphql::Error { + use async_graphql::ErrorExtensions; + wire_error("mutation_outcome_unknown").extend_with(|_, extensions| { + extensions.set("outcome", "unknown"); + extensions.set("retryable", false); + }) +} +fn mutation_error(error: RuntimeMutationError) -> RuntimeGraphqlError { + RuntimeGraphqlError::new(error.code().as_str()) +} +fn record_error(_: RuntimeRecordError) -> RuntimeGraphqlError { + RuntimeGraphqlError::new("invalid_graphql_input") +} +fn input_fields( + module: &RuntimeGraphqlModule, + c: &RuntimeCollection, + collection: &RuntimeCollectionHandle, + input: &Value, + limits: RuntimeGraphqlLimits, +) -> Result, RuntimeGraphqlError> { + let Value::Object(values) = input else { + return Err(invalid()); + }; + values + .iter() + .map(|(name, value)| { + let field = c + .fields + .iter() + .find(|f| f.api_name == name.as_str()) + .ok_or_else(invalid)?; + let handle = module + .schema + .resolve_field(collection, &field.id) + .map_err(record_error)?; + let value = if value == &Value::Null { + RuntimeValue::Null + } else { + super::scalar::decode(field.value_kind, value, limits.scalars)? + }; + Ok((handle, value)) + }) + .collect() +} +pub(super) async fn prepare( + modules: &[RuntimeGraphqlModule], + context: Option<&RuntimeGraphqlRequest>, + planner: &mut Planner<'_>, + selections: &SelectionSet, + root: &str, + registry: &async_graphql::registry::Registry, +) -> Result { + let mut prepared = BTreeMap::new(); + let response_bytes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + for field in planner.selections(selections, root, 0)? { + for module in modules { + for c in &module.schema.schema().collections { + for (prefix, action) in actions(module) { + let object = module.object_name(c); + if field.name.node.as_str() != format!("{prefix}{object}") { + continue; + } + let context = + context.ok_or_else(|| RuntimeGraphqlError::new("authorization_missing"))?; + if context.write_authority.is_none() || context.mutation_environment.is_none() { + return Err(RuntimeGraphqlError::new("authorization_missing")); + } + let args = planner.resolve(&field)?; + if context.fingerprint != module.schema.fingerprint() + || args.get("expectedSchema") + != Some(&Value::String(module.schema.fingerprint().to_string())) + { + return Err(RuntimeGraphqlError::new("schema_mismatch")); + } + let collection = module + .schema + .resolve_collection(&c.id) + .map_err(record_error)?; + let mut fields = vec![]; + for payload in planner.selections( + &field.selection_set.node, + &format!("{object}MutationPayload"), + 1, + )? { + if payload.name.node == "__typename" { + continue; + } + if payload.name.node != "record" { + return Err(invalid()); + } + for selected in + planner.selections(&payload.selection_set.node, &object, 2)? + { + if selected.name.node == "__typename" { + continue; + } + let f = c + .fields + .iter() + .find(|f| f.api_name == selected.name.node.as_str()) + .ok_or_else(|| { + RuntimeGraphqlError::new("unsupported_capability") + })?; + let handle = module + .schema + .resolve_field(&collection, &f.id) + .map_err(record_error)?; + if !fields.contains(&handle) { + fields.push(handle); + } + } + } + let mut intent = super::plan::FilterIntent::default(); + let expected = args + .get("expected") + .filter(|v| **v != Value::Null) + .map(|value| { + super::plan::filter( + &module.schema, + &collection, + c, + value, + planner.limits, + 0, + &mut intent, + ) + }) + .transpose()?; + let order = module + .schema + .runtime_order(&collection, None, planner.limits.query) + .map_err(super::plan::query_error)?; + let grant = if fields.is_empty() && expected.is_none() { + RuntimeReadGrant::new(None) + } else { + context + .authority + .authorize(RuntimeReadCheck { + schema: &module.schema, + collection: &collection, + projection: &fields, + filter: expected.as_ref(), + filter_operations: intent + .operations + .iter() + .map(|(f, op)| (f, *op)) + .collect(), + requested_order: &[], + order_was_supplied: false, + order: &order, + logical_operators: intent.logical, + relation: None, + include_count: false, + }) + .await? + }; + let returning = if fields.is_empty() { + None + } else { + Some( + module + .schema + .resolve_projection(&collection, &fields) + .map_err(record_error)?, + ) + }; + let options = module.options.mutations.as_ref().ok_or_else(invalid)?; + let mut limits = options.limits; + macro_rules! restrict { + ($($field:ident),* $(,)?) => { + $(limits.query.$field = limits.query.$field.min(planner.limits.query.$field);)* + } + } + restrict!( + max_predicate_depth, + max_predicate_nodes, + max_values_per_list, + max_bind_parameters, + max_order_terms, + max_projection_fields, + default_page_size, + max_page_size, + max_cursor_bytes, + max_cursor_values + ); + limits.max_record_bytes = limits + .max_record_bytes + .min(planner.limits.max_response_bytes); + let input = args + .get("input") + .map(|v| input_fields(module, c, &collection, v, planner.limits)) + .transpose()? + .unwrap_or_default(); + let request = if action == RuntimeMutationAction::Create { + module.schema.runtime_create_request( + &collection, + &input, + returning.clone(), + limits, + ) + } else { + let keys = input_fields( + module, + c, + &collection, + args.get("key").ok_or_else(invalid)?, + planner.limits, + )?; + let key = module + .schema + .runtime_key(&collection, &keys) + .map_err(mutation_error)?; + if action == RuntimeMutationAction::Update { + module.schema.runtime_update_request( + key, + &input, + expected, + returning.clone(), + limits, + ) + } else { + module.schema.runtime_delete_request( + key, + expected, + returning.clone(), + limits, + ) + } + } + .map_err(mutation_error)?; + // Reserve B's preimage, DML and result reads, excluding host hook I/O. + if prepared.len().saturating_add(1).saturating_mul(4) + > planner.limits.max_statements + || prepared.len().saturating_add(1) > planner.limits.max_materialized_nodes + { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let shape = super::execution::OutputShape::compile( + planner, + registry, + &field.selection_set.node, + &format!("{object}MutationPayload"), + 0, + )?; + prepared.insert( + field.response_key().node.to_string(), + Prepared { + module: module.clone(), + collection, + request, + returning, + visibility: grant.predicate, + limits: planner.limits, + shape, + response_bytes: response_bytes.clone(), + outcome: Arc::new(Mutex::new(GroupOutcome::Ready)), + }, + ); + } + } + } + } + Ok(super::execution::Results(BTreeMap::new(), prepared)) +} + +struct AuthorityAdapter { + host: Arc>, + prepared: Prepared, +} +impl RuntimeWriteAuthority for AuthorityAdapter { + fn authorize_intent<'a>( + &'a self, + check: RuntimeWriteIntent<'a>, + tx: &'a mut MutationContext<'_, B>, + ) -> futures::future::BoxFuture<'a, Result> { + Box::pin(async move { + let grant = self.host.authorize_intent(check, tx).await?; + grant.graphql_restrict( + &self.prepared.module.schema, + &self.prepared.collection, + self.prepared.visibility.clone(), + self.prepared.limits.query, + ) + }) + } + fn authorize_preimage<'a>( + &'a self, + check: RuntimePreimageCheck<'a>, + tx: &'a mut MutationContext<'_, B>, + ) -> futures::future::BoxFuture<'a, Result<(), RuntimeMutationError>> { + self.host.authorize_preimage(check, tx) + } + fn authorize_result<'a>( + &'a self, + check: RuntimeResultCheck<'a>, + tx: &'a mut MutationContext<'_, B>, + ) -> futures::future::BoxFuture<'a, Result> { + self.host.authorize_result(check, tx) + } +} +fn wire_error(code: &'static str) -> async_graphql::Error { + use async_graphql::ErrorExtensions; + async_graphql::Error::new(code).extend_with(|_, extensions| extensions.set("code", code)) +} +async fn execute( + database: Database, + ctx: ResolverContext<'_>, +) -> async_graphql::Result> { + let context = ctx.data::>()?; + let selection = ctx.field(); + let key = selection.alias().unwrap_or(selection.name()); + let prepared = ctx + .data::()? + .1 + .get(key) + .ok_or_else(|| wire_error("authorization_missing"))? + .clone(); + let mut execution = match GroupExecution::start(&prepared.outcome)? { + GroupStart::Cached(result) => return result.map(super::schema::field_value), + GroupStart::Execute(execution) => execution, + }; + let result = execute_once(database, context, &prepared, key, &mut execution).await; + execution.finish(&result); + result.map(super::schema::field_value) +} +async fn execute_once( + database: Database, + context: &RuntimeGraphqlRequest, + prepared: &Prepared, + key: &str, + execution: &mut GroupExecution, +) -> async_graphql::Result { + let environment = context + .mutation_environment + .clone() + .ok_or_else(|| wire_error("authorization_missing"))?; + let authority = context + .write_authority + .as_ref() + .and_then(|a| a.downcast_ref::>>()) + .ok_or_else(|| wire_error("authorization_missing"))? + .clone(); + let hook = context + .mutation_hook + .as_ref() + .map(|h| { + h.downcast_ref::>>() + .cloned() + .ok_or_else(|| wire_error("authorization_missing")) + }) + .transpose()?; + let request = prepared.request.clone(); + let adapter = AuthorityAdapter { + host: authority, + prepared: prepared.clone(), + }; + let effect = database + .transaction_with_auth( + TransactionMode::StateMachine, + context.auth.as_ref(), + move |tx| { + Box::pin(async move { + let effect = tx + .mutate_runtime(&environment, &request, &adapter) + .await + .map_err(crate::graphql::errors::OrmPublicError::from)?; + if let Some(hook) = hook { + tx.run_runtime_before_commit(hook.as_ref(), &effect) + .await + .map_err(crate::graphql::errors::OrmPublicError::from)?; + } + Ok(effect) + }) + }, + ) + .await + .map_err(|error| { + let code = match error { + TransactionError::Rejected(error) => { + error.runtime_mutation_code().unwrap_or("mutation_rejected") + } + TransactionError::Retryable(_) => "transaction_retryable", + TransactionError::Failed(_) => "transaction_failed", + }; + wire_error(code) + })?; + execution.committed = true; + let Some(projection) = prepared.returning.as_ref() else { + let payload = async_graphql::value!({"record":null}); + reserve_payload(prepared, &payload, key)?; + return Ok(payload); + }; + let record = effect + .record() + .ok_or_else(|| committed_error("mutation_payload_failed"))?; + let kind = if effect.action() == RuntimeMutationAction::Delete { + RuntimeMutationRecordKind::DeletedPreimage + } else { + RuntimeMutationRecordKind::CommittedResult + }; + let timeout = prepared + .module + .options + .mutations + .as_ref() + .ok_or_else(|| committed_error("mutation_payload_failed"))? + .output_authorization_timeout; + let decision = tokio::time::timeout( + timeout, + context + .authority + .authorize_mutation_result(RuntimeMutationOutputCheck { + schema: &prepared.module.schema, + collection: &prepared.collection, + action: effect.action(), + kind, + projection, + record, + }), + ) + .await; + match decision { + Ok(Ok(())) => {} + Ok(Err(error)) if error == RuntimeGraphqlError::denied() => { + return Err(committed_error("mutation_payload_denied")); + } + Ok(Err(_)) => return Err(committed_error("mutation_payload_failed")), + Err(_) => return Err(committed_error("mutation_payload_timeout")), + } + // Serialize only the authorized selected projection, never internal policy/key fields. + let mut values = async_graphql::indexmap::IndexMap::new(); + let metadata = prepared + .module + .schema + .schema() + .collections + .iter() + .find(|c| &c.id == prepared.collection.id()) + .ok_or_else(|| committed_error("mutation_payload_failed"))?; + for field in projection.fields() { + let value = match record + .state(field) + .map_err(|_| committed_error("mutation_payload_failed"))? + { + RuntimeFieldState::Value(value) => { + super::scalar::encode(value, prepared.limits.scalars) + .map_err(|_| committed_error("mutation_payload_failed"))? + } + RuntimeFieldState::Null => Value::Null, + RuntimeFieldState::Unloaded => return Err(committed_error("mutation_payload_failed")), + }; + let name = &metadata + .fields + .iter() + .find(|f| &f.id == field.id()) + .ok_or_else(|| committed_error("mutation_payload_failed"))? + .api_name; + values.insert(async_graphql::Name::new(name), value); + } + let payload = Value::Object( + [(async_graphql::Name::new("record"), Value::Object(values))] + .into_iter() + .collect(), + ); + reserve_payload(prepared, &payload, key)?; + Ok(payload) +} + +/// The immutable transaction-captured image presented after confirmed commit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeMutationRecordKind { + CommittedResult, + /// The deleted row's authorized preimage; never a post-commit database reread. + DeletedPreimage, +} +/// Independent release check. Unselected/internal fields remain unloaded. +pub struct RuntimeMutationOutputCheck<'a> { + pub schema: &'a ValidatedRuntimeSchema, + pub collection: &'a RuntimeCollectionHandle, + pub action: RuntimeMutationAction, + pub kind: RuntimeMutationRecordKind, + pub projection: &'a RuntimeProjection, + pub record: &'a RuntimeRecord, +} +fn committed_error(code: &'static str) -> async_graphql::Error { + use async_graphql::ErrorExtensions; + wire_error(code).extend_with(|_, extensions| { + extensions.set("outcome", "committed"); + extensions.set("retryable", false); + }) +} + +fn reserve_payload(prepared: &Prepared, payload: &Value, key: &str) -> async_graphql::Result<()> { + use std::sync::atomic::Ordering; + let maximum = prepared.limits.max_response_bytes; + let mut bytes = key.len().saturating_mul(6).saturating_add(8); + prepared + .shape + .measure(payload, &mut bytes, maximum) + .map_err(|_| committed_error("mutation_payload_failed"))?; + prepared + .response_bytes + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |used| { + used.checked_add(bytes).filter(|total| *total <= maximum) + }) + .map_err(|_| committed_error("mutation_payload_failed"))?; + Ok(()) +} diff --git a/crates/graphql-orm/src/graphql/runtime/plan.rs b/crates/graphql-orm/src/graphql/runtime/plan.rs index 4d3d3036..5bd7ad0d 100644 --- a/crates/graphql-orm/src/graphql/runtime/plan.rs +++ b/crates/graphql-orm/src/graphql/runtime/plan.rs @@ -56,7 +56,7 @@ pub(super) struct Planner<'a> { pub cost: RuntimeGraphqlCost, } impl Planner<'_> { - fn resolve( + pub(super) fn resolve( &self, v: &async_graphql::parser::types::Field, ) -> Result, RuntimeGraphqlError> { @@ -81,7 +81,9 @@ impl Planner<'_> { if depth > self.limits.max_depth { return Err(RuntimeGraphqlError::new("cost_exceeded")); } - let mut fields: BTreeMap = BTreeMap::new(); + // A response-key group keeps its first occurrence in document order. + let mut fields: async_graphql::indexmap::IndexMap = + async_graphql::indexmap::IndexMap::new(); for selection in &set.items { self.expanded = self.expanded.checked_add(1).ok_or_else(invalid)?; if self.expanded > self.limits.max_selections { @@ -402,7 +404,7 @@ pub(super) struct FilterIntent { pub logical: Vec<&'static str>, } -fn filter( +pub(super) fn filter( schema: &ValidatedRuntimeSchema, collection: &RuntimeCollectionHandle, c: &RuntimeCollection, diff --git a/crates/graphql-orm/src/graphql/runtime/schema.rs b/crates/graphql-orm/src/graphql/runtime/schema.rs index f28b9c26..33c7a07b 100644 --- a/crates/graphql-orm/src/graphql/runtime/schema.rs +++ b/crates/graphql-orm/src/graphql/runtime/schema.rs @@ -18,7 +18,7 @@ pub(super) fn field_value(value: Value) -> FieldValue<'static> { pub(super) fn relation_output_key(name: &str, response_key: &str) -> String { format!("{name}:{response_key}") } -fn output(name: &str, ty: TypeRef) -> Field { +pub(super) fn output(name: &str, ty: TypeRef) -> Field { Field::new(name, ty, |ctx| { FieldFuture::new(async move { let value = &ctx diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 1bde33bb..88390ee0 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1350,7 +1350,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.42.0" +version = "0.43.0" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.42.0" +version = "0.43.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock index 94be809b..a53788af 100644 --- a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.42.0" +version = "0.43.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.42.0" +version = "0.43.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock index ab08faea..194b3c04 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.42.0" +version = "0.43.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.42.0" +version = "0.43.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock index 9d578eb4..7857611d 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.42.0" +version = "0.43.0" dependencies = [ "async-graphql", "async-trait", @@ -1032,7 +1032,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.42.0" +version = "0.43.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/runtime_mutations.rs b/crates/graphql-orm/tests/runtime_mutations.rs index fe082a45..048dcae5 100644 --- a/crates/graphql-orm/tests/runtime_mutations.rs +++ b/crates/graphql-orm/tests/runtime_mutations.rs @@ -1367,3 +1367,7 @@ async fn emitted_suffix_bind_budgets_precede_target_reads_for_cas_and_authority( } f.finish().await; } + +#[cfg(feature = "runtime-graphql")] +#[path = "support/runtime_graphql_mutations.rs"] +mod graphql_mutations; diff --git a/crates/graphql-orm/tests/support/runtime_graphql_mutations.rs b/crates/graphql-orm/tests/support/runtime_graphql_mutations.rs new file mode 100644 index 00000000..295a0368 --- /dev/null +++ b/crates/graphql-orm/tests/support/runtime_graphql_mutations.rs @@ -0,0 +1,1011 @@ +// Reuse B's owned database, journal, policy and cancellation fixtures. +use super::*; +use graphql_orm::{ + async_graphql::{Request, Variables, dynamic::Schema}, + graphql::runtime::*, +}; +struct Read; +impl RuntimeReadAuthority for Read { + fn authorize<'a>( + &'a self, + check: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + if check + .filter_operations + .iter() + .any(|(field, _)| field.id().as_str() == "tenant") + { + return Err(RuntimeGraphqlError::denied()); + } + Ok(RuntimeReadGrant::new(None)) + }) + } +} +fn api(f: &Fixture) -> Schema { + let module = RuntimeGraphqlModule::compile( + Arc::new(f.schema().clone()), + RuntimeGraphqlOptions::default() + .with_cursor_profile(RuntimeCursorProfile::Unprotected) + .with_mutations(Default::default()), + ) + .unwrap(); + RuntimeGraphqlComposer::new(f.db.clone(), "Query", Default::default()) + .unwrap() + .mutation_root("Mutation") + .unwrap() + .install(module) + .unwrap() + .finish() + .unwrap() +} +fn request(f: &Fixture, query: &str) -> Request { + Request::new(query) + .variables(Variables::from_json(serde_json::json!({ + "schema": f.schema().fingerprint().to_string() + }))) + .data(context(f)) +} +fn context(f: &Fixture) -> RuntimeGraphqlRequest { + RuntimeGraphqlRequest::new( + f.schema().fingerprint(), + Arc::new(Read), + None, + Default::default(), + ) + .with_mutation_environment(f.environment.clone()) + .with_write_authority(Arc::new(f.authority())) +} +#[tokio::test] +async fn typed_roots_delegate_create_cas_delete_and_require_keys() { + let f = Fixture::new().await; + let api = api(&f); + let sdl = api.sdl(); + assert!(sdl.contains("id: RuntimeInt64!")); + let invalid = api + .execute(request( + &f, + r#"mutation($schema:String!){ + createRecord(input:{tenant:"north",label:"initial"},expectedSchema:$schema){__typename} + }"#, + )) + .await; + assert!(!invalid.errors.is_empty()); + assert_eq!(f.count().await, 0); + let created = api.execute(request(&f, r#"mutation($schema:String!){ + createRecord(input:{id:"1",tenant:"north",label:"initial"},expectedSchema:$schema){__typename} + }"#)).await; + assert!(created.errors.is_empty(), "{:?}", created.errors); + assert_eq!(f.count().await, 1); + let update = r#"mutation($schema:String!){ + updateRecord(key:{id:"1"},input:{label:"updated"},expected:{label:{eq:"initial"}}, + expectedSchema:$schema){__typename} + }"#; + let updated = api.execute(request(&f, update)).await; + assert!(updated.errors.is_empty(), "{:?}", updated.errors); + let conflict = api.execute(request(&f, update)).await; + assert!(!conflict.errors.is_empty()); + let denied = api + .execute(request( + &f, + r#"mutation($schema:String!){ + updateRecord(key:{id:"1"},input:{score:"8"},expected:{tenant:{notIn:[]}}, + expectedSchema:$schema){__typename} + }"#, + )) + .await; + assert!(!denied.errors.is_empty()); + let deleted = api + .execute(request( + &f, + r#"mutation($schema:String!){ + deleteRecord(key:{id:"1"},expectedSchema:$schema){__typename} + }"#, + )) + .await; + assert!(deleted.errors.is_empty(), "{:?}", deleted.errors); + assert_eq!(f.count().await, 0); + f.finish().await; +} + +const CREATE: &str = r#"mutation($schema:String!){ + createRecord(input:{id:"1",tenant:"north",label:"initial"},expectedSchema:$schema){__typename} +}"#; +#[tokio::test] +async fn guarded_journal_failure_and_whole_request_cancellation_roll_back() { + let f = Fixture::new().await; + let api = api(&f); + let mut events = f.db.ensure_event_sender::().subscribe(); + for pending in [false, true] { + let staged = Arc::new(AtomicBool::new(false)); + let hook = Arc::new(JournalHook { + staged: staged.clone(), + pending, + fail: !pending, + }); + let request = request(&f, CREATE).data(context(&f).with_mutation_hook(hook)); + if pending { + assert!( + tokio::time::timeout(Duration::from_millis(150), api.execute(request)) + .await + .is_err() + ); + } else { + assert!(!api.execute(request).await.errors.is_empty()); + } + assert!(staged.load(Ordering::SeqCst)); + assert_eq!(f.count().await, 0); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 0); + assert!(events.try_recv().is_err()); + } + let request = request(&f, CREATE).data(context(&f).with_mutation_hook(Arc::new(JournalHook { + staged: Arc::new(AtomicBool::new(false)), + pending: false, + fail: false, + }))); + let response = api.execute(request).await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!(f.count().await, 1); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 1); + assert_eq!(events.try_recv().unwrap(), 20); + f.finish().await; +} +#[tokio::test] +async fn competing_graphql_cas_roots_cannot_both_commit() { + let f = Fixture::new().await; + let api = api(&f); + assert!(api.execute(request(&f, CREATE)).await.errors.is_empty()); + let query = r#"mutation($schema:String!){ + updateRecord(key:{id:"1"},input:{score:"8"},expected:{score:{eq:"7"}}, + expectedSchema:$schema){__typename} + }"#; + let (a, b) = tokio::join!( + api.execute(request(&f, query)), + api.execute(request(&f, query)) + ); + assert_eq!( + usize::from(a.errors.is_empty()) + usize::from(b.errors.is_empty()), + 1 + ); + // No blind retry is performed by the adapter. + f.finish().await; +} +#[tokio::test] +async fn composite_required_keys_nullability_and_policy_revision_fail_closed() { + let mut definition = definition(); + definition.collections[0] + .primary_key + .push(FieldId::new("tenant").unwrap()); + let f = Fixture::with_definition(definition).await; + let api = api(&f); + let mut old = f.authority(); + old.expected_policy_revision = 0; + let response = api + .execute(request(&f, CREATE).data(context(&f).with_write_authority(Arc::new(old)))) + .await; + assert!(!response.errors.is_empty()); + assert_eq!(f.count().await, 0); + assert!(api.execute(request(&f, CREATE)).await.errors.is_empty()); + for input in ["{label:null}", "{id:\"2\"}"] { + let response = api + .execute(request( + &f, + &format!( + r#"mutation($schema:String!){{ + updateRecord(key:{{id:"1",tenant:"north"}},input:{input}, + expectedSchema:$schema){{__typename}} + }}"# + ), + )) + .await; + assert!(!response.errors.is_empty()); + } + let response = api + .execute(request( + &f, + r#"mutation($schema:String!){ + updateRecord(key:{id:"1"},input:{label:"changed"},expectedSchema:$schema){__typename} + }"#, + )) + .await; + assert!(!response.errors.is_empty()); + for input in ["{optional:\"value\"}", "{score:\"9\"}", "{optional:null}"] { + let response = api + .execute(request( + &f, + &format!( + r#"mutation($schema:String!){{ + updateRecord(key:{{id:"1",tenant:"north"}},input:{input}, + expectedSchema:$schema){{__typename}} + }}"# + ), + )) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + } + f.finish().await; +} + +struct OutputRead { + db: Database, + mode: u8, + delete: bool, + calls: Arc, +} +impl RuntimeReadAuthority for OutputRead { + fn authorize<'a>( + &'a self, + check: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result> { + Read.authorize(check) + } + fn authorize_mutation_result<'a>( + &'a self, + check: RuntimeMutationOutputCheck<'a>, + ) -> BoxFuture<'a, Result<(), RuntimeGraphqlError>> { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!( + Journal::count_all(&self.db).await.unwrap(), + 1, + "callback is after journal commit" + ); + assert_eq!(check.collection.id().as_str(), "records"); + assert_eq!( + check.kind == RuntimeMutationRecordKind::DeletedPreimage, + self.delete + ); + assert_eq!(check.action == RuntimeMutationAction::Delete, self.delete); + assert_eq!(check.projection.fields().len(), 1); + let field = check + .schema + .resolve_field(check.collection, &FieldId::new("label").unwrap()) + .unwrap(); + assert_eq!( + check.record.value(&field).unwrap(), + &RuntimeValue::String("initial".into()) + ); + for hidden in ["id", "tenant", "score"] { + let field = check + .schema + .resolve_field(check.collection, &FieldId::new(hidden).unwrap()) + .unwrap(); + assert!(matches!( + check.record.state(&field).unwrap(), + RuntimeFieldState::Unloaded + )); + } + match self.mode { + 0 => Ok(()), + 1 => Err(RuntimeGraphqlError::denied()), + 2 => Err(RuntimeGraphqlError::unavailable()), + 3 => std::future::pending().await, + _ => unreachable!(), + } + }) + } +} +#[tokio::test] +async fn post_commit_allow_denial_error_timeout_and_deleted_preimages() { + for delete in [false, true] { + // 4 selects the unmodified, default-denying C-only authority. + for mode in 0..6 { + let f = Fixture::new().await; + let mut options = RuntimeGraphqlMutationOptions::default(); + options.output_authorization_timeout = if mode == 5 { + Duration::from_secs(10) + } else { + Duration::from_millis(30) + }; + let module = RuntimeGraphqlModule::compile( + Arc::new(f.schema().clone()), + RuntimeGraphqlOptions::default() + .with_cursor_profile(RuntimeCursorProfile::Unprotected) + .with_mutations(options), + ) + .unwrap(); + let api = RuntimeGraphqlComposer::new(f.db.clone(), "Query", Default::default()) + .unwrap() + .mutation_root("Mutation") + .unwrap() + .install(module) + .unwrap() + .finish() + .unwrap(); + if delete { + f.run(f.create(1), f.authority()).await.unwrap(); + } + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let read: Arc = if mode == 4 { + Arc::new(Read) + } else { + Arc::new(OutputRead { + db: f.db.clone(), + mode: if mode == 5 { 3 } else { mode }, + delete, + calls: calls.clone(), + }) + }; + let context = RuntimeGraphqlRequest::new( + f.schema().fingerprint(), + read, + None, + Default::default(), + ) + .with_mutation_environment(f.environment.clone()) + .with_write_authority(Arc::new(f.authority())) + .with_mutation_hook(Arc::new(JournalHook { + staged: Arc::new(AtomicBool::new(false)), + pending: false, + fail: false, + })); + let query = if delete { + r#"mutation($schema:String!){deleteRecord(key:{id:"1"},expectedSchema:$schema){copy:record{caption:label}}}"# + } else { + r#"mutation($schema:String!){createRecord(input:{id:"1",tenant:"north",label:"initial"},expectedSchema:$schema){copy:record{caption:label}}}"# + }; + if mode == 5 { + assert!( + tokio::time::timeout( + Duration::from_millis(150), + api.execute(request(&f, query).data(context)) + ) + .await + .is_err() + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(f.count().await, if delete { 0 } else { 1 }); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 1); + f.finish().await; + continue; + } + let response = api.execute(request(&f, query).data(context)).await; + assert_eq!(f.count().await, if delete { 0 } else { 1 }); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 1); + if mode == 0 { + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data.into_json().unwrap()[if delete { + "deleteRecord" + } else { + "createRecord" + }]["copy"]["caption"], + "initial" + ); + } else { + assert_eq!(response.errors.len(), 1); + let error = &response.errors[0]; + let extensions = error.extensions.as_ref().unwrap(); + assert_eq!( + extensions.get("outcome"), + Some(&graphql_orm::async_graphql::Value::String( + "committed".into() + )) + ); + assert_eq!( + extensions.get("retryable"), + Some(&graphql_orm::async_graphql::Value::Boolean(false)) + ); + assert_eq!( + error.message, + match mode { + 1 | 4 => "mutation_payload_denied", + 2 => "mutation_payload_failed", + 3 => "mutation_payload_timeout", + _ => unreachable!(), + } + ); + assert!(!format!("{response:?}").contains("initial")); + } + assert_eq!(calls.load(Ordering::SeqCst), usize::from(mode != 4)); + f.finish().await; + } + } +} + +struct DenyProjection; +impl RuntimeReadAuthority for DenyProjection { + fn authorize<'a>( + &'a self, + _: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async { Err(RuntimeGraphqlError::denied()) }) + } +} +#[tokio::test] +async fn precommit_projection_and_result_checks_remain_required() { + let f = Fixture::new().await; + let api = api(&f); + let query = r#"mutation($schema:String!){createRecord(input:{id:"1",tenant:"north",label:"initial"},expectedSchema:$schema){record{label}}}"#; + let context = RuntimeGraphqlRequest::new( + f.schema().fingerprint(), + Arc::new(DenyProjection), + None, + Default::default(), + ) + .with_mutation_environment(f.environment.clone()) + .with_write_authority(Arc::new(f.authority())); + assert!( + !api.execute(request(&f, query).data(context)) + .await + .errors + .is_empty() + ); + assert_eq!(f.count().await, 0); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let read = Arc::new(OutputRead { + db: f.db.clone(), + mode: 0, + delete: false, + calls: calls.clone(), + }); + let mut write = f.authority(); + write.deny_result = true; + let context = + RuntimeGraphqlRequest::new(f.schema().fingerprint(), read, None, Default::default()) + .with_mutation_environment(f.environment.clone()) + .with_write_authority(Arc::new(write)); + assert!( + !api.execute(request(&f, query).data(context)) + .await + .errors + .is_empty() + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(f.count().await, 0); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 0); + f.finish().await; +} + +#[derive(Default)] +struct GroupProbe { + intents: std::sync::atomic::AtomicUsize, + projections: std::sync::Mutex>>, + released: std::sync::Mutex>, + hooks: std::sync::Mutex>, +} +struct GroupRead { + probe: Arc, + // 0 allow, 1 deny, 2 error, 3 await cancellation/timeout, 4 deny score selection. + mode: u8, +} +impl RuntimeReadAuthority for GroupRead { + fn authorize<'a>( + &'a self, + check: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.probe.projections.lock().unwrap().push( + check + .projection + .iter() + .map(|f| f.id().as_str().to_owned()) + .collect(), + ); + if self.mode == 4 && check.projection.iter().any(|f| f.id().as_str() == "score") { + return Err(RuntimeGraphqlError::denied()); + } + Ok(RuntimeReadGrant::new(None)) + }) + } + fn authorize_mutation_result<'a>( + &'a self, + check: RuntimeMutationOutputCheck<'a>, + ) -> BoxFuture<'a, Result<(), RuntimeGraphqlError>> { + Box::pin(async move { + let label = check + .schema + .resolve_field(check.collection, &FieldId::new("label").unwrap()) + .unwrap(); + let RuntimeFieldState::Value(RuntimeValue::String(label)) = + check.record.state(&label).unwrap() + else { + panic!("merged label projection must be present"); + }; + assert_eq!( + check.kind, + if check.action == RuntimeMutationAction::Delete { + RuntimeMutationRecordKind::DeletedPreimage + } else { + RuntimeMutationRecordKind::CommittedResult + } + ); + self.probe + .released + .lock() + .unwrap() + .push((check.action, label.clone())); + match self.mode { + 1 => Err(RuntimeGraphqlError::denied()), + 2 => Err(RuntimeGraphqlError::unavailable()), + 3 => std::future::pending().await, + _ => Ok(()), + } + }) + } +} +struct GroupWrite { + probe: Arc, + inner: Authority, +} +impl RuntimeWriteAuthority for GroupWrite { + fn authorize_intent<'a>( + &'a self, + check: RuntimeWriteIntent<'a>, + tx: &'a mut MutationContext<'_, Backend>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.probe.intents.fetch_add(1, Ordering::SeqCst); + self.inner.authorize_intent(check, tx).await + }) + } + fn authorize_preimage<'a>( + &'a self, + check: RuntimePreimageCheck<'a>, + tx: &'a mut MutationContext<'_, Backend>, + ) -> BoxFuture<'a, Result<(), RuntimeMutationError>> { + self.inner.authorize_preimage(check, tx) + } + fn authorize_result<'a>( + &'a self, + check: RuntimeResultCheck<'a>, + tx: &'a mut MutationContext<'_, Backend>, + ) -> BoxFuture<'a, Result> { + self.inner.authorize_result(check, tx) + } +} +struct GroupJournal { + probe: Arc, + // 0 commit, 1 reject, 2 await cancellation. + mode: u8, +} +impl RuntimeMutationHook for GroupJournal { + fn before_commit<'a>( + &'a self, + tx: &'a mut MutationContext<'_, Backend>, + pending: &'a RuntimeMutationEffect, + ) -> BoxFuture<'a, Result<(), RuntimeMutationError>> { + Box::pin(async move { + let id = { + let mut hooks = self.probe.hooks.lock().unwrap(); + hooks.push(pending.action()); + hooks.len().to_string() + }; + tx.insert::(CreateJournalInput { + id, + message: "group".into(), + }) + .await + .map_err(|e| { + RuntimeMutationError::new(RuntimeMutationErrorCode::HookFailed).with_source(e) + })?; + match self.mode { + 1 => Err(RuntimeMutationError::new( + RuntimeMutationErrorCode::HookFailed, + )), + 2 => std::future::pending().await, + _ => Ok(()), + } + }) + } +} +fn group_context( + f: &Fixture, + probe: &Arc, + output: u8, + hook: u8, + budget: RuntimeGraphqlLimits, +) -> RuntimeGraphqlRequest { + RuntimeGraphqlRequest::new( + f.schema().fingerprint(), + Arc::new(GroupRead { + probe: probe.clone(), + mode: output, + }), + None, + budget, + ) + .with_mutation_environment(f.environment.clone()) + .with_write_authority(Arc::new(GroupWrite { + probe: probe.clone(), + inner: f.authority(), + })) + .with_mutation_hook(Arc::new(GroupJournal { + probe: probe.clone(), + mode: hook, + })) +} +fn repeated(field: &str) -> String { + format!( + r#"mutation($schema:String!){{ + same:{field}{{record{{label}}}} + same:{field}{{record{{score}}}} + ...Again + }} fragment Again on Mutation{{same:{field}{{record{{renamed:label score}}}}}}"# + ) +} +const GROUP_CREATE: &str = + r#"createRecord(input:{id:"1",tenant:"north",label:"initial"},expectedSchema:$schema)"#; +const GROUP_UPDATE: &str = r#"updateRecord(key:{id:"1"},input:{label:"updated"},expected:{label:{eq:"initial"}},expectedSchema:$schema)"#; +const GROUP_DELETE: &str = + r#"deleteRecord(key:{id:"1"},expected:{label:{eq:"initial"}},expectedSchema:$schema)"#; + +#[tokio::test] +async fn repeated_mutation_groups_merge_projections_and_run_once_with_tight_budgets() { + for field in [GROUP_CREATE, GROUP_UPDATE, GROUP_DELETE] { + let f = Fixture::new().await; + if field != GROUP_CREATE { + f.run(f.create(1), f.authority()).await.unwrap(); + } + for budget in [ + RuntimeGraphqlLimits { + max_statements: 3, + ..Default::default() + }, + RuntimeGraphqlLimits { + max_materialized_nodes: 0, + ..Default::default() + }, + ] { + let probe = Arc::new(GroupProbe::default()); + let denied = api(&f) + .execute( + request(&f, &repeated(field)).data(group_context(&f, &probe, 0, 0, budget)), + ) + .await; + assert_eq!(denied.errors[0].message, "cost_exceeded"); + assert_eq!(probe.intents.load(Ordering::SeqCst), 0); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 0); + } + let probe = Arc::new(GroupProbe::default()); + // One merged payload fits; separately charging its repeated occurrences does not. + let budget = RuntimeGraphqlLimits { + max_statements: 4, + max_materialized_nodes: 1, + max_response_bytes: 400, + ..Default::default() + }; + let response = api(&f) + .execute(request(&f, &repeated(field)).data(group_context(&f, &probe, 0, 0, budget))) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().unwrap(); + let label = if field == GROUP_UPDATE { + "updated" + } else { + "initial" + }; + assert_eq!( + data["same"]["record"], + serde_json::json!({"label":label,"score":"7","renamed":label}) + ); + assert_eq!(probe.intents.load(Ordering::SeqCst), 1); + assert_eq!(probe.hooks.lock().unwrap().len(), 1); + assert_eq!(probe.released.lock().unwrap().len(), 1); + assert_eq!( + *probe.projections.lock().unwrap(), + vec![vec!["label".to_owned(), "score".to_owned()]] + ); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 1); + assert_eq!(f.count().await, if field == GROUP_DELETE { 0 } else { 1 }); + f.finish().await; + } +} + +#[tokio::test] +async fn distinct_aliases_keep_document_order_and_reserve_separate_transactions() { + let f = Fixture::new().await; + f.run(f.create(1), f.authority()).await.unwrap(); + let query = r#"mutation($schema:String!){ + z:updateRecord(key:{id:"1"},input:{label:"first"},expected:{label:{eq:"initial"}},expectedSchema:$schema){record{label}} + a:updateRecord(key:{id:"1"},input:{label:"second"},expected:{label:{eq:"first"}},expectedSchema:$schema){record{label}} + ...Again + } fragment Again on Mutation { + z:updateRecord(key:{id:"1"},input:{label:"first"},expected:{label:{eq:"initial"}},expectedSchema:$schema){record{score}} + }"#; + for statements in [4, 7] { + let probe = Arc::new(GroupProbe::default()); + let budget = RuntimeGraphqlLimits { + max_statements: statements, + ..Default::default() + }; + let denied = api(&f) + .execute(request(&f, query).data(group_context(&f, &probe, 0, 0, budget))) + .await; + assert_eq!(denied.errors[0].message, "cost_exceeded"); + assert_eq!(probe.intents.load(Ordering::SeqCst), 0); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 0); + } + let probe = Arc::new(GroupProbe::default()); + let budget = RuntimeGraphqlLimits { + max_statements: 8, + max_materialized_nodes: 2, + ..Default::default() + }; + let response = api(&f) + .execute(request(&f, query).data(group_context(&f, &probe, 0, 0, budget))) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().unwrap(); + assert_eq!( + data["z"]["record"], + serde_json::json!({"label":"first","score":"7"}) + ); + assert_eq!(data["a"]["record"]["label"], "second"); + assert_eq!( + *probe.released.lock().unwrap(), + vec![ + (RuntimeMutationAction::Update, "first".into()), + (RuntimeMutationAction::Update, "second".into()) + ] + ); + assert_eq!(probe.intents.load(Ordering::SeqCst), 2); + assert_eq!(probe.hooks.lock().unwrap().len(), 2); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 2); + f.finish().await; +} + +struct CancelFirstRoot; +struct CancelRootExtension(AtomicBool); +impl graphql_orm::async_graphql::extensions::ExtensionFactory for CancelFirstRoot { + fn create(&self) -> Arc { + Arc::new(CancelRootExtension(AtomicBool::new(false))) + } +} +#[async_trait::async_trait] +impl graphql_orm::async_graphql::extensions::Extension for CancelRootExtension { + async fn resolve( + &self, + ctx: &graphql_orm::async_graphql::extensions::ExtensionContext<'_>, + info: graphql_orm::async_graphql::extensions::ResolveInfo<'_>, + next: graphql_orm::async_graphql::extensions::NextResolve<'_>, + ) -> graphql_orm::async_graphql::ServerResult> { + if info.parent_type == "Mutation" && !self.0.swap(true, Ordering::SeqCst) { + tokio::time::timeout(Duration::from_millis(500), next.run(ctx, info)) + .await + .unwrap_or_else(|_| { + Err(graphql_orm::async_graphql::ServerError::new( + "host_timeout", + None, + )) + }) + } else { + next.run(ctx, info).await + } + } +} +fn group_api(f: &Fixture, cancel: bool, timeout: Duration) -> Schema { + let mut options = RuntimeGraphqlMutationOptions::default(); + options.output_authorization_timeout = timeout; + let module = RuntimeGraphqlModule::compile( + Arc::new(f.schema().clone()), + RuntimeGraphqlOptions::default() + .with_cursor_profile(RuntimeCursorProfile::Unprotected) + .with_mutations(options), + ) + .unwrap(); + let composer = RuntimeGraphqlComposer::new(f.db.clone(), "Query", Default::default()) + .unwrap() + .mutation_root("Mutation") + .unwrap() + .install(module) + .unwrap(); + if cancel { + composer.extension(CancelFirstRoot).finish().unwrap() + } else { + composer.finish().unwrap() + } +} +#[tokio::test] +async fn repeated_groups_cache_rejections_and_post_commit_denial_error_timeout() { + for field in [GROUP_CREATE, GROUP_UPDATE, GROUP_DELETE] { + for (output, hook, code, committed) in [ + (0, 1, "hook_failed", false), + (1, 0, "mutation_payload_denied", true), + (2, 0, "mutation_payload_failed", true), + (3, 0, "mutation_payload_timeout", true), + ] { + let f = Fixture::new().await; + if field != GROUP_CREATE { + f.run(f.create(1), f.authority()).await.unwrap(); + } + let probe = Arc::new(GroupProbe::default()); + let response = group_api(&f, false, Duration::from_millis(25)) + .execute(request(&f, &repeated(field)).data(group_context( + &f, + &probe, + output, + hook, + Default::default(), + ))) + .await; + assert_eq!(response.errors.len(), 3, "{:?}", response.errors); + for error in &response.errors { + assert_eq!(error.message, code); + if committed { + assert_eq!( + error.extensions.as_ref().unwrap().get("outcome").unwrap(), + &graphql_orm::async_graphql::Value::String("committed".into()) + ); + assert_eq!( + error.extensions.as_ref().unwrap().get("retryable").unwrap(), + &graphql_orm::async_graphql::Value::Boolean(false) + ); + } + } + let wire = serde_json::to_string(&response).unwrap(); + assert!(!wire.contains("updated") && !wire.contains("initial")); + assert_eq!(probe.intents.load(Ordering::SeqCst), 1); + assert_eq!(probe.hooks.lock().unwrap().len(), 1); + assert_eq!(probe.released.lock().unwrap().len(), usize::from(committed)); + assert_eq!( + Journal::count_all(&f.db).await.unwrap(), + i64::from(committed) + ); + // A fresh request is a fresh group, and sees the actual committed state. + let next = api(&f) + .execute(request( + &f, + &format!("mutation($schema:String!){{{field}{{__typename}}}}"), + )) + .await; + assert_eq!(next.errors.is_empty(), !committed); + f.finish().await; + } + } + for denial in [false, true] { + let f = Fixture::new().await; + f.run(f.create(1), f.authority()).await.unwrap(); + let probe = Arc::new(GroupProbe::default()); + let mut authority = f.authority(); + authority.deny_preimage = denial; + let preimages = authority.preimage_calls.clone(); + let field = if denial { + GROUP_UPDATE.to_owned() + } else { + GROUP_UPDATE.replace("eq:\"initial\"", "eq:\"stale\"") + }; + let context = group_context(&f, &probe, 0, 0, Default::default()).with_write_authority( + Arc::new(GroupWrite { + probe: probe.clone(), + inner: authority, + }), + ); + let response = api(&f) + .execute(request(&f, &repeated(&field)).data(context)) + .await; + assert_eq!(response.errors.len(), 3); + assert!( + response + .errors + .iter() + .all(|error| error.message == response.errors[0].message) + ); + assert_eq!(probe.intents.load(Ordering::SeqCst), 1); + assert_eq!(preimages.load(Ordering::SeqCst), 1); + assert!(probe.hooks.lock().unwrap().is_empty()); + assert!(probe.released.lock().unwrap().is_empty()); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 0); + f.finish().await; + } +} +#[tokio::test] +async fn canceled_occurrence_is_terminal_while_the_executor_continues_repeats() { + for field in [GROUP_CREATE, GROUP_UPDATE, GROUP_DELETE] { + for committed in [false, true] { + let f = Fixture::new().await; + if field != GROUP_CREATE { + f.run(f.create(1), f.authority()).await.unwrap(); + } + let probe = Arc::new(GroupProbe::default()); + let response = group_api(&f, true, Duration::from_secs(10)) + .execute(request(&f, &repeated(field)).data(group_context( + &f, + &probe, + if committed { 3 } else { 0 }, + if committed { 0 } else { 2 }, + Default::default(), + ))) + .await; + assert_eq!(response.errors.len(), 3, "{:?}", response.errors); + assert_eq!(response.errors[0].message, "host_timeout"); + for error in &response.errors[1..] { + assert_eq!( + error.message, + if committed { + "mutation_payload_canceled" + } else { + "mutation_outcome_unknown" + } + ); + let extensions = error.extensions.as_ref().unwrap(); + assert_eq!( + extensions.get("outcome").unwrap(), + &graphql_orm::async_graphql::Value::String( + if committed { "committed" } else { "unknown" }.into() + ) + ); + assert_eq!( + extensions.get("retryable").unwrap(), + &graphql_orm::async_graphql::Value::Boolean(false) + ); + } + assert_eq!(probe.intents.load(Ordering::SeqCst), 1); + assert_eq!(probe.hooks.lock().unwrap().len(), 1); + assert_eq!(probe.released.lock().unwrap().len(), usize::from(committed)); + assert_eq!( + Journal::count_all(&f.db).await.unwrap(), + i64::from(committed) + ); + let present = if field == GROUP_CREATE { + committed + } else if field == GROUP_DELETE { + !committed + } else { + true + }; + assert_eq!(f.count().await, i64::from(present)); + assert!( + !serde_json::to_string(&response) + .unwrap() + .contains("initial") + ); + f.finish().await; + } + } +} + +#[tokio::test] +async fn repeated_groups_keep_validation_directives_and_projection_denial_before_writes() { + let f = Fixture::new().await; + let schema = api(&f); + let probe = Arc::new(GroupProbe::default()); + let invalid = repeated(GROUP_CREATE).replacen("id:\"1\"", "id:\"2\"", 1); + let response = schema + .execute(request(&f, &invalid).data(group_context(&f, &probe, 0, 0, Default::default()))) + .await; + assert!(!response.errors.is_empty()); + assert_eq!(probe.intents.load(Ordering::SeqCst), 0); + // A later fragment's selected field is authorized before any occurrence writes. + let denied = schema + .execute(request(&f, &repeated(GROUP_CREATE)).data(group_context( + &f, + &probe, + 4, + 0, + Default::default(), + ))) + .await; + assert_eq!(denied.errors[0].message, "authorization_denied"); + assert_eq!(probe.intents.load(Ordering::SeqCst), 0); + assert_eq!(f.count().await, 0); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 0); + let query = format!( + r#"mutation($schema:String!, $skip:Boolean = true){{ + same:{GROUP_CREATE} @skip(if:$skip){{record{{label}}}} + ... on Mutation {{same:{GROUP_CREATE}{{__typename}}}} + ...Again @include(if:true) + }} fragment Again on Mutation {{same:{GROUP_CREATE}{{__typename}}}}"# + ); + let probe = Arc::new(GroupProbe::default()); + let budget = RuntimeGraphqlLimits { + max_statements: 4, + max_materialized_nodes: 1, + ..Default::default() + }; + let response = schema + .execute(request(&f, &query).data(group_context(&f, &probe, 1, 0, budget))) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data.into_json().unwrap()["same"]["__typename"], + "RecordMutationPayload" + ); + assert_eq!(probe.intents.load(Ordering::SeqCst), 1); + assert_eq!(probe.hooks.lock().unwrap().len(), 1); + assert!(probe.projections.lock().unwrap().is_empty()); + assert!(probe.released.lock().unwrap().is_empty()); + assert_eq!(Journal::count_all(&f.db).await.unwrap(), 1); + assert_eq!(f.count().await, 1); + f.finish().await; +} diff --git a/docs/plans/active/runtime-platform-capabilities/README.md b/docs/plans/active/runtime-platform-capabilities/README.md index 0f7d8a85..ced9a684 100644 --- a/docs/plans/active/runtime-platform-capabilities/README.md +++ b/docs/plans/active/runtime-platform-capabilities/README.md @@ -3,7 +3,7 @@ title: Bounded runtime platform capability contract kind: plan status: active owner: graphql-orm-maintainers -last_reviewed: 2026-10-01 +last_reviewed: 2026-10-11 review_by: 2026-12-31 supersedes: [] --- @@ -19,10 +19,11 @@ implementation and merged by PR #86 at `9ed46db5d1c5c6214f83ef9084352030e14905ad PR A implements the owned migration slice; its current public mechanics are in [owned runtime migrations](../../../reference/graphql-orm/runtime-migrations.md). B is accepted at `04a1216aff86141ff6500b1dd0bf9adb4b0c6447` as C's committed -predecessor. C is being implemented separately behind `runtime-graphql`; its +predecessor. C is accepted at `9d4cca34cb04ab0fd27c8313215001591f495c43`; its [current mechanics and approved custom-directive limitation](../../../reference/graphql-orm/runtime-graphql.md) -are documented independently. D remains a proposal gated on reviewed C/B. -Final reconciliation and owner merge/release remain #112 → A → B → C. A proposal +are documented independently. D is implemented for review on that exact C revision, including the approved +additive post-commit output callback. It remains unaccepted and unreleased. +Final reconciliation and owner merge/release remain #112 → A → B → C → D. A proposal or unpublished implementation is not downstream adoption evidence. The initiating [consumer contract](https://github.com/Dastari/digibase/blob/agent/vertical-slice-plan/docs/coordination/prompts/0013-graphql-orm-runnable-product-prerequisites.md) @@ -932,6 +933,66 @@ Context enter framework-neutral policy signatures. ## D: dynamic mutation registration +### Approved additive post-commit record authorization + +The approved D guarantee requires independent authorization after commit, +including delete preimages. C's `RuntimeReadAuthority::authorize` returns an SQL +predicate and projection grant but receives no record. B's `authorize_result` +runs before commit and requires the pinned transaction. A deleted row cannot be +re-read after commit to apply a newly granted SQL predicate. Neither callback +alone establishes the required post-commit release decision. + +Approved additive interface (implemented with a default-deny method): + +```rust,ignore +pub struct RuntimeMutationOutputCheck<'a> { + pub schema: &'a ValidatedRuntimeSchema, + pub collection: &'a RuntimeCollectionHandle, + pub action: RuntimeMutationAction, + pub kind: RuntimeMutationRecordKind, // CommittedResult or DeletedPreimage + pub projection: &'a RuntimeProjection, + pub record: &'a RuntimeRecord, +} +// Additional default-deny method on RuntimeReadAuthority: +fn authorize_mutation_result<'a>( + &'a self, + check: RuntimeMutationOutputCheck<'a>, +) -> BoxFuture<'a, Result<(), RuntimeGraphqlError>>; +``` + +The default implementation denies. C-only host authorities remain source +compatible. Selected fields and CAS capabilities are authorized before writes; +B independently authorizes the authoritative preimage/result and runs the +journal hook. Only after the runner confirms commit may the adapter invoke this +new callback and release the selected record (the deleted preimage for delete). +The host must fail closed when its policy needs information absent from that +projection. The callback is not a journal hook and has no mutation context. +Post-commit denial, timeout or failure cannot imply rollback or trigger retry; +clients must be told the mutation committed but the payload was withheld. No +successful payload or protected record is released on that failure. + +The callback is object-safe and runs only when a record is requested. Its host- +configured timeout returns `mutation_payload_timeout`; denial returns +`mutation_payload_denied`, other failure or response encoding/budget failure +returns `mutation_payload_failed`. Each has `outcome: "committed"` and +`retryable: false` extensions, without record values. Whole-request cancellation +or transport loss after commit can prevent delivery of either success or this +error: clients must reconcile the outcome, never infer rollback or blindly retry. + +Repeated runtime mutation occurrences with a validated common response key merge +their payload projections and execute one transaction, guarded journal and +post-commit authorization. Distinct aliases keep first-occurrence document order +and separate transactions. Statement/node reservations and response measurement +apply once per merged group; expansion limits still count every occurrence. +The request-scoped group outcome is terminal on success, error or cancellation, +so later occurrences cannot restart work. If an extension continues after +resolver cancellation, repetitions receive a sanitized nonretryable committed +payload-canceled error after confirmed commit, or an unknown-outcome error before +commit confirmation. Whole-request cancellation can still suppress the response. +This preserves B's before-commit rollback and commit-ambiguity guarantees. + +### Approved registration contract + Extend `RuntimeGraphqlOptions` through a builder method `with_mutations(RuntimeGraphqlMutationOptions)`; reads remain the default. Reuse C naming/coercion/composition and B input construction/engine. Add @@ -987,7 +1048,7 @@ policy-only revision before DML, without conflating revision with fingerprint. Resolve selected payload record fields into an independently authorized RuntimeProjection before mutation. If only `__typename` is selected, request no return record; never expose the internal key as a fallback. Execute one -state-machine transaction per mutation root, call the host before-commit hook +state-machine transaction per validated response-key group, call the host before-commit hook with the pending effect, then commit before resolving a successful payload. Denials, CAS conflicts, and commit failures are safe GraphQL errors, with no protected values in conflict payloads. No relation/nested mutation payload @@ -1139,11 +1200,12 @@ B's independent implementation is based on that exact committed A revision, with aligned unpublished ORM/macros 0.41.0. It adds schema-bound requests, explicit intent/preimage/result authority, pinned structural CAS, exact return projections, atomic repository journal writes and guarded operation/hook -cancellation. It remains subject to exact-head review. Its reference and compiled +cancellation. B is accepted at `04a1216aff86141ff6500b1dd0bf9adb4b0c6447`. Its reference and compiled host example describe environment/policy revision pinning, backend gates and ambiguous commit limitations. The static-only inner-timeout limitation remains. -C/D remain approved proposals with unimplemented interfaces, dependent on their +C is accepted at `9d4cca34cb04ab0fd27c8313215001591f495c43`. D is implemented with response-key grouping corrections +for review, but remains unaccepted/unreleased and dependent on its respective reviewed predecessors. Preserve host-managed AEAD cursor protection, lossless JSON text, required create keys and independent post-commit payload read authorization. Reconcile B onto the final reviewed A after #112 integration diff --git a/docs/reference/graphql-orm/runtime-graphql.md b/docs/reference/graphql-orm/runtime-graphql.md index 3d3edf12..48708525 100644 --- a/docs/reference/graphql-orm/runtime-graphql.md +++ b/docs/reference/graphql-orm/runtime-graphql.md @@ -3,7 +3,7 @@ title: Runtime GraphQL reads and checked composition kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-10-09 +last_reviewed: 2026-10-11 review_by: 2027-02-01 supersedes: [] --- @@ -13,8 +13,7 @@ supersedes: [] The additive `runtime-graphql` feature registers read-only dynamic GraphQL over an `Arc`. It is off by default. Static derives, SDL, framework-neutral cursors and repository consumers are unchanged. No data -migration is required. This is C, based on the reviewed B revision; mutation -registration D is not implemented here. +migration is required. This is C, based on the reviewed B revision; typed mutation registration is explicitly opt-in. `RuntimeGraphqlModule::compile` checks public names and deterministic ordering. `RuntimeGraphqlComposer` checks every root and type name before publication. @@ -214,3 +213,73 @@ StartsWith/EndsWith execution and the SQLite empty-suffix correction remain gate on reviewed #112 → A → B → C reconciliation. This does not weaken those release acceptance requirements. No merge, publication or downstream pin is implied by an unpublished C branch. D requires separate reviewed C/B predecessors. + + +## Typed mutations and committed output authorization + +Enable `RuntimeGraphqlOptions::with_mutations(RuntimeGraphqlMutationOptions::default())` +and compose `mutation_root("Mutation")`. The existing executable host example now +runs typed create, CAS update and exact-key delete with atomic typed journal +inserts, and explicit post-commit public-record release. Enable only the required +actions through mutation options. GraphQL cannot represent empty input objects; +an enabled create/update action with no writable fields is rejected during +compilation with `unsupported_capability`. Generated fields are excluded, keys +are required on create and absent from update. Defaults stay omitted in GraphQL +SDL so omission and explicit null remain distinct. Nested output relations and +nested/bulk writes are unsupported. + +Attach a certified environment, pinned write authority and optional journal hook +using `with_mutation_environment`, `with_write_authority` and +`with_mutation_hook`. Each validated response-key group uses B's guarded +state-machine transaction once. Repeated inline fields and fragment selections +with the same response key merge their requested payload projections. They share +one transaction, journal hook, post-commit authority decision and immutable +payload/error, even when another alias occurs between the repeats. Distinct +aliases execute serially in first-occurrence document order in separate +transactions. They do not share outcomes, grants or caches across requests. + +Preflight conservatively reserves four ORM statements and one materialized node +per distinct runtime mutation group; it measures the merged response shape once. +Host journal/policy I/O needs separate host bounds. Every selection occurrence +still contributes to document/fragment limits. The locked executor may invoke +repeated resolver occurrences, but the request-scoped terminal outcome prevents +another mutation attempt. Its response-object merger combines their selected +fields; cached errors may be reported at each occurrence's location. + +Hook failures and cancellation before commit roll back. No automatic retries occur. The host +retains complete policy-revision validation and schema/DDL fencing obligations. + +Selected projection and CAS field/operator capabilities are independently checked +before mutation; B's authoritative result check still runs in the transaction. +After confirmed commit, `RuntimeReadAuthority::authorize_mutation_result` receives +schema/collection identity, action, selected projection and the immutable captured +record. `RuntimeMutationRecordKind::DeletedPreimage` explicitly identifies delete +results; no post-commit reread is attempted. Internal keys and policy fields stay +unloaded and are never serialized unless independently selected and authorized. +The object-safe callback defaults to denial, preserving existing read-only host +implementations. Hosts must deny when required policy information is absent. +Only selecting `__typename` requests no record and does not call this callback. + +The callback timeout defaults to five seconds and is host configurable through +`output_authorization_timeout`. Denial yields `mutation_payload_denied`, other +errors yield `mutation_payload_failed`, and timeout yields +`mutation_payload_timeout`. All have `extensions.outcome = "committed"` and +`extensions.retryable = false`, without record values or host error details. +These errors cannot undo the mutation or journal. Encoding/response-budget failures +after commit use the same committed failure classification. Whole-request +cancellation or transport loss after commit may deliver no response at all; +clients must reconcile state instead of assuming rollback or retrying blindly. +An uncertain transaction commit remains distinct from a confirmed committed +payload error. No durable replay or idempotency service is provided. + +If a host extension cancels an individual mutation resolver but continues the +same execution, that response-key group remains terminal. Repeats do not restart +its transaction, journal hook or output authorization. Cancellation after +confirmed commit produces `mutation_payload_canceled` with `outcome: "committed"` +and `retryable: false` on a subsequent occurrence. Cancellation before commit +confirmation (including while COMMIT may be in flight) produces +`mutation_outcome_unknown` with `outcome: "unknown"` and `retryable: false`. +This conservative classification does not imply rollback; reconcile state. +Whole-request cancellation may prevent any response. Before-commit guarded hook +cancellation still rolls back under B's guarantees. Static transaction +inner-timeout semantics are unchanged. diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 8a047211..93654aee 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,11 +18,11 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.42.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.43.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.106.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.15.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.42.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.43.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.5.4` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |