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;