Skip to main content

fraiseql_core/runtime/
mod.rs

1//! Runtime query executor - executes compiled queries.
2//!
3//! # Architecture
4//!
5//! The runtime loads a `CompiledSchema` and executes incoming GraphQL queries by:
6//! 1. Parsing the GraphQL query
7//! 2. Matching it to a compiled query template
8//! 3. Binding variables
9//! 4. Executing the pre-compiled SQL
10//! 5. Projecting JSONB results to GraphQL response
11//!
12//! # Key Concepts
13//!
14//! - **Zero runtime compilation**: All SQL is pre-compiled
15//! - **Pattern matching**: Match incoming query structure to templates
16//! - **Variable binding**: Safe parameter substitution
17//! - **Result projection**: JSONB → GraphQL JSON transformation
18//!
19//! # Example
20//!
21//! ```no_run
22//! // Requires: a compiled schema file and a live PostgreSQL database.
23//! // See: tests/integration/ for runnable examples.
24//! use fraiseql_core::runtime::Executor;
25//! use fraiseql_core::schema::CompiledSchema;
26//! use fraiseql_core::db::postgres::PostgresAdapter;
27//! use std::sync::Arc;
28//!
29//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
30//! # let schema_json = r#"{"types":[],"queries":[]}"#;
31//! // Load compiled schema
32//! let schema = CompiledSchema::from_json(schema_json, false)?;
33//!
34//! // Create executor with a concrete adapter implementation
35//! let adapter = Arc::new(PostgresAdapter::new("postgresql://localhost/mydb").await?);
36//! let executor = Executor::new(schema, adapter);
37//!
38//! // Execute GraphQL query
39//! let query = r#"query { users { id name } }"#;
40//! let result = executor.execute(query, None).await?;
41//!
42//! println!("{}", result);
43//! # Ok(())
44//! # }
45//! ```
46
47mod aggregate_parser;
48mod aggregate_projector;
49pub mod aggregation;
50/// Argument-name validation for a root field (GraphQL § 5.4.1).
51mod argument_validation;
52/// Argument-value validation for a root field (GraphQL § 5.6.1, § 5.8.5, § 6.1.2).
53mod argument_value_validation;
54pub mod cascade;
55mod embed_selection;
56mod executor;
57mod explain;
58pub mod field_filter;
59pub mod input_validator;
60pub mod jsonb_strategy;
61mod matcher;
62pub mod mutation_result;
63pub(crate) mod native_columns;
64pub mod partial_period;
65mod planner;
66pub(crate) mod projection;
67pub mod query_function;
68pub mod query_tracing;
69pub mod relay;
70pub mod sql_logger;
71pub mod subscription;
72pub mod tenant_enforcer;
73/// Variable-definition validation for the executed operation (GraphQL § 5.8.3).
74mod variable_validation;
75pub mod window;
76mod window_parser;
77mod window_projector;
78
79use std::sync::Arc;
80
81pub use aggregate_parser::AggregateQueryParser;
82pub use aggregate_projector::AggregationProjector;
83pub use aggregation::{AggregationSqlGenerator, ParameterizedAggregationSql};
84pub use argument_validation::validate_argument_names;
85pub use argument_value_validation::{
86    validate_argument_values, validate_enum_argument_literals, validate_enum_argument_values,
87    validate_enum_variable_values, validate_variable_values,
88};
89pub use embed_selection::{CountSelection, EmbedSelection};
90pub(crate) use executor::field_type_to_where_type;
91pub use executor::{
92    Executor,
93    JsonRowStream,
94    MutationExecution,
95    // The row-shaped read's results (#1351). This list is enumerated, so a new
96    // public item is invisible outside the crate until it is named here.
97    RowRead,
98    // What a mutation's function may stamp (rulings AA 1, AG 2, AJ 1): one derivation for the
99    // runner and the CLI's stamp lint.
100    StampContract,
101    StreamedRowRead,
102    // A subscription's read plan (ruling AA 4).
103    SubscriptionPlan,
104    coerce_pagination_arg,
105    mutation::{WriteSelections, mutation_return_selections},
106    // Contract errors refused (ruling AJ 3), exported as a metric.
107    mutation_contract_errors,
108    pipeline::{extract_root_field_names, is_multi_root, multi_root_queries_total},
109    // Exported so the admin SQL console's RLS preview (#962) resolves an
110    // identity's session variables with the *same* function that sets them on a
111    // real query. A preview computed by a second implementation is a preview of
112    // that implementation.
113    security::resolve_session_variables,
114    suppressed_subscription_events,
115};
116pub use explain::{ExplainPlan, ExplainResult};
117pub use field_filter::{
118    FieldAccessResult, can_access_field, can_reference_field, classify_field_access, filter_fields,
119};
120pub use jsonb_strategy::{JsonbOptimizationOptions, JsonbStrategy};
121pub use matcher::{QueryMatch, QueryMatcher, suggest_similar};
122pub use planner::{ExecutionPlan, QueryPlanner};
123pub(crate) use projection::stored_key_candidates;
124pub use projection::{
125    FieldMapping, ProjectionMapper, ResultProjector, project_entity, project_nested_lists,
126    stamp_nested_typenames,
127};
128pub use query_function::{QueryFunctionRequest, QueryFunctionResolver};
129pub use query_tracing::{
130    QueryExecutionTrace, QueryPhaseSpan, QueryTraceBuilder, create_phase_span, create_query_span,
131};
132pub use sql_logger::{SqlOperation, SqlQueryLog, SqlQueryLogBuilder, create_sql_span};
133pub use subscription::{
134    ActiveSubscription, DeliveryResult, KafkaAdapter, KafkaConfig, KafkaMessage, SubscriptionError,
135    SubscriptionEvent, SubscriptionId, SubscriptionManager, SubscriptionOperation,
136    SubscriptionPayload, TransportAdapter, TransportManager, WebhookAdapter, WebhookConfig,
137    WebhookPayload, extract_rls_conditions, protocol,
138};
139pub use tenant_enforcer::TenantEnforcer;
140pub use variable_validation::{
141    collect_variable_references, validate_variable_types, validate_variable_uses,
142    validate_variables_used,
143};
144
145/// Result of a bulk REST operation (collection-level PATCH/DELETE).
146#[derive(Debug, Clone)]
147pub struct BulkResult {
148    /// Number of rows affected.
149    pub affected_rows: u64,
150    /// Entities returned when `Prefer: return=representation` is set.
151    pub entities:      Option<Vec<serde_json::Value>>,
152}
153pub use window::{WindowSql, WindowSqlGenerator};
154pub use window_parser::WindowQueryParser;
155pub use window_projector::WindowProjector;
156
157use crate::security::{
158    Authorizer, BeforeMutationGate, FieldAuthorizer, FieldFilter, FieldFilterConfig,
159    QueryValidatorConfig, RLSPolicy,
160};
161
162/// Runtime configuration for the FraiseQL query executor.
163///
164/// Controls safety limits, security policies, and performance tuning. All settings
165/// have production-safe defaults and can be overridden via the builder-style methods.
166///
167/// # Defaults
168///
169/// | Field | Default | Notes |
170/// |-------|---------|-------|
171/// | `cache_query_plans` | `true` | Caches parsed query plans for repeated queries |
172/// | `query_validation` | `None` | Derived from compiled `[validation]` limits (#379) |
173/// | `enable_tracing` | `false` | Emit `OpenTelemetry` spans for each query |
174/// | `query_timeout_ms` | `30 000` | Hard limit; 0 disables the timeout |
175/// | `field_filter` | `None` | No field-level access control |
176/// | `rls_policy` | `None` | No row-level security |
177/// | `authorizer` | `None` | No operation-level authorization |
178/// | `before_mutation_gate` | `None` | No `before:mutation` enforcement |
179/// | `query_function_resolver` | `None` | A function-backed root field refuses, by name |
180///
181/// # Example
182///
183/// ```
184/// use fraiseql_core::runtime::RuntimeConfig;
185/// use fraiseql_core::security::FieldFilterConfig;
186///
187/// let config = RuntimeConfig {
188///     enable_tracing: true,
189///     query_timeout_ms: 5_000,
190///     ..RuntimeConfig::default()
191/// }
192/// .with_field_filter(
193///     FieldFilterConfig::new()
194///         .protect_field("User", "salary")
195///         .protect_field("User", "ssn"),
196/// );
197/// ```
198///
199/// `Clone` is derived (the trait-object fields are behind `Arc`) so a rebuild —
200/// a hot-reload, a per-tenant executor — can start from the live config and
201/// re-apply only what the new compiled schema owns, via
202/// [`with_compiled_schema`](Self::with_compiled_schema).
203#[derive(Clone)]
204pub struct RuntimeConfig {
205    /// Enable query plan caching.
206    pub cache_query_plans: bool,
207
208    /// Maximum number of rows a top-level `first`/`last`/`limit` argument may
209    /// request, guarding against unbounded-pagination denial of service (#421):
210    /// the top-level row count is the one knob that sizes the database result set
211    /// and the serialized response. A request exceeding this is rejected with a
212    /// [`crate::FraiseQLError::Validation`]. `None` disables the ceiling. Default
213    /// `Some(1000)`.
214    pub max_page_size: Option<u32>,
215
216    /// Enable performance tracing.
217    pub enable_tracing: bool,
218
219    /// Optional field filter for access control.
220    /// When set, validates that users have required scopes to access fields.
221    pub field_filter: Option<FieldFilter>,
222
223    /// Optional row-level security (RLS) policy.
224    /// When set, evaluates access rules based on `SecurityContext` to determine
225    /// what rows a user can access (e.g., tenant isolation, owner-based access).
226    pub rls_policy: Option<Arc<dyn RLSPolicy>>,
227
228    /// Optional dynamic field-level authorizer.
229    ///
230    /// When set, fields marked policy-gated in the compiled schema
231    /// ([`FieldDefinition::authorize`](crate::schema::FieldDefinition)) are passed to
232    /// this authorizer per row, which returns an allow/deny decision based on the
233    /// principal, the parent row, and the field arguments. Composes as a logical AND
234    /// with the static `requires_scope` gate and is fail-closed (any error denies).
235    /// See [`FieldAuthorizer`].
236    pub field_authorizer: Option<Arc<dyn FieldAuthorizer>>,
237
238    /// Optional dynamic operation-level authorizer.
239    ///
240    /// When set, every operation (query, mutation, subscription) is passed to this
241    /// authorizer before dispatch, which returns an allow/deny decision based on the
242    /// principal (or `None` when anonymous), the operation kind and name, and the
243    /// request input. Composes as a logical AND with the static `requires_role` gate
244    /// and is fail-closed (any error or raise denies with HTTP 403 / `FORBIDDEN`).
245    /// See [`Authorizer`].
246    pub authorizer: Option<Arc<dyn Authorizer>>,
247
248    /// Query timeout in milliseconds (0 = no timeout).
249    pub query_timeout_ms: u64,
250
251    /// JSONB field optimization strategy options
252    pub jsonb_optimization: JsonbOptimizationOptions,
253
254    /// Optional query validation config.
255    ///
256    /// When `Some`, `QueryValidator` runs at the start of every
257    /// `Executor::execute()` call, before any parsing or SQL dispatch.
258    /// This provides `DoS` protection for direct `fraiseql-core` embedders that
259    /// do not route through `fraiseql-server` (which already runs `RequestValidator`
260    /// at the HTTP layer). Enforces: query size, depth, complexity, and alias count
261    /// (alias amplification protection).
262    ///
263    /// When `None` (the default), the executor derives the gate from the
264    /// compiled schema's declared `[validation]` limits at construction (#379),
265    /// so an operator's declared bound binds on every transport that reaches
266    /// the executor. An embedder that must disable validation despite declared
267    /// schema limits can install an explicit all-`usize::MAX` config.
268    pub query_validation: Option<QueryValidatorConfig>,
269
270    /// Hard ceiling on a single operation's estimated cost (#379).
271    ///
272    /// **Schema-derived** — recomputed from the compiled
273    /// `[security.cost_budget] per_request_max` on every
274    /// [`with_compiled_schema`](Self::with_compiled_schema), never set by the
275    /// caller. Enforced in the executor's dispatch alongside GATE-1, so the
276    /// operator's declared ceiling binds on every transport that executes a
277    /// GraphQL document — not only on `/graphql`.
278    pub max_operation_cost: Option<u64>,
279
280    /// Hard ceiling on the bytes a single read may deliver.
281    ///
282    /// **Schema-derived** — recomputed from the compiled `[validation]
283    /// max_response_bytes` on every
284    /// [`with_compiled_schema`](Self::with_compiled_schema), never set by the
285    /// caller.
286    ///
287    /// Unlike [`max_operation_cost`](Self::max_operation_cost) this is not scored
288    /// before the database runs: what a read weighs is not recoverable from the
289    /// request. It is resolved at the read chokepoint and charged on the rows that
290    /// come back, so it binds on every transport that returns rows — documents,
291    /// REST reads and exports, and both gRPC arms — and not only on the ones that
292    /// carry a document to score. See
293    /// [`ResponseBudget`](crate::security::ResponseBudget).
294    pub max_response_bytes: Option<u64>,
295
296    /// Emit structured `tracing` events for every successfully-executed mutation.
297    ///
298    /// When `true`, a `tracing::info!` event with target `"fraiseql::mutation_audit"` is
299    /// emitted at the end of every successful `execute_mutation_query_with_security()` call.
300    /// The event carries fields: `mutation_name`, `entity_type`, `operation`, `tenant_id`.
301    ///
302    /// **Zero-cost when disabled**: the guard `if !self.config.audit_mutations { return }`
303    /// short-circuits before any string formatting or allocation occurs.
304    ///
305    /// Set to `true` when `audit_logging_enabled = true` in the compiled schema's
306    /// `[security.enterprise]` section (threaded through `Server::new()` at startup).
307    pub audit_mutations: bool,
308
309    /// Global switch for the Change-Spine change-log outbox write (default `true`).
310    ///
311    /// When `true`, every successful state-changing mutation writes one
312    /// `core.tb_entity_change_log` row in-transaction (the framework owns the
313    /// write). Set `false` to disable the outbox **globally** — e.g. for an
314    /// application that does not consume the Change Spine — so no mutation pays
315    /// the write. The per-mutation
316    /// [`MutationDefinition.changelog`](crate::schema::MutationDefinition) flag
317    /// composes as a logical AND on top of this: a row is written only when the
318    /// global switch is on **and** the mutation is not individually opted out.
319    ///
320    /// Sourced from `[changelog] enabled` in `fraiseql.toml`, overridable at
321    /// runtime by `FRAISEQL_CHANGELOG_ENABLED`.
322    pub changelog_enabled: bool,
323
324    /// Validate-bind-without-commit mode for mutations (default `false`).
325    ///
326    /// When `true`, every state-changing mutation is executed inside a database
327    /// transaction that is **rolled back** instead of committed: the function
328    /// binds and runs (so constraints, triggers, and the `mutation_response`
329    /// shape are all validated) but no writes persist and no change-log row is
330    /// emitted. Powers the `fraiseql query --dry-run` CLI smoke check and the
331    /// `doctor --runtime` mutation probes.
332    ///
333    /// A dry run is the same write in `WriteMode::DryRun` (ruling AA 6): the same
334    /// transaction, session variables, outbox row and gate, ending in a rollback, and
335    /// nothing that follows a commit follows it. Queries are unaffected (they never commit).
336    pub dry_run_mutations: bool,
337
338    /// Response-size guards for the typed cascade surface (graphql-cascade
339    /// `16_security`). A cascade mutation returning more affected entities than
340    /// [`CascadeLimits::max_updated_entities`] is truncated with `truncated`
341    /// metadata; one exceeding [`CascadeLimits::max_response_size_mb`] is
342    /// rejected. Same DoS-guard family as [`max_page_size`](Self::max_page_size).
343    pub cascade_limits: CascadeLimits,
344
345    /// Optional `before:mutation` enforcement point (#1327).
346    ///
347    /// When set, every mutation the engine is about to execute is passed to this
348    /// gate first — per root field, in document order, with the arguments the
349    /// write will bind from (request variables merged with inline literals).
350    /// The gate may let the write proceed, rewrite its arguments, or refuse it.
351    /// Fail-closed: any error refuses the write.
352    ///
353    /// Enforced at `execute_mutation_impl`, the single point every mutation entry
354    /// path converges on, so a transport cannot reach a write without passing it.
355    /// See [`BeforeMutationGate`].
356    pub before_mutation_gate: Option<Arc<dyn BeforeMutationGate>>,
357
358    /// Optional resolver for function-backed root query fields (#1329).
359    ///
360    /// When a compiled query declares `function = "<name>"`, the engine asks this
361    /// resolver for the field's data instead of reading a relation, then projects,
362    /// field-filters and caches the answer exactly as it would a SQL-backed one.
363    ///
364    /// `None` means a function-backed field is **refused by name** rather than
365    /// answered — an embedder who loaded a schema declaring one and wired no
366    /// resolver has a field that cannot work, and the refusal says so. The stock
367    /// binary wires one whenever the compiled schema carries a `functions` section,
368    /// and #1326 already refuses to boot a build that cannot serve one.
369    ///
370    /// See [`QueryFunctionResolver`].
371    pub query_function_resolver: Option<Arc<dyn QueryFunctionResolver>>,
372}
373
374/// Response-size limits for the typed cascade surface, per the graphql-cascade
375/// spec's security requirements (`specification/16_security.md`).
376///
377/// Defaults are the spec's: depth 3, 500 affected entities, 5 `MiB`.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
379pub struct CascadeLimits {
380    /// Maximum relationship depth a cascade may traverse (spec default `3`).
381    pub max_depth:            usize,
382    /// Maximum number of affected entities (`updated` + `deleted`) before the
383    /// cascade is truncated and flagged `truncated` in its metadata (spec
384    /// default `500`).
385    pub max_updated_entities: usize,
386    /// Maximum serialized cascade size in `MiB` before the mutation is rejected
387    /// (spec default `5`). A value of `0` **disables** the size ceiling — set it
388    /// only to intentionally lift the limit, never as an "unbounded" value reached
389    /// by accident.
390    pub max_response_size_mb: usize,
391}
392
393impl Default for CascadeLimits {
394    fn default() -> Self {
395        Self {
396            max_depth:            3,
397            max_updated_entities: 500,
398            max_response_size_mb: 5,
399        }
400    }
401}
402
403impl std::fmt::Debug for RuntimeConfig {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        f.debug_struct("RuntimeConfig")
406            .field("cache_query_plans", &self.cache_query_plans)
407            .field("max_page_size", &self.max_page_size)
408            .field("enable_tracing", &self.enable_tracing)
409            .field("field_filter", &self.field_filter.is_some())
410            .field("rls_policy", &self.rls_policy.is_some())
411            .field("field_authorizer", &self.field_authorizer.is_some())
412            .field("authorizer", &self.authorizer.is_some())
413            .field("query_timeout_ms", &self.query_timeout_ms)
414            .field("jsonb_optimization", &self.jsonb_optimization)
415            .field("query_validation", &self.query_validation)
416            .field("max_operation_cost", &self.max_operation_cost)
417            .field("max_response_bytes", &self.max_response_bytes)
418            .field("audit_mutations", &self.audit_mutations)
419            .field("changelog_enabled", &self.changelog_enabled)
420            .field("dry_run_mutations", &self.dry_run_mutations)
421            .field("cascade_limits", &self.cascade_limits)
422            .field("before_mutation_gate", &self.before_mutation_gate.is_some())
423            .field("query_function_resolver", &self.query_function_resolver.is_some())
424            .finish()
425    }
426}
427
428impl Default for RuntimeConfig {
429    fn default() -> Self {
430        Self {
431            cache_query_plans:       true,
432            max_page_size:           Some(1000),
433            enable_tracing:          false,
434            field_filter:            None,
435            rls_policy:              None,
436            field_authorizer:        None,
437            authorizer:              None,
438            query_timeout_ms:        30_000, // 30 second default timeout
439            jsonb_optimization:      JsonbOptimizationOptions::default(),
440            query_validation:        None,
441            max_operation_cost:      None,
442            max_response_bytes:      None,
443            audit_mutations:         false,
444            changelog_enabled:       true,
445            dry_run_mutations:       false,
446            cascade_limits:          CascadeLimits::default(),
447            before_mutation_gate:    None,
448            query_function_resolver: None,
449        }
450    }
451}
452
453impl RuntimeConfig {
454    /// Create a new runtime config with a field filter.
455    ///
456    /// # Example
457    ///
458    /// ```
459    /// use fraiseql_core::runtime::RuntimeConfig;
460    /// use fraiseql_core::security::FieldFilterConfig;
461    ///
462    /// let config = RuntimeConfig::default()
463    ///     .with_field_filter(
464    ///         FieldFilterConfig::new()
465    ///             .protect_field("User", "salary")
466    ///             .protect_field("User", "ssn")
467    ///     );
468    /// ```
469    #[must_use = "builder method returns modified builder"]
470    pub fn with_field_filter(mut self, config: FieldFilterConfig) -> Self {
471        self.field_filter = Some(FieldFilter::new(config));
472        self
473    }
474
475    /// Configure row-level security (RLS) policy for access control.
476    ///
477    /// When set, the executor will evaluate the RLS policy before executing queries,
478    /// applying WHERE clause filters based on the user's `SecurityContext`.
479    ///
480    /// # Example
481    ///
482    /// ```rust
483    /// use fraiseql_core::runtime::RuntimeConfig;
484    /// use fraiseql_core::security::DefaultRLSPolicy;
485    /// use std::sync::Arc;
486    ///
487    /// let config = RuntimeConfig::default()
488    ///     .with_rls_policy(Arc::new(DefaultRLSPolicy::new()));
489    /// ```
490    #[must_use = "builder method returns modified builder"]
491    pub fn with_rls_policy(mut self, policy: Arc<dyn RLSPolicy>) -> Self {
492        self.rls_policy = Some(policy);
493        self
494    }
495
496    /// Configure a dynamic field-level authorizer.
497    ///
498    /// When set, fields marked policy-gated in the compiled schema
499    /// ([`FieldDefinition::authorize`](crate::schema::FieldDefinition)) are evaluated
500    /// per row by this authorizer. The decision composes as a logical AND with the
501    /// static `requires_scope` gate and is fail-closed (any error or raise denies
502    /// with HTTP 403 / `FORBIDDEN`). Parallel to [`with_rls_policy`](Self::with_rls_policy).
503    ///
504    /// # Example
505    ///
506    /// ```rust
507    /// use fraiseql_core::runtime::RuntimeConfig;
508    /// use fraiseql_core::security::{
509    ///     FieldAuthorizer, FieldAuthzRequest, FieldAuthzDecision,
510    /// };
511    /// use fraiseql_core::error::Result;
512    /// use std::sync::Arc;
513    ///
514    /// struct AllowAll;
515    /// impl FieldAuthorizer for AllowAll {
516    ///     fn authorize_field(&self, _req: &FieldAuthzRequest<'_>) -> Result<FieldAuthzDecision> {
517    ///         Ok(FieldAuthzDecision::Allow)
518    ///     }
519    /// }
520    ///
521    /// let config = RuntimeConfig::default().with_field_authorizer(Arc::new(AllowAll));
522    /// ```
523    #[must_use = "builder method returns modified builder"]
524    pub fn with_field_authorizer(mut self, authorizer: Arc<dyn FieldAuthorizer>) -> Self {
525        self.field_authorizer = Some(authorizer);
526        self
527    }
528
529    /// Override the cascade response-size limits (graphql-cascade `16_security`).
530    #[must_use = "builder method returns modified builder"]
531    pub const fn with_cascade_limits(mut self, limits: CascadeLimits) -> Self {
532        self.cascade_limits = limits;
533        self
534    }
535
536    /// Configure a dynamic operation-level authorizer.
537    ///
538    /// When set, every operation (query, mutation, subscription) is passed to this
539    /// authorizer before dispatch. The decision composes as a logical AND with the
540    /// static `requires_role` gate and is fail-closed (any error or raise denies with
541    /// HTTP 403 / `FORBIDDEN`). Parallel to
542    /// [`with_field_authorizer`](Self::with_field_authorizer) and
543    /// [`with_rls_policy`](Self::with_rls_policy).
544    ///
545    /// # Example
546    ///
547    /// ```rust
548    /// use fraiseql_core::runtime::RuntimeConfig;
549    /// use fraiseql_core::security::{Authorizer, AuthzRequest, AuthzDecision};
550    /// use fraiseql_core::error::Result;
551    /// use std::sync::Arc;
552    ///
553    /// struct AllowAll;
554    /// impl Authorizer for AllowAll {
555    ///     fn authorize(&self, _req: &AuthzRequest<'_>) -> Result<AuthzDecision> {
556    ///         Ok(AuthzDecision::Allow)
557    ///     }
558    /// }
559    ///
560    /// let config = RuntimeConfig::default().with_authorizer(Arc::new(AllowAll));
561    /// ```
562    #[must_use = "builder method returns modified builder"]
563    pub fn with_authorizer(mut self, authorizer: Arc<dyn Authorizer>) -> Self {
564        self.authorizer = Some(authorizer);
565        self
566    }
567
568    /// Configure the `before:mutation` enforcement point (#1327).
569    ///
570    /// When set, every mutation the engine executes is adjudicated by this gate
571    /// first — per root field, in document order, with the arguments the write
572    /// will bind from. It may proceed, rewrite the arguments, or refuse. Installed
573    /// at `execute_mutation_impl`, the chokepoint every transport converges on, so
574    /// no route can execute a write without running it. Fail-closed: any error
575    /// refuses the write.
576    ///
577    /// # Example
578    ///
579    /// ```rust
580    /// use fraiseql_core::runtime::RuntimeConfig;
581    /// use fraiseql_core::security::{
582    ///     BeforeMutationGate, BeforeMutationOutcome, BeforeMutationRequest,
583    /// };
584    /// use fraiseql_core::error::Result;
585    /// use std::sync::Arc;
586    ///
587    /// struct AllowAll;
588    ///
589    /// #[async_trait::async_trait]
590    /// impl BeforeMutationGate for AllowAll {
591    ///     async fn before_mutation(
592    ///         &self,
593    ///         _req: &BeforeMutationRequest<'_>,
594    ///     ) -> Result<BeforeMutationOutcome> {
595    ///         Ok(BeforeMutationOutcome::Proceed)
596    ///     }
597    /// }
598    ///
599    /// let config = RuntimeConfig::default().with_before_mutation_gate(Arc::new(AllowAll));
600    /// ```
601    #[must_use = "builder method returns modified builder"]
602    pub fn with_before_mutation_gate(mut self, gate: Arc<dyn BeforeMutationGate>) -> Self {
603        self.before_mutation_gate = Some(gate);
604        self
605    }
606
607    /// Register the resolver for function-backed root query fields (#1329).
608    ///
609    /// Parallel to [`with_before_mutation_gate`](Self::with_before_mutation_gate).
610    /// Without one, a query declaring `function = "<name>"` is refused by name
611    /// rather than answered.
612    #[must_use]
613    pub fn with_query_function_resolver(
614        mut self,
615        resolver: Arc<dyn QueryFunctionResolver>,
616    ) -> Self {
617        self.query_function_resolver = Some(resolver);
618        self
619    }
620
621    /// Build a [`RuntimeConfig`] from a compiled schema, applying every
622    /// schema-derived runtime setting that an executor must honor.
623    ///
624    /// This is the **single seam** every server entry point routes through so
625    /// the config can never drift by constructor (H16): `Server::new`,
626    /// `with_relay_pagination`, and `with_flight_service` previously built the
627    /// executor with [`RuntimeConfig::default`], silently dropping the
628    /// compiled audit-logging flag, the #421 page-size ceiling, and the
629    /// change-log toggle, and skipping the schema-format-version check.
630    ///
631    /// Applied in order:
632    /// 1. **Schema-format-version validation** — a legacy schema (no version) warns; an
633    ///    incompatible future version is rejected. Coupling the check into the constructor means a
634    ///    caller cannot obtain a config while skipping the validation.
635    /// 2. **Audit logging** — `audit_mutations` from the compiled `[security.enterprise]
636    ///    audit_logging_enabled`.
637    /// 3. **Page-size ceiling (#421)** — `FRAISEQL_MAX_PAGE_SIZE` overrides the compiled
638    ///    `[validation] max_page_size`, which overrides the default.
639    /// 4. **Change-log outbox toggle** — `FRAISEQL_CHANGELOG_ENABLED` overrides the compiled
640    ///    `[changelog] write_enabled` (default `true`).
641    ///
642    /// # Errors
643    ///
644    /// Returns the validation message when the compiled schema was produced by
645    /// a different fraiseql build (#1304).
646    pub fn from_compiled_schema(schema: &crate::schema::CompiledSchema) -> Result<Self, String> {
647        Self::default().with_compiled_schema(schema)
648    }
649
650    /// Re-apply every schema-derived setting on top of an existing config.
651    ///
652    /// [`from_compiled_schema`](Self::from_compiled_schema) is this method over
653    /// [`Self::default`]. The split exists for **rebuilds**: a hot-reload must
654    /// re-derive the settings the new compiled schema owns while preserving the
655    /// ones the embedder installed programmatically (authorizers, RLS policy,
656    /// field filter, validator). Reconstructing from `default()` on reload is
657    /// what silently reverted audit logging, the page-size ceiling and the
658    /// change-log toggle on every `SIGUSR1` (#750).
659    ///
660    /// # Errors
661    ///
662    /// Returns the validation message when the compiled schema was produced by
663    /// a different fraiseql build (#1304).
664    pub fn with_compiled_schema(
665        self,
666        schema: &crate::schema::CompiledSchema,
667    ) -> Result<Self, String> {
668        schema.validate_producer_version()?;
669
670        // Audit logging: security.enterprise.audit_logging_enabled.
671        let audit_mutations = schema
672            .security
673            .as_ref()
674            .and_then(|s| s.enterprise.as_ref())
675            .is_some_and(|e| e.audit_logging_enabled);
676        if audit_mutations {
677            tracing::info!("Mutation audit logging enabled (target: fraiseql::mutation_audit)");
678        }
679
680        // #421: FRAISEQL_MAX_PAGE_SIZE > compiled [validation] max_page_size > default.
681        let max_page_size = page_size_precedence(
682            std::env::var("FRAISEQL_MAX_PAGE_SIZE").ok().as_deref(),
683            schema.validation_config.as_ref().and_then(|v| v.max_page_size),
684        );
685
686        // Change-Spine outbox write toggle (default on): FRAISEQL_CHANGELOG_ENABLED
687        // overrides the compiled [changelog] write_enabled.
688        let changelog_enabled = std::env::var("FRAISEQL_CHANGELOG_ENABLED")
689            .ok()
690            .map(|v| {
691                !matches!(v.trim().to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off")
692            })
693            .or_else(|| schema.changelog.as_ref().map(|c| c.write_enabled))
694            .unwrap_or(true);
695        if !changelog_enabled {
696            tracing::info!(
697                "Change-log outbox write disabled (FRAISEQL_CHANGELOG_ENABLED / [changelog] write_enabled)"
698            );
699        }
700
701        // Exhaustive destructuring, deliberately without `..`: adding a field to
702        // `RuntimeConfig` is a compile error here until it is classified as
703        // schema-derived (recomputed above, so the binding is discarded) or
704        // caller-owned (carried through unchanged). A `..Self::default()` tail
705        // would instead make a new schema-derived field silently revert to its
706        // default on every construction and every reload — the H16 drift this
707        // seam exists to prevent, one level down.
708        // Declaration order throughout (clippy::inconsistent_struct_constructor).
709        // `_`-bound fields are the schema-derived ones, recomputed above; every
710        // other field is caller-owned and carried through unchanged.
711        // #379: the per-request cost ceiling is declared in the compiled
712        // [security.cost_budget] and owned by the schema.
713        let max_operation_cost = schema
714            .security
715            .as_ref()
716            .and_then(|s| s.cost_budget.as_ref())
717            .and_then(|c| c.per_request_max);
718
719        // The response-bytes ceiling is declared in the compiled [validation] and
720        // owned by the schema, exactly as the cost ceiling above is owned by
721        // [security.cost_budget]. Both are recomputed here rather than carried
722        // through, so a hot reload cannot leave a stale ceiling in force.
723        let max_response_bytes =
724            schema.validation_config.as_ref().and_then(|v| v.max_response_bytes);
725
726        let Self {
727            cache_query_plans,
728            max_page_size: _, // schema-derived
729            enable_tracing,
730            field_filter,
731            rls_policy,
732            field_authorizer,
733            authorizer,
734            query_timeout_ms,
735            jsonb_optimization,
736            query_validation,
737            max_operation_cost: _, // schema-derived
738            max_response_bytes: _, // schema-derived
739            audit_mutations: _,    // schema-derived
740            changelog_enabled: _,  // schema-derived
741            dry_run_mutations,
742            cascade_limits,
743            before_mutation_gate,
744            query_function_resolver,
745        } = self;
746
747        Ok(Self {
748            cache_query_plans,
749            max_page_size,
750            enable_tracing,
751            field_filter,
752            rls_policy,
753            field_authorizer,
754            authorizer,
755            query_timeout_ms,
756            jsonb_optimization,
757            query_validation,
758            max_operation_cost,
759            max_response_bytes,
760            audit_mutations,
761            changelog_enabled,
762            dry_run_mutations,
763            cascade_limits,
764            before_mutation_gate,
765            query_function_resolver,
766        })
767    }
768}
769
770/// Resolve the top-level page-size ceiling (#421) by precedence.
771///
772/// `env` is the raw `FRAISEQL_MAX_PAGE_SIZE` value when set (a positive integer,
773/// or `"0"`/`"none"` to disable the ceiling). It overrides `compiled` (the
774/// `[validation] max_page_size` from the compiled schema), which overrides the
775/// runtime default (1000). Returns `None` only when explicitly disabled.
776#[must_use]
777pub fn page_size_precedence(env: Option<&str>, compiled: Option<u32>) -> Option<u32> {
778    if let Some(raw) = env {
779        let trimmed = raw.trim();
780        if trimmed.eq_ignore_ascii_case("none") || trimmed == "0" {
781            return None;
782        }
783        if let Ok(n) = trimmed.parse::<u32>() {
784            return Some(n);
785        }
786        // Unparseable env value: ignore it and fall through to compiled/default.
787    }
788    compiled.or(RuntimeConfig::default().max_page_size)
789}
790
791/// Execution context for query cancellation support.
792///
793/// This struct provides a mechanism for gracefully cancelling long-running queries
794/// via cancellation tokens, enabling proper cleanup and error reporting when:
795/// - A client connection closes
796/// - A user explicitly cancels a query
797/// - A system shutdown is initiated
798///
799/// # Example
800///
801/// ```no_run
802/// // Requires: a running tokio runtime and an Executor with a live database adapter.
803/// // See: tests/integration/ for runnable examples.
804/// use fraiseql_core::runtime::ExecutionContext;
805/// use std::time::Duration;
806///
807/// let ctx = ExecutionContext::new("query-123".to_string());
808///
809/// // Spawn a task that cancels after 5 seconds
810/// let cancel_token = ctx.cancellation_token().clone();
811/// tokio::spawn(async move {
812///     tokio::time::sleep(Duration::from_secs(5)).await;
813///     cancel_token.cancel();
814/// });
815///
816/// // Execute query with cancellation support
817/// // let result = executor.execute_with_context(query, None, &ctx).await;
818/// ```
819#[derive(Debug, Clone)]
820pub struct ExecutionContext {
821    /// Unique identifier for tracking the query execution
822    query_id: String,
823
824    /// Cancellation token for gracefully stopping the query
825    /// When cancelled, ongoing query execution should stop and return a Cancelled error
826    token: tokio_util::sync::CancellationToken,
827}
828
829impl ExecutionContext {
830    /// Create a new execution context with a cancellation token.
831    ///
832    /// # Arguments
833    ///
834    /// * `query_id` - Unique identifier for this query execution
835    ///
836    /// # Example
837    ///
838    /// ```rust
839    /// # use fraiseql_core::runtime::ExecutionContext;
840    /// let ctx = ExecutionContext::new("user-query-001".to_string());
841    /// assert_eq!(ctx.query_id(), "user-query-001");
842    /// ```
843    #[must_use]
844    pub fn new(query_id: String) -> Self {
845        Self {
846            query_id,
847            token: tokio_util::sync::CancellationToken::new(),
848        }
849    }
850
851    /// Get the query ID.
852    #[must_use]
853    pub fn query_id(&self) -> &str {
854        &self.query_id
855    }
856
857    /// Get a reference to the cancellation token.
858    ///
859    /// The returned token can be used to:
860    /// - Clone and pass to background tasks
861    /// - Check if cancellation was requested
862    /// - Propagate cancellation through the call stack
863    #[must_use]
864    pub const fn cancellation_token(&self) -> &tokio_util::sync::CancellationToken {
865        &self.token
866    }
867
868    /// Check if cancellation has been requested.
869    #[must_use]
870    pub fn is_cancelled(&self) -> bool {
871        self.token.is_cancelled()
872    }
873}
874
875#[cfg(test)]
876mod tests;