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.enabled
731 || toml_schema.observers.redis_url.is_some()
732 || toml_schema.observers.nats_url.is_some()
733 {
734 if toml_schema.observers.backend == "nats" && toml_schema.observers.nats_url.is_none() {
735 tracing::warn!(
736 "observers.backend is \"nats\" but observers.nats_url is not set; \
737 the runtime will require FRAISEQL_NATS_URL to be configured"
738 );
739 }
740 // No `handlers` key (#631): compiled handler declarations are not a
741 // runtime concept — TOML validation has already rejected a non-empty
742 // `[[observers.handlers]]`, and the compiled `ObserversConfig` is
743 // `deny_unknown_fields` so the key cannot be represented.
744 merged["observers_config"] = json!({
745 "enabled": toml_schema.observers.enabled,
746 "backend": toml_schema.observers.backend,
747 "redis_url": toml_schema.observers.redis_url,
748 "nats_url": toml_schema.observers.nats_url,
749 });
750 }
751
752 // Embed the `[functions]` settings when the project configures any (#1325).
753 // A table left at its defaults produces no section: an empty one would read
754 // as a deliberate choice, and the compiler's own `functions/` convention is
755 // the honest answer to "the author said nothing".
756 if toml_schema.functions.is_configured() {
757 merged["functions_config"] = json!({
758 "module_dir": toml_schema.functions.module_dir,
759 "dlq_store": toml_schema.functions.dlq_store,
760 });
761 }
762
763 // Embed federation configuration if enabled. Lower the TOML config into the
764 // compiled (`fraiseql_core`) shape explicitly so the merged JSON matches what
765 // the converter/runtime deserialize — a raw passthrough silently dropped
766 // `service_name`/`version` and the `per_database` → `per_entity` overrides.
767 if toml_schema.federation.enabled {
768 merged["federation_config"] =
769 serde_json::to_value(toml_schema.federation.to_compiled())
770 .context("Failed to serialize federation config")?;
771 }
772
773 // Embed the `[auth]` OAuth-client groups. The PKCE group (#621) is
774 // all-four-or-none (enforced by `OidcClientConfig::validate` at load
775 // time), so if `client_id` is present the whole group is; it lowers into
776 // the compiled `auth.pkce` object, which carries the client identity and
777 // the provider `discovery_url` only — the runtime resolves the
778 // authorization/token endpoints at boot, and the secret is read from
779 // `client_secret_env`, never embedded. The social group (#368,
780 // `[auth.social.*]`) is already authored in the compiled type and is
781 // embedded verbatim under `auth.social`.
782 if let Some(auth) = &toml_schema.auth {
783 let mut auth_obj = serde_json::Map::new();
784 if let (Some(discovery_url), Some(client_id), Some(client_secret_env), Some(redirect)) = (
785 auth.discovery_url.as_ref(),
786 auth.client_id.as_ref(),
787 auth.client_secret_env.as_ref(),
788 auth.server_redirect_uri.as_ref(),
789 ) {
790 auth_obj.insert(
791 "pkce".to_string(),
792 json!({
793 "discovery_url": discovery_url,
794 "client_id": client_id,
795 "client_secret_env": client_secret_env,
796 "server_redirect_uri": redirect,
797 }),
798 );
799 }
800 if let Some(social) = &auth.social {
801 auth_obj.insert(
802 "social".to_string(),
803 serde_json::to_value(social).context("Failed to serialize [auth.social]")?,
804 );
805 }
806 if let Some(local) = &auth.local {
807 auth_obj.insert(
808 "local".to_string(),
809 serde_json::to_value(local).context("Failed to serialize [auth.local]")?,
810 );
811 }
812 if !auth_obj.is_empty() {
813 merged["auth"] = serde_json::Value::Object(auth_obj);
814 }
815 }
816
817 // Embed subscriptions configuration (hooks, limits)
818 let subs_json = serde_json::to_value(&toml_schema.subscriptions)
819 .context("Failed to serialize subscriptions config")?;
820 if subs_json != serde_json::json!({}) {
821 merged["subscriptions_config"] = subs_json;
822 }
823
824 // Embed validation config (depth/complexity limits)
825 let val_json = serde_json::to_value(&toml_schema.validation)
826 .context("Failed to serialize validation config")?;
827 if val_json != serde_json::json!({}) {
828 merged["validation_config"] = val_json;
829 }
830
831 // Embed debug config when enabled
832 if toml_schema.debug.enabled {
833 let debug_json = serde_json::to_value(&toml_schema.debug)
834 .context("Failed to serialize debug config")?;
835 merged["debug_config"] = debug_json;
836 }
837
838 // Embed MCP config when enabled
839 if toml_schema.mcp.enabled {
840 merged["mcp_config"] =
841 serde_json::to_value(&toml_schema.mcp).context("Failed to serialize MCP config")?;
842 }
843
844 // Embed REST config when enabled (with path validation)
845 if toml_schema.rest.enabled {
846 let path = &toml_schema.rest.path;
847 if !path.starts_with('/') {
848 anyhow::bail!("REST config: `path` must start with '/' (got {path:?})");
849 }
850
851 let rest_config: fraiseql_core::schema::RestConfig = toml_schema.rest.clone().into();
852
853 merged["rest_config"] =
854 serde_json::to_value(&rest_config).context("Failed to serialize REST config")?;
855 }
856
857 // Embed gRPC config when enabled (#780).
858 //
859 // `descriptor_path` is validated here rather than at boot: `build_grpc_service` reads
860 // the FileDescriptorSet from that path, so an empty one guarantees a server that
861 // cannot mount the transport it was told to enable. Failing at compile time is the
862 // difference between a message naming the TOML key and a runtime read error.
863 if toml_schema.grpc.enabled {
864 if toml_schema.grpc.descriptor_path.trim().is_empty() {
865 anyhow::bail!(
866 "[grpc] enabled = true requires `descriptor_path`, the path to the \
867 FileDescriptorSet binary (.binpb) the transport serves. Generate one with \
868 `protoc --descriptor_set_out`, or set `enabled = false`."
869 );
870 }
871 if toml_schema.grpc.stream_batch_size == 0 {
872 anyhow::bail!(
873 "[grpc] stream_batch_size must be greater than 0 (got 0) — a zero batch \
874 size would make every server-streaming response empty."
875 );
876 }
877 merged["grpc_config"] = serde_json::to_value(&toml_schema.grpc)
878 .context("Failed to serialize gRPC config")?;
879 }
880
881 // Embed naming convention
882 merged["naming_convention"] = serde_json::to_value(toml_schema.naming_convention)
883 .context("Failed to serialize naming_convention")?;
884
885 // Embed changelog GraphQL-exposure config (gating + injection happen in the converter)
886 if let Some(ref changelog) = toml_schema.changelog {
887 merged["changelog_config"] =
888 serde_json::to_value(changelog).context("Failed to serialize changelog config")?;
889 }
890
891 // Embed hierarchy definitions for ID-based ltree operators
892 if let Some(ref hierarchies) = toml_schema.hierarchies {
893 merged["hierarchies_config"] = serde_json::to_value(hierarchies)
894 .context("Failed to serialize hierarchies config")?;
895 }
896
897 // Embed per-request session variables. Serialized from the same type the
898 // runtime deserializes, so the mapping an RLS policy depends on cannot be
899 // lost to a key rename here (#628).
900 if toml_schema.session_variables != fraiseql_core::schema::SessionVariablesConfig::default()
901 {
902 merged["session_variables"] = serde_json::to_value(&toml_schema.session_variables)
903 .context("Failed to serialize session_variables")?;
904 }
905
906 // `[locale]` (#1512): serialized from the type the runtime deserializes.
907 if let Some(locale) = &toml_schema.locale {
908 merged["locale"] =
909 serde_json::to_value(locale).context("Failed to serialize locale")?;
910 }
911
912 // `[inject_defaults]` (#1384): the TOML schema's section, reconciled with a block the
913 // SDK may have emitted into types.json from the same config.
914 let document_defaults = merged
915 .get("inject_defaults")
916 .cloned()
917 .map(serde_json::from_value::<super::intermediate::IntermediateInjectDefaults>)
918 .transpose()
919 .context("Invalid inject_defaults in the merged schema document")?;
920 if let Some(defaults) = crate::config::inject_defaults::InjectDefaultsToml::reconcile(
921 toml_schema.inject_defaults.as_ref(),
922 document_defaults,
923 )? {
924 merged["inject_defaults"] =
925 serde_json::to_value(defaults).context("Failed to serialize inject_defaults")?;
926 }
927
928 // Refuse a security control declared under a key that does not bind (#806/#807).
929 //
930 // This is the TOML / multi-file workflow's *only* deserialization point — all six
931 // `merge_*` entry points funnel through `merge_values` — and it is a genuinely
932 // separate seam from the JSON workflow's guard in `commands::compile`. Guarding
933 // only the JSON path would have left every `--schema-dir` and `types.json` user
934 // with the original silent drop, which is the shape of defect this guard exists
935 // to close: a check that exists and has a call site routing around it.
936 super::intermediate::reject_drifted_keys(&merged).map_err(|e| anyhow::anyhow!("{e}"))?;
937
938 // Convert to IntermediateSchema
939 let mut schema = serde_json::from_value::<IntermediateSchema>(merged)
940 .context("Failed to convert merged schema to IntermediateSchema")?;
941
942 // Inject TOML [query_defaults] into the schema so the converter can apply
943 // them as project-wide fallbacks for list-query auto-params.
944 schema.query_defaults = Some(IntermediateQueryDefaults {
945 where_clause: toml_schema.query_defaults.where_clause,
946 order_by: toml_schema.query_defaults.order_by,
947 limit: toml_schema.query_defaults.limit,
948 offset: toml_schema.query_defaults.offset,
949 pagination_order: toml_schema.query_defaults.pagination_order,
950 });
951
952 Ok(schema)
953 }
954}