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