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