Skip to main content

fraiseql_cli/schema/
merger.rs

1//! Schema merger - combines language-generated types.json with TOML configuration
2//!
3//! This module merges:
4//! - types.json: Generated by language implementations (Python, Go, etc.)
5//! - fraiseql.toml: Configuration (security, federation, observers, caching, etc.)
6//!
7//! Result: Complete `IntermediateSchema` ready for compilation
8
9use std::fs;
10
11use anyhow::{Context, Result};
12use fraiseql_core::schema::CrudNamingConfig;
13use serde_json::{Value, json};
14
15use crate::{
16    config::TomlSchema,
17    schema::{
18        IntermediateSchema,
19        intermediate::{IntermediateArgument, IntermediateQueryDefaults},
20    },
21};
22
23/// Convert a PascalCase GraphQL type name to a `snake_case` entity name.
24///
25/// Used to derive the entity segment for CRUD naming templates:
26/// `"UserProfile"` → `"user_profile"`, `"User"` → `"user"`.
27pub(crate) fn pascal_to_snake(type_name: &str) -> String {
28    let mut out = String::with_capacity(type_name.len() + 4);
29    for (i, ch) in type_name.chars().enumerate() {
30        if ch.is_uppercase() && i > 0 {
31            out.push('_');
32        }
33        out.push(ch.to_ascii_lowercase());
34    }
35    out
36}
37
38/// Resolve the `sql_source` for a TOML-defined mutation.
39///
40/// Precedence (highest first):
41/// 1. Explicit `sql_source` on the mutation.
42/// 2. `[crud]` naming config resolved from `operation` + entity derived from `return_type`.
43///
44/// Returns an error when neither is available, naming the offending mutation.
45fn resolve_mutation_sql_source(
46    mutation_name: &str,
47    sql_source: Option<&str>,
48    operation: &str,
49    return_type: &str,
50    crud: Option<&CrudNamingConfig>,
51) -> Result<String> {
52    if let Some(src) = sql_source {
53        return Ok(src.to_string());
54    }
55    if let Some(cfg) = crud {
56        let entity = pascal_to_snake(return_type);
57        if let Some(resolved) = cfg.resolve(operation, &entity) {
58            return Ok(resolved);
59        }
60    }
61    anyhow::bail!(
62        "Mutation '{mutation_name}' has no `sql_source` and no `[crud]` naming config \
63         could resolve it (operation = {operation:?}, return_type = {return_type:?}). \
64         Either add `sql_source` to the mutation or configure `[crud]` in fraiseql.toml."
65    )
66}
67
68/// Lower `[[analytics.queries]]` into ordinary compiled queries (#624).
69///
70/// Each entry becomes a list-returning, view-backed `QueryDefinition`: the
71/// operator-authored `sql_source` goes through the same compile-time SQL
72/// identifier validation as every other query, and the SELECT list is the
73/// declared `return_type`'s fields — so no client-supplied identifier can
74/// reach `FROM` or the SELECT list (the P01 constraint, satisfied
75/// structurally rather than by a new validator).
76///
77/// Fails loud on an unknown `return_type`, a name that collides with an
78/// existing query, or a name ending in `_aggregate` / `_window` — the
79/// executor's classifier dispatches those suffixes before query resolution,
80/// so such a query would compile and then be unreachable.
81fn apply_analytics_queries(
82    analytics: &crate::config::toml_schema::AnalyticsConfig,
83    queries_array: &mut Vec<Value>,
84    types_array: &[Value],
85) -> Result<()> {
86    for query in &analytics.queries {
87        if queries_array
88            .iter()
89            .any(|q| q.get("name").and_then(Value::as_str) == Some(query.name.as_str()))
90        {
91            anyhow::bail!(
92                "[[analytics.queries]] name '{}' collides with an existing query; analytics \
93                 queries share the query namespace.",
94                query.name
95            );
96        }
97        if query.name.ends_with("_aggregate") || query.name.ends_with("_window") {
98            anyhow::bail!(
99                "[[analytics.queries]] name '{}' ends in a reserved suffix: the executor \
100                 dispatches `_aggregate` / `_window` root fields to the fact-table planners \
101                 before query resolution, so this query would be unreachable. Rename it.",
102                query.name
103            );
104        }
105        let type_exists = types_array
106            .iter()
107            .any(|t| t.get("name").and_then(Value::as_str) == Some(query.return_type.as_str()));
108        if !type_exists {
109            let declared: Vec<&str> = types_array
110                .iter()
111                .filter_map(|t| t.get("name").and_then(Value::as_str))
112                .collect();
113            anyhow::bail!(
114                "[[analytics.queries]] '{}' declares return_type '{}', which is not a \
115                 declared type. Declared types: {}",
116                query.name,
117                query.return_type,
118                declared.join(", ")
119            );
120        }
121        queries_array.push(json!({
122            "name": query.name,
123            "return_type": query.return_type,
124            "returns_list": true,
125            "sql_source": query.sql_source,
126            "description": query.description,
127        }));
128    }
129    Ok(())
130}
131
132/// Lower `[[caching.rules]]` onto the compiled fields the runtime's result
133/// cache consumes (#623): the named query's `cache_ttl_seconds` (per-view TTL
134/// map, opt-in) and each trigger mutation's `invalidates_views`
135/// (mutation-driven invalidation).
136///
137/// Fails loud on an unknown query, an unknown trigger mutation, a query with
138/// no `sql_source`, or a TTL already authored on the query — a cache rule
139/// that silently matches nothing, or silently loses to another authoring
140/// source, is the #612 config-honesty bug this section was rejected over.
141fn apply_caching_rules(
142    caching: &crate::config::toml_schema::CachingConfig,
143    queries_array: &mut [Value],
144    mutations_array: &mut [Value],
145) -> Result<()> {
146    fn named<'a>(items: &'a mut [Value], name: &str) -> Option<&'a mut Value> {
147        items.iter_mut().find(|i| i.get("name").and_then(Value::as_str) == Some(name))
148    }
149    fn names(items: &[Value]) -> String {
150        items
151            .iter()
152            .filter_map(|i| i.get("name").and_then(Value::as_str))
153            .collect::<Vec<_>>()
154            .join(", ")
155    }
156
157    for rule in &caching.rules {
158        let Some(query) = named(queries_array, &rule.query) else {
159            anyhow::bail!(
160                "[[caching.rules]] names query '{}', which does not exist. Declared \
161                 queries: {}",
162                rule.query,
163                names(queries_array)
164            );
165        };
166        if query.get("cache_ttl_seconds").is_some_and(|v| !v.is_null()) {
167            anyhow::bail!(
168                "[[caching.rules]] sets ttl_seconds for query '{}', but the query already \
169                 declares cache_ttl_seconds (SDK/JSON-authored, or an earlier rule). Two \
170                 authoring sources for one TTL must not silently last-write-win — remove \
171                 one of them.",
172                rule.query
173            );
174        }
175        let Some(view) = query.get("sql_source").and_then(Value::as_str).map(str::to_owned) else {
176            anyhow::bail!(
177                "[[caching.rules]] targets query '{}', which has no sql_source; only \
178                 view-backed queries are cacheable.",
179                rule.query
180            );
181        };
182        query["cache_ttl_seconds"] = json!(rule.ttl_seconds);
183
184        for trigger in &rule.invalidation_triggers {
185            let Some(mutation) = named(mutations_array, trigger) else {
186                anyhow::bail!(
187                    "[[caching.rules]] for query '{}' names invalidation trigger '{}', \
188                     which is not a declared mutation. Declared mutations: {}",
189                    rule.query,
190                    trigger,
191                    names(mutations_array)
192                );
193            };
194            let views = mutation
195                .as_object_mut()
196                .expect("mutation entries are objects by construction")
197                .entry("invalidates_views")
198                .or_insert_with(|| json!([]));
199            let list = views.as_array_mut().ok_or_else(|| {
200                anyhow::anyhow!(
201                    "mutation '{trigger}' has a non-array invalidates_views; cannot append"
202                )
203            })?;
204            if !list.iter().any(|v| v.as_str() == Some(view.as_str())) {
205                list.push(json!(view));
206            }
207        }
208    }
209    Ok(())
210}
211
212/// Serialize TOML-declared arguments through [`IntermediateArgument`].
213///
214/// The round trip through the typed struct is the point: it is what makes the emitted wire
215/// keys (`type`, `nullable`) the ones the consumer reads, rather than a second spelling
216/// maintained by hand in a `json!` literal (`#756`).
217fn lower_toml_args(args: &[crate::config::toml_schema::ArgumentDefinition]) -> Result<Vec<Value>> {
218    args.iter()
219        .map(|arg| {
220            serde_json::to_value(IntermediateArgument::from(arg))
221                .with_context(|| format!("Failed to serialize declared argument {:?}", arg.name))
222        })
223        .collect()
224}
225
226/// Schema merger combining language types and TOML config
227pub struct SchemaMerger;
228
229impl SchemaMerger {
230    /// Merge types.json file with TOML configuration
231    ///
232    /// # Arguments
233    /// * `types_path` - Path to types.json (from language implementation)
234    /// * `toml_path` - Path to fraiseql.toml (configuration)
235    ///
236    /// # Returns
237    /// Combined `IntermediateSchema`.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if either file cannot be read or parsed, or if the
242    /// merged result cannot be deserialized into an `IntermediateSchema`.
243    pub fn merge_files(types_path: &str, toml_path: &str) -> Result<IntermediateSchema> {
244        // Load types.json
245        let types_json = fs::read_to_string(types_path)
246            .context(format!("Failed to read types.json from {types_path}"))?;
247        let types_value: Value =
248            serde_json::from_str(&types_json).context("Failed to parse types.json")?;
249
250        // Load TOML
251        let toml_schema = TomlSchema::from_file(toml_path)
252            .context(format!("Failed to load TOML from {toml_path}"))?;
253
254        // Note: TOML validation is skipped here because queries may reference types
255        // from types.json (not yet loaded). Validation happens in the compiler after merge.
256
257        // Merge
258        Self::merge_values(&types_value, &toml_schema)
259    }
260
261    /// Merge TOML-only (no types.json)
262    ///
263    /// # Arguments
264    /// * `toml_path` - Path to fraiseql.toml with inline type definitions
265    ///
266    /// # Returns
267    /// `IntermediateSchema` from TOML definitions.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the TOML file cannot be loaded, if validation fails,
272    /// or if the merged result cannot be deserialized into an `IntermediateSchema`.
273    pub fn merge_toml_only(toml_path: &str) -> Result<IntermediateSchema> {
274        let toml_schema = TomlSchema::from_file(toml_path)
275            .context(format!("Failed to load TOML from {toml_path}"))?;
276
277        toml_schema.validate()?;
278
279        // Convert TOML to intermediate schema
280        let types_value = toml_schema.to_intermediate_schema();
281        Self::merge_values(&types_value, &toml_schema)
282    }
283
284    /// Merge from directory with auto-discovery
285    ///
286    /// # Arguments
287    /// * `toml_path` - Path to fraiseql.toml (configuration)
288    /// * `schema_dir` - Path to directory containing schema files
289    ///
290    /// # Returns
291    /// `IntermediateSchema` from loaded files + TOML definitions.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if either file cannot be loaded or validated, if the
296    /// directory cannot be read, or if the merged result cannot be deserialized.
297    pub fn merge_from_directory(toml_path: &str, schema_dir: &str) -> Result<IntermediateSchema> {
298        let toml_schema = TomlSchema::from_file(toml_path)
299            .context(format!("Failed to load TOML from {toml_path}"))?;
300
301        toml_schema.validate()?;
302
303        // Load all files from directory
304        let types_value = crate::schema::MultiFileLoader::load_from_directory(schema_dir)
305            .context(format!("Failed to load schema from directory {schema_dir}"))?;
306
307        // Merge with TOML definitions
308        Self::merge_values(&types_value, &toml_schema)
309    }
310
311    /// Load every section from a set of files, returning `None` when the list is empty.
312    fn load_all_sections(files: &[String]) -> Result<Option<serde_json::Value>> {
313        if files.is_empty() {
314            return Ok(None);
315        }
316        let paths: Vec<std::path::PathBuf> = files.iter().map(std::path::PathBuf::from).collect();
317        let loaded = crate::schema::MultiFileLoader::load_from_paths(&paths)
318            .context("Failed to load explicit schema files")?;
319        Ok(Some(loaded))
320    }
321
322    /// Parse a JSON file and absorb **every** authorable section into the accumulator.
323    ///
324    /// This used to read exactly `types`, `queries` and `mutations`, with the comment
325    /// "Missing keys are silently skipped" — which is how a domain that declared enums
326    /// compiled successfully without them (`#755`). The section list now lives in one
327    /// place, so this site cannot fall behind the others.
328    fn extend_from_json_file(path: &std::path::Path, accumulator: &mut Value) -> Result<()> {
329        let content = fs::read_to_string(path)
330            .with_context(|| format!("Failed to read {}", path.display()))?;
331        let value: Value = serde_json::from_str(&content)
332            .with_context(|| format!("Failed to parse {}", path.display()))?;
333        super::seam::absorb_sections(accumulator, &value, &path.display().to_string())
334    }
335
336    /// Apply TOML metadata (`sql_source`, `description`) to a type JSON object in place.
337    fn enrich_type_from_toml(
338        enriched_type: &mut Value,
339        toml_type: &crate::config::toml_schema::TypeDefinition,
340    ) {
341        // The overlay half of the shared emitter (#1266): every key TOML contributes to
342        // a type, minus identity and `fields`, which the SDK's `schema.json` owns here.
343        // Written as a merge rather than a replacement for that reason — and reading the
344        // key list from one place is what stops a new TOML type key from reaching the
345        // compiler on the TOML-only path and vanishing on the `--types` one.
346        for (key, value) in toml_type.intermediate_overlay() {
347            enriched_type[key] = value;
348        }
349    }
350
351    /// Merge explicit file lists
352    ///
353    /// # Arguments
354    /// * `toml_path` - Path to fraiseql.toml (configuration)
355    /// * `type_files` - Vector of type file paths
356    /// * `query_files` - Vector of query file paths
357    /// * `mutation_files` - Vector of mutation file paths
358    ///
359    /// # Returns
360    /// `IntermediateSchema` from loaded files + TOML definitions
361    ///
362    /// # Errors
363    ///
364    /// Returns an error if the TOML file cannot be loaded or validated, or if any
365    /// of the type/query/mutation files fail to load or parse.
366    pub fn merge_explicit_files(
367        toml_path: &str,
368        type_files: &[String],
369        query_files: &[String],
370        mutation_files: &[String],
371    ) -> Result<IntermediateSchema> {
372        let toml_schema = TomlSchema::from_file(toml_path)
373            .context(format!("Failed to load TOML from {toml_path}"))?;
374
375        toml_schema.validate()?;
376
377        let mut types_value = super::seam::empty_accumulator();
378
379        // Absorb each file list wholesale rather than lifting one key out of it: a
380        // `--type-files` entry that also declares enums used to have those enums read and
381        // then discarded, because `load_section` returned only the caller's named key.
382        for files in [type_files, query_files, mutation_files] {
383            if let Some(loaded) = Self::load_all_sections(files)? {
384                super::seam::absorb_sections(&mut types_value, &loaded, "explicit schema files")?;
385            }
386        }
387
388        Self::merge_values(&types_value, &toml_schema)
389    }
390
391    /// Merge from domains (domain-based organization)
392    ///
393    /// # Arguments
394    /// * `toml_path` - Path to fraiseql.toml with domain_discovery enabled
395    ///
396    /// # Returns
397    /// `IntermediateSchema` from all domains (types.json, queries.json, mutations.json).
398    ///
399    /// # Errors
400    ///
401    /// Returns an error if the TOML cannot be loaded or validated, if domain
402    /// discovery fails, if any domain file cannot be parsed, or if the merged
403    /// result cannot be deserialized.
404    pub fn merge_from_domains(toml_path: &str) -> Result<IntermediateSchema> {
405        let toml_schema = TomlSchema::from_file(toml_path)
406            .context(format!("Failed to load TOML from {toml_path}"))?;
407
408        toml_schema.validate()?;
409
410        // Resolve domains from configuration
411        let domains = toml_schema
412            .domain_discovery
413            .resolve_domains()
414            .context("Failed to discover domains")?;
415
416        if domains.is_empty() {
417            // No domains found, return empty schema merged with TOML definitions
418            return Self::merge_values(&super::seam::empty_accumulator(), &toml_schema);
419        }
420
421        let mut types_value = super::seam::empty_accumulator();
422
423        for domain in domains {
424            for filename in ["types.json", "queries.json", "mutations.json"] {
425                let path = domain.path.join(filename);
426                if path.exists() {
427                    Self::extend_from_json_file(&path, &mut types_value)?;
428                }
429            }
430        }
431
432        // Merge with TOML definitions
433        Self::merge_values(&types_value, &toml_schema)
434    }
435
436    /// Merge with TOML includes (glob patterns for schema files)
437    ///
438    /// # Arguments
439    /// * `toml_path` - Path to fraiseql.toml with schema.includes section
440    ///
441    /// # Returns
442    /// `IntermediateSchema` from loaded files + TOML definitions.
443    ///
444    /// # Errors
445    ///
446    /// Returns an error if the TOML cannot be loaded or validated, if any glob
447    /// pattern is invalid, if a matched file cannot be parsed, or if the merged
448    /// result cannot be deserialized.
449    pub fn merge_with_includes(toml_path: &str) -> Result<IntermediateSchema> {
450        let toml_schema = TomlSchema::from_file(toml_path)
451            .context(format!("Failed to load TOML from {toml_path}"))?;
452
453        toml_schema.validate()?;
454
455        // If includes are specified, load and merge files.
456        //
457        // Every resolved glob group is absorbed whole. The previous shape lifted a single
458        // named key out of each group (`loaded.get("queries")`, `loaded.get("mutations")`),
459        // so an included file that declared anything else contributed nothing (`#755`).
460        let mut types_value = super::seam::empty_accumulator();
461        if !toml_schema.includes.is_empty() {
462            let resolved = toml_schema
463                .includes
464                .resolve_globs()
465                .context("Failed to resolve glob patterns in schema.includes")?;
466
467            for group in [&resolved.types, &resolved.queries, &resolved.mutations] {
468                if group.is_empty() {
469                    continue;
470                }
471                let loaded = crate::schema::MultiFileLoader::load_from_paths(group)
472                    .context("Failed to load included schema files")?;
473                super::seam::absorb_sections(&mut types_value, &loaded, "schema.includes")?;
474            }
475        }
476
477        // Merge with TOML definitions
478        Self::merge_values(&types_value, &toml_schema)
479    }
480
481    /// Merge JSON types with TOML schema
482    #[allow(clippy::cognitive_complexity)] // Reason: deep merge of two schema formats with many field-level transformations
483    fn merge_values(types_value: &Value, toml_schema: &TomlSchema) -> Result<IntermediateSchema> {
484        // #1017: every self-contained TOML check runs on EVERY compile path, here at
485        // the funnel all six `merge_*` entry points share.
486        //
487        // `merge_files` (the `--types` workflow) cannot call the whole of
488        // `TomlSchema::validate()`, because a query in the TOML may legitimately name a
489        // type that only `types.json` defines. That exemption justifies skipping the
490        // type-reference checks and nothing else — but for a long time it skipped all of
491        // them, so an invalid `[server]` port, a `pool_min` above `pool_max`, an
492        // incomplete `[auth]` PKCE group and an unparseable proxy CIDR were refused by
493        // five workflows and compiled by the sixth. #612's unconsumed-config gate, #892's
494        // `[tenancy]` check and #897's role check were each rescued here one at a time;
495        // calling the self-contained half wholesale is what stops the next one needing
496        // the same rescue.
497        //
498        // The other five entry points call `TomlSchema::validate()`, which runs this half
499        // too, so they check it twice. That redundancy is the point and must not be
500        // "cleaned up" by dropping this call: it is what makes the funnel — not the
501        // entry point — the thing that guarantees the check ran.
502        toml_schema.validate_self_contained()?;
503
504        // Typo guard: [queries.defaults] is a common mistake for [query_defaults].
505        if toml_schema.queries.contains_key("defaults") {
506            anyhow::bail!(
507                "Found a query definition named 'defaults' under [queries.defaults]. \
508                 Did you mean [query_defaults] to set global auto-param defaults?\n\
509                 If you intended a query called 'defaults', rename it to avoid confusion."
510            );
511        }
512
513        // Start with arrays for types, queries, mutations (not objects!)
514        // This matches IntermediateSchema structure which uses Vec<T>
515        let mut types_array: Vec<Value> = Vec::new();
516        let mut queries_array: Vec<Value> = Vec::new();
517        let mut mutations_array: Vec<Value> = Vec::new();
518
519        // Process types from types.json (comes as array from language SDKs)
520        if let Some(types_obj) = types_value.get("types") {
521            match types_obj {
522                // Handle array format (from language SDKs)
523                Value::Array(types_list) => {
524                    for type_item in types_list {
525                        if let Some(type_name) = type_item.get("name").and_then(|v| v.as_str()) {
526                            let mut enriched_type = type_item.clone();
527                            if let Some(toml_type) = toml_schema.types.get(type_name) {
528                                Self::enrich_type_from_toml(&mut enriched_type, toml_type);
529                            }
530                            types_array.push(enriched_type);
531                        }
532                    }
533                },
534                // Handle object format (from TOML-only, for backward compatibility)
535                Value::Object(types_map) => {
536                    for (type_name, type_value) in types_map {
537                        let mut enriched_type = type_value.clone();
538                        enriched_type["name"] = json!(type_name);
539
540                        // Convert fields from object to array format if needed
541                        if let Some(Value::Object(fields_map)) = enriched_type.get("fields") {
542                            let fields_array: Vec<Value> = fields_map
543                                .iter()
544                                .map(|(field_name, field_value)| {
545                                    let mut field = field_value.clone();
546                                    field["name"] = json!(field_name);
547                                    field
548                                })
549                                .collect();
550                            enriched_type["fields"] = json!(fields_array);
551                        }
552
553                        if let Some(toml_type) = toml_schema.types.get(type_name) {
554                            Self::enrich_type_from_toml(&mut enriched_type, toml_type);
555                        }
556
557                        types_array.push(enriched_type);
558                    }
559                },
560                _ => {},
561            }
562        }
563
564        // Add types from TOML that aren't already in types_array
565        let existing_type_names: std::collections::HashSet<_> = types_array
566            .iter()
567            .filter_map(|t| t.get("name").and_then(|v| v.as_str()).map(str::to_string))
568            .collect();
569
570        for (type_name, toml_type) in &toml_schema.types {
571            if !existing_type_names.contains(type_name) {
572                // One emitter, shared with `TomlSchema::to_intermediate_schema` (#959,
573                // widened from fields to the whole type by #1266): the hand-built copies
574                // had drifted, and the symptom of the drift is an authored key that
575                // silently does nothing on one of the TOML paths.
576                types_array.push(toml_type.to_intermediate_json(type_name));
577            }
578        }
579
580        if let Some(Value::Array(queries_list)) = types_value.get("queries") {
581            queries_array.clone_from(queries_list);
582        }
583
584        // Add queries from TOML
585        for (query_name, toml_query) in &toml_schema.queries {
586            queries_array.push(json!({
587                "name": query_name,
588                "return_type": toml_query.return_type,
589                "returns_list": toml_query.return_array,
590                "sql_source": toml_query.sql_source,
591                "description": toml_query.description,
592                "arguments": lower_toml_args(&toml_query.args)?,
593            }));
594        }
595
596        if let Some(Value::Array(mutations_list)) = types_value.get("mutations") {
597            mutations_array.clone_from(mutations_list);
598        }
599
600        // Add mutations from TOML
601        for (mutation_name, toml_mutation) in &toml_schema.mutations {
602            let sql_source = resolve_mutation_sql_source(
603                mutation_name,
604                toml_mutation.sql_source.as_deref(),
605                &toml_mutation.operation,
606                &toml_mutation.return_type,
607                toml_schema.crud.as_ref(),
608            )?;
609            mutations_array.push(json!({
610                "name": mutation_name,
611                "return_type": toml_mutation.return_type,
612                "sql_source": sql_source,
613                "operation": toml_mutation.operation,
614                "input_style": toml_mutation.input_style,
615                "changelog_pre_image": toml_mutation.changelog_pre_image,
616                "cascade": toml_mutation.cascade,
617                "description": toml_mutation.description,
618                "arguments": lower_toml_args(&toml_mutation.args)?,
619            }));
620        }
621
622        // #624: lower [[analytics.queries]] into ordinary compiled queries —
623        // before the caching rules, so a [[caching.rules]] entry can target an
624        // analytics query.
625        apply_analytics_queries(&toml_schema.analytics, &mut queries_array, &types_array)?;
626
627        // #623: lower [[caching.rules]] onto the compiled fields the runtime's
628        // result cache consumes. Runs after both arrays hold every authored
629        // operation (JSON/SDK and TOML alike), so a rule can target either.
630        apply_caching_rules(&toml_schema.caching, &mut queries_array, &mut mutations_array)?;
631
632        // Build the merged schema by **carrying the authored document forward** and
633        // overwriting only the three sections this function actually transforms.
634        //
635        // This used to be `json!({version, types, queries, mutations})` — a fresh object
636        // built from scratch, which silently discarded every other section the SDK had
637        // authored: enums, input types, interfaces, unions, subscriptions, observers,
638        // custom scalars, sources, session variables and federation (`#755`). Eight
639        // categories, on all five TOML-workflow compile paths, with `✓ Schema compiled
640        // successfully` printed.
641        //
642        // Pass-through inverts the default: a section the merger does not understand
643        // survives to `IntermediateSchema`, which either binds it or — since it now denies
644        // unknown fields — refuses the compile. Neither outcome is silent, and a section
645        // added tomorrow needs no change here.
646        let mut merged = types_value.clone();
647        if !merged.is_object() {
648            anyhow::bail!(
649                "Expected a JSON object at the top level of the schema input, found {}",
650                if merged.is_array() {
651                    "an array"
652                } else {
653                    "a scalar"
654                }
655            );
656        }
657        merged["version"] = json!("2.0.0");
658        merged["types"] = json!(types_array);
659        merged["queries"] = json!(queries_array);
660        merged["mutations"] = json!(mutations_array);
661
662        // Warn when PKCE is enabled without state encryption (insecure configuration).
663        if let Some(pkce) = &toml_schema.security.pkce {
664            if pkce.enabled {
665                let enc_enabled =
666                    toml_schema.security.state_encryption.as_ref().is_some_and(|e| e.enabled);
667                if !enc_enabled {
668                    tracing::warn!(
669                        "pkce.enabled = true but state_encryption.enabled = false. \
670                         PKCE state will be stored unencrypted. \
671                         Set [security.state_encryption] enabled = true for production."
672                    );
673                }
674            }
675        }
676
677        // Add security configuration if available in TOML.
678        //
679        // Constructed as the typed `fraiseql_core::schema::SecurityConfig` and
680        // serialized (#977): the compiled seam owns every section's shape and is
681        // `deny_unknown_fields`, so a key this block emitted under the wrong
682        // name would fail to deserialize instead of landing in a catch-all.
683        // Building the struct — rather than writing key names by hand — makes
684        // that drift a compile error here, before it can reach a file.
685        // `tenancy_gate_seam_test` additionally asks the consumer's questions of
686        // a compiled file rather than checking key presence.
687        let sec = &toml_schema.security;
688        merged["security"] = serde_json::to_value(fraiseql_core::schema::SecurityConfig {
689            // #897: lowered from `[[security.role_definitions]]` / `default_role`, through
690            // the same `to_runtime` the project-config producer uses. Hard-coded empty
691            // until then, which made field-level RBAC undeclarable on this workflow: a
692            // field marked `requires_scope` was denied to everyone, because no role could
693            // be defined to grant the scope.
694            role_definitions:       sec
695                .role_definitions
696                .iter()
697                .map(crate::config::security::RoleDefinitionConfig::to_runtime)
698                .collect(),
699            default_role:           sec.default_role.clone(),
700            multi_tenant:           sec.multi_tenant,
701            rls:                    sec.rls.clone(),
702            // #892: lowered from `[tenancy]`, through the same `to_runtime` the project-
703            // config path uses. Hard-coded to the default until then, which made
704            // `tenancy.mode` — and so schema-per-tenant provisioning, row-mode
705            // `@tenant_id` validation and `is_multi_tenant()` — unreachable from this
706            // workflow and from every `[domain_discovery]` project.
707            tenancy:                toml_schema.tenancy.to_runtime(),
708            cost_budget:            sec.cost_budget.clone(),
709            // #983: not emitted — no consumer reads it, and every Workflow-A schema
710            // used to carry `"authenticated"` here by default, which is precisely the
711            // kind of declaration an operator reads as an enforced boundary.
712            default_policy:         None,
713            rules:                  sec.rules.clone(),
714            policies:               sec.policies.clone(),
715            field_auth:             sec.field_auth.clone(),
716            enterprise:             Some(sec.enterprise.clone()),
717            error_sanitization:     sec.error_sanitization.clone(),
718            rate_limiting:          sec.rate_limiting.clone(),
719            state_encryption:       sec.state_encryption.clone(),
720            pkce:                   sec.pkce.clone(),
721            api_keys:               sec.api_keys.clone(),
722            token_revocation:       sec.token_revocation.clone(),
723            trusted_documents:      sec.trusted_documents.clone(),
724            persisted_queries_only: sec.persisted_queries_only,
725            service_accounts:       None,
726        })
727        .context("Failed to serialize [security] into the compiled schema")?;
728
729        // Embed observers configuration if enabled or if any backend URL is set
730        if toml_schema.observers.is_configured() {
731            if toml_schema.observers.backend == fraiseql_core::schema::ObserversBackend::Nats
732                && toml_schema.observers.nats_url.is_none()
733            {
734                tracing::warn!(
735                    "observers.backend is \"nats\" but observers.nats_url is not set; \
736                     the runtime will require FRAISEQL_NATS_URL to be configured"
737                );
738            }
739            // No `handlers` key (#631): compiled handler declarations are not a
740            // runtime concept — TOML validation has already rejected a non-empty
741            // `[[observers.handlers]]`, and the compiled `ObserversConfig` is
742            // `deny_unknown_fields` so the key cannot be represented.
743            merged["observers_config"] = serde_json::to_value(toml_schema.observers.to_compiled())
744                .context("Failed to serialize observers config")?;
745        }
746
747        // Embed the `[functions]` settings when the project configures any (#1325).
748        // A table left at its defaults produces no section: an empty one would read
749        // as a deliberate choice, and the compiler's own `functions/` convention is
750        // the honest answer to "the author said nothing".
751        if toml_schema.functions.is_configured() {
752            merged["functions_config"] = json!({
753                "module_dir": toml_schema.functions.module_dir,
754                "dlq_store": toml_schema.functions.dlq_store,
755            });
756        }
757
758        // Embed federation configuration if enabled. Lower the TOML config into the
759        // compiled (`fraiseql_core`) shape explicitly so the merged JSON matches what
760        // the converter/runtime deserialize — a raw passthrough silently dropped
761        // `service_name`/`version` and the `per_database` → `per_entity` overrides.
762        if toml_schema.federation.enabled {
763            merged["federation_config"] =
764                serde_json::to_value(toml_schema.federation.to_compiled())
765                    .context("Failed to serialize federation config")?;
766        }
767
768        // Embed the `[auth]` OAuth-client groups. The PKCE group (#621) is
769        // all-four-or-none (enforced by `OidcClientConfig::validate` at load
770        // time), so if `client_id` is present the whole group is; it lowers into
771        // the compiled `auth.pkce` object, which carries the client identity and
772        // the provider `discovery_url` only — the runtime resolves the
773        // authorization/token endpoints at boot, and the secret is read from
774        // `client_secret_env`, never embedded. The social group (#368,
775        // `[auth.social.*]`) is already authored in the compiled type and is
776        // embedded verbatim under `auth.social`.
777        if let Some(auth) = &toml_schema.auth {
778            let mut auth_obj = serde_json::Map::new();
779            if let (Some(discovery_url), Some(client_id), Some(client_secret_env), Some(redirect)) = (
780                auth.discovery_url.as_ref(),
781                auth.client_id.as_ref(),
782                auth.client_secret_env.as_ref(),
783                auth.server_redirect_uri.as_ref(),
784            ) {
785                auth_obj.insert(
786                    "pkce".to_string(),
787                    json!({
788                        "discovery_url": discovery_url,
789                        "client_id": client_id,
790                        "client_secret_env": client_secret_env,
791                        "server_redirect_uri": redirect,
792                    }),
793                );
794            }
795            if let Some(social) = &auth.social {
796                auth_obj.insert(
797                    "social".to_string(),
798                    serde_json::to_value(social).context("Failed to serialize [auth.social]")?,
799                );
800            }
801            if let Some(local) = &auth.local {
802                auth_obj.insert(
803                    "local".to_string(),
804                    serde_json::to_value(local).context("Failed to serialize [auth.local]")?,
805                );
806            }
807            if !auth_obj.is_empty() {
808                merged["auth"] = serde_json::Value::Object(auth_obj);
809            }
810        }
811
812        // Embed subscriptions configuration (hooks, limits)
813        let subs_json = serde_json::to_value(&toml_schema.subscriptions)
814            .context("Failed to serialize subscriptions config")?;
815        if subs_json != serde_json::json!({}) {
816            merged["subscriptions_config"] = subs_json;
817        }
818
819        // Embed validation config (depth/complexity limits)
820        let val_json = serde_json::to_value(&toml_schema.validation)
821            .context("Failed to serialize validation config")?;
822        if val_json != serde_json::json!({}) {
823            merged["validation_config"] = val_json;
824        }
825
826        // Embed debug config when enabled
827        if toml_schema.debug.enabled {
828            let debug_json = serde_json::to_value(&toml_schema.debug)
829                .context("Failed to serialize debug config")?;
830            merged["debug_config"] = debug_json;
831        }
832
833        // Embed MCP config when enabled
834        if toml_schema.mcp.enabled {
835            merged["mcp_config"] =
836                serde_json::to_value(&toml_schema.mcp).context("Failed to serialize MCP config")?;
837        }
838
839        // Embed REST config when enabled (with path validation)
840        if toml_schema.rest.enabled {
841            let path = &toml_schema.rest.path;
842            if !path.starts_with('/') {
843                anyhow::bail!("REST config: `path` must start with '/' (got {path:?})");
844            }
845
846            let rest_config: fraiseql_core::schema::RestConfig = toml_schema.rest.clone().into();
847
848            merged["rest_config"] =
849                serde_json::to_value(&rest_config).context("Failed to serialize REST config")?;
850        }
851
852        // Embed gRPC config when enabled (#780).
853        //
854        // `descriptor_path` is validated here rather than at boot: `build_grpc_service` reads
855        // the FileDescriptorSet from that path, so an empty one guarantees a server that
856        // cannot mount the transport it was told to enable. Failing at compile time is the
857        // difference between a message naming the TOML key and a runtime read error.
858        if toml_schema.grpc.enabled {
859            if toml_schema.grpc.descriptor_path.trim().is_empty() {
860                anyhow::bail!(
861                    "[grpc] enabled = true requires `descriptor_path`, the path to the \
862                     FileDescriptorSet binary (.binpb) the transport serves. Generate one with \
863                     `protoc --descriptor_set_out`, or set `enabled = false`."
864                );
865            }
866            if toml_schema.grpc.stream_batch_size == 0 {
867                anyhow::bail!(
868                    "[grpc] stream_batch_size must be greater than 0 (got 0) — a zero batch \
869                     size would make every server-streaming response empty."
870                );
871            }
872            merged["grpc_config"] = serde_json::to_value(&toml_schema.grpc)
873                .context("Failed to serialize gRPC config")?;
874        }
875
876        // Embed naming convention
877        merged["naming_convention"] = serde_json::to_value(toml_schema.naming_convention)
878            .context("Failed to serialize naming_convention")?;
879
880        // Embed changelog GraphQL-exposure config (gating + injection happen in the converter)
881        if let Some(ref changelog) = toml_schema.changelog {
882            merged["changelog_config"] =
883                serde_json::to_value(changelog).context("Failed to serialize changelog config")?;
884        }
885
886        // Embed hierarchy definitions for ID-based ltree operators
887        if let Some(ref hierarchies) = toml_schema.hierarchies {
888            merged["hierarchies_config"] = serde_json::to_value(hierarchies)
889                .context("Failed to serialize hierarchies config")?;
890        }
891
892        // Embed per-request session variables. Serialized from the same type the
893        // runtime deserializes, so the mapping an RLS policy depends on cannot be
894        // lost to a key rename here (#628).
895        if toml_schema.session_variables != fraiseql_core::schema::SessionVariablesConfig::default()
896        {
897            merged["session_variables"] = serde_json::to_value(&toml_schema.session_variables)
898                .context("Failed to serialize session_variables")?;
899        }
900
901        // `[locale]` (#1512): serialized from the type the runtime deserializes.
902        if let Some(locale) = &toml_schema.locale {
903            merged["locale"] =
904                serde_json::to_value(locale).context("Failed to serialize locale")?;
905        }
906
907        // `[inject_defaults]` (#1384): the TOML schema's section, reconciled with a block the
908        // SDK may have emitted into types.json from the same config.
909        let document_defaults = merged
910            .get("inject_defaults")
911            .cloned()
912            .map(serde_json::from_value::<super::intermediate::IntermediateInjectDefaults>)
913            .transpose()
914            .context("Invalid inject_defaults in the merged schema document")?;
915        if let Some(defaults) = crate::config::inject_defaults::InjectDefaultsToml::reconcile(
916            toml_schema.inject_defaults.as_ref(),
917            document_defaults,
918        )? {
919            merged["inject_defaults"] =
920                serde_json::to_value(defaults).context("Failed to serialize inject_defaults")?;
921        }
922
923        // Refuse a security control declared under a key that does not bind (#806/#807).
924        //
925        // This is the TOML / multi-file workflow's *only* deserialization point — all six
926        // `merge_*` entry points funnel through `merge_values` — and it is a genuinely
927        // separate seam from the JSON workflow's guard in `commands::compile`. Guarding
928        // only the JSON path would have left every `--schema-dir` and `types.json` user
929        // with the original silent drop, which is the shape of defect this guard exists
930        // to close: a check that exists and has a call site routing around it.
931        super::intermediate::reject_drifted_keys(&merged).map_err(|e| anyhow::anyhow!("{e}"))?;
932
933        // Convert to IntermediateSchema
934        let mut schema = serde_json::from_value::<IntermediateSchema>(merged)
935            .context("Failed to convert merged schema to IntermediateSchema")?;
936
937        // Inject TOML [query_defaults] into the schema so the converter can apply
938        // them as project-wide fallbacks for list-query auto-params.
939        schema.query_defaults = Some(IntermediateQueryDefaults {
940            where_clause:     toml_schema.query_defaults.where_clause,
941            order_by:         toml_schema.query_defaults.order_by,
942            limit:            toml_schema.query_defaults.limit,
943            offset:           toml_schema.query_defaults.offset,
944            pagination_order: toml_schema.query_defaults.pagination_order,
945        });
946
947        Ok(schema)
948    }
949}