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;