Skip to main content

fraiseql_cli/schema/converter/
mod.rs

1//! Schema Converter
2//!
3//! Converts `IntermediateSchema` (language-agnostic) to `CompiledSchema` (Rust-specific)
4
5mod cascade_types;
6mod directives;
7pub mod functions;
8mod identity;
9mod interface_conformance;
10mod mutation_error_union;
11mod mutations;
12mod queries;
13mod relay;
14mod subscriptions;
15pub(crate) mod tenancy;
16mod types;
17
18#[cfg(test)]
19mod tests;
20
21use std::collections::HashSet;
22
23use anyhow::{Context, Result};
24use fraiseql_core::{
25    compiler::fact_table::{
26        DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
27    },
28    schema::{CompiledSchema, FieldType},
29};
30use fraiseql_functions::FunctionsConfig;
31use tracing::{info, warn};
32
33use super::{
34    artifact::CompiledArtifact,
35    intermediate::{IntermediateFactTable, IntermediateInjectDefaults, IntermediateSchema},
36};
37
38/// The directory a project's function modules live in when nothing says otherwise.
39///
40/// The default lives here, in the compiler, rather than on the wire type: every
41/// compiled artifact carries an explicit `module_dir`, so a *hand-written*
42/// `schema.compiled.json` that omits it still fails loudly instead of quietly
43/// resolving somewhere its author did not choose.
44const DEFAULT_FUNCTION_MODULE_DIR: &str = "functions";
45
46/// Converts intermediate format to compiled format
47pub struct SchemaConverter;
48
49/// The author-declared enum, interface, union and custom-scalar names of one schema
50/// (`#923`, extended by `#1018`).
51///
52/// `parse_field_type` matched the twelve builtin scalar names and routed everything else
53/// to `FieldType::Object`, because it is a free function with no view of the document it
54/// is parsing a name from. So `status: OrderStatus`, where the same file declares
55/// `enum OrderStatus`, compiled to `{"Object": "OrderStatus"}` — and
56/// `FieldType::Enum`/`Interface`/`Union` had **no producer at all** for authored schemas;
57/// their only writers were the compiler's own synthesized cascade types.
58///
59/// Everything downstream branches on the variant:
60///
61/// * introspection (`field_resolver`) reports `TypeKind::Object` for an enum-typed field, so an
62///   introspection-driven client (Apollo, Relay, graphql-codegen) is told a scalar enum is an
63///   object with no fields;
64/// * the TypeScript client generator treats `Object` as requiring a nested selection and emits
65///   `status { … }` for a scalar value;
66/// * `--emit-ddl` maps `Object` to `JSONB` rather than to the enum's own Postgres type.
67///
68/// `#1018` is the same defect one variant over: a name declared in `custom_scalars` also
69/// fell through to `Object`, so `FieldType::Scalar` had no producer either.
70///
71/// Held by name rather than by definition because that is all resolution needs, and
72/// because it must be built *before* the type arrays are consumed by conversion.
73#[derive(Debug, Default)]
74pub(crate) struct DeclaredTypeNames {
75    objects:    HashSet<String>,
76    enums:      HashSet<String>,
77    interfaces: HashSet<String>,
78    unions:     HashSet<String>,
79    scalars:    HashSet<String>,
80}
81
82impl DeclaredTypeNames {
83    /// Collect the names an intermediate schema declares.
84    fn collect(schema: &IntermediateSchema) -> Self {
85        Self {
86            objects:    schema.types.iter().map(|t| t.name.clone()).collect(),
87            enums:      schema.enums.iter().map(|e| e.name.clone()).collect(),
88            interfaces: schema.interfaces.iter().map(|i| i.name.clone()).collect(),
89            unions:     schema.unions.iter().map(|u| u.name.clone()).collect(),
90            scalars:    schema.custom_scalars.iter().flatten().map(|s| s.name.clone()).collect(),
91        }
92    }
93
94    /// Resolve a non-builtin type name to the variant the rest of the codebase branches on.
95    ///
96    /// Falls back to `FieldType::Object` for a name declared nowhere. That never reaches an
97    /// artifact: `SchemaValidator` refuses an undeclared field type by name (#1530), and the
98    /// server refuses an artifact whose field names an undeclared object at load.
99    ///
100    /// A name declared in `custom_scalars` resolves to `FieldType::Scalar` (#1018). Without
101    /// it that variant had no producer for an authored schema — the #923 defect, one variant
102    /// over — and three consumers branched on the wrong answer: `--emit-ddl` emitted `JSONB`
103    /// where `TEXT` is correct, introspection reported `OBJECT` for a leaf, and the
104    /// TypeScript generator emitted a sub-selection for a scalar.
105    fn resolve(&self, name: &str) -> FieldType {
106        let owned = || name.to_string();
107        if self.objects.contains(name) {
108            // A declared type keeps its name, even one the engine also knows as a rich
109            // scalar (`Money`, `Image`, `Color`, …): the author declared it.
110            FieldType::Object(owned())
111        } else if self.enums.contains(name) {
112            FieldType::Enum(owned())
113        } else if self.interfaces.contains(name) {
114            FieldType::Interface(owned())
115        } else if self.unions.contains(name) {
116            FieldType::Union(owned())
117        } else if self.scalars.contains(name) || fraiseql_core::schema::is_known_scalar(name) {
118            // A declared custom scalar (#1018), or one of the engine's own rich scalars
119            // (#1530): `Email`, `Hostname`, … are scalars without anyone declaring them, and
120            // fell through to an object reference that was served without its value.
121            FieldType::Scalar(owned())
122        } else {
123            FieldType::Object(owned())
124        }
125    }
126}
127
128/// Optional, default-off behaviours for [`SchemaConverter::convert_with_options`].
129///
130/// Threaded here rather than on [`IntermediateSchema`] so adding an option never
131/// breaks the many full-literal `IntermediateSchema { .. }` constructions, and
132/// the plain [`SchemaConverter::convert`] keeps its historical signature.
133#[derive(Debug, Clone, Default)]
134pub struct ConvertOptions {
135    /// Auto-synthesize a shared `MutationError` type + per-mutation result unions
136    /// and rewrite object-returning mutations to those unions (`[fraiseql.mutations]
137    /// auto_error_union`).
138    pub auto_error_union: bool,
139}
140
141impl SchemaConverter {
142    /// Convert `IntermediateSchema` to `CompiledSchema` with default options.
143    ///
144    /// # Errors
145    ///
146    /// See [`SchemaConverter::convert_with_options`].
147    pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
148        Self::convert_with_options(intermediate, &ConvertOptions::default())
149    }
150
151    /// Convert `IntermediateSchema` into the whole compiled **artifact** — the
152    /// schema plus the platform sections written beside it (#1325).
153    ///
154    /// [`convert_with_options`](Self::convert_with_options) produces only the
155    /// `CompiledSchema`, which is what almost every caller wants. The sections that
156    /// are *siblings* of it in `schema.compiled.json` — currently `functions` — are
157    /// assembled here, so there is exactly one place where an authored section
158    /// becomes a compiled one and exactly one place a probe has to look.
159    ///
160    /// The `[functions]` TOML settings (`module_dir`, `dlq_store`) are paired with
161    /// the authored definitions here; the two halves have one owner each.
162    ///
163    /// # Errors
164    ///
165    /// See [`convert_with_options`](Self::convert_with_options).
166    pub fn convert_artifact(
167        mut intermediate: IntermediateSchema,
168        options: &ConvertOptions,
169    ) -> Result<CompiledArtifact> {
170        let functions = Self::take_functions_section(&mut intermediate)?;
171        let schema = Self::convert_with_options(intermediate, options)?;
172        // Unconditional, including when the project declares no function at all:
173        // a query whose `function = "<name>"` names nothing is exactly the case an
174        // `if let Some(functions)` here would wave through, and it is the one an
175        // author hits by deleting a function and forgetting the query (#1329).
176        functions::validate_against_schema(functions.as_ref(), &schema)?;
177        Ok(CompiledArtifact { schema, functions })
178    }
179
180    /// Pair the authored function declarations with their `[functions]` settings.
181    ///
182    /// Returns `None` when the project declares no functions and no enabled source, so
183    /// a schema that has never heard of them serialises exactly as it did before.
184    ///
185    /// # Errors
186    ///
187    /// Refuses a `[functions]` table when neither a function nor an enabled source would
188    /// use it.
189    /// There is nothing to apply the settings to, so carrying them would write a
190    /// section the server reads and cannot act on, and dropping them would be the
191    /// silent-default failure this whole seam exists to remove (#1008 refuses a
192    /// `storage` section on the same grounds).
193    fn take_functions_section(
194        intermediate: &mut IntermediateSchema,
195    ) -> Result<Option<FunctionsConfig>> {
196        let settings = intermediate.functions_config.take().unwrap_or_default();
197        // An empty list is absence, not an empty section, and two producers reach this.
198        // Some SDKs always emit the key and some omit it, so without this rule the same
199        // project compiled through two of them yields different artifacts. More
200        // importantly `seam::empty_accumulator` seeds EVERY array section with `[]`, so
201        // every TOML-workflow compile arrives here with an empty list whether or not the
202        // project has ever heard of functions — and an emitted section is one #1326
203        // refuses to boot on a build that cannot serve it.
204        let definitions = intermediate.functions.take().unwrap_or_default();
205        // An enabled source's connector is loaded from `module_dir` too (#1399), so a
206        // project whose only code is a connector still needs the section — with no
207        // definitions in it. Refusing the table here made the documented connector
208        // layout impossible to compile.
209        let runs_a_connector = intermediate.sources.iter().flatten().any(|source| source.enabled);
210        if definitions.is_empty() && !runs_a_connector {
211            anyhow::ensure!(
212                settings.module_dir.is_none() && settings.dlq_store.is_none(),
213                "the `[functions]` table configures the functions subsystem but the schema \
214                 declares no function and no enabled source, so nothing would use it. Declare \
215                 a function or a source, or remove the table."
216            );
217            return Ok(None);
218        }
219
220        Ok(Some(FunctionsConfig {
221            module_dir: settings
222                .module_dir
223                .unwrap_or_else(|| std::path::PathBuf::from(DEFAULT_FUNCTION_MODULE_DIR)),
224            definitions,
225            dlq_store: settings.dlq_store,
226        }))
227    }
228
229    /// Convert `IntermediateSchema` to `CompiledSchema`.
230    ///
231    /// This performs:
232    /// 1. Type conversion (intermediate types → compiled types)
233    /// 2. Field name normalization (type → `field_type`)
234    /// 3. Optional mutation-error-union synthesis (`options.auto_error_union`)
235    /// 4. Validation (type references, circular refs, etc.)
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if any type, query, mutation, interface, subscription,
240    /// or directive conversion fails, if federation/security/observer config JSON
241    /// cannot be deserialized, or if compiled schema validation detects unknown
242    /// type references.
243    pub fn convert_with_options(
244        intermediate: IntermediateSchema,
245        options: &ConvertOptions,
246    ) -> Result<CompiledSchema> {
247        info!("Converting intermediate schema to compiled format");
248
249        // Aggregate the per-type `@subscribable(tables=[...])` annotations (#366)
250        // into the top-level `CompiledSchema.subscribable` list BEFORE the types
251        // are consumed below. Empty `tables` is treated as "not subscribable".
252        let subscribable: Vec<fraiseql_core::schema::SubscribableEntity> = intermediate
253            .types
254            .iter()
255            .filter_map(|t| {
256                t.subscribable_tables
257                    .as_ref()
258                    .filter(|tables| !tables.is_empty())
259                    .map(|tables| fraiseql_core::schema::SubscribableEntity {
260                        entity_type: t.name.clone(),
261                        tables:      tables.clone(),
262                        pre_image:   t.subscribable_pre_image,
263                    })
264            })
265            .collect();
266
267        // Refuse a schema that declares observers, rather than validating them and throwing
268        // them away (#779).
269        //
270        // The `observers` block is emitted by the Python SDK's `@fraiseql.observer` (its
271        // shipped example declares five), it binds to `IntermediateSchema.observers`, and
272        // `SchemaValidator` spends ~220 lines checking names, entities, events, actions and
273        // retry policy — a typo in any of them fails the compile, which tells the author
274        // emphatically that the block is honoured. It was not: this function set
275        // `observers: Vec::new()` under a comment claiming they were "populated from
276        // IntermediateSchema", nothing else ever wrote the field, and no webhook, Slack
277        // message or email ever fired for any declared event.
278        //
279        // The runtime loads observers exclusively from `tb_observer` / the admin API and
280        // reads nothing from the compiled schema. So *carrying* them here would only move
281        // the silent drop one layer down and produce a compiled artifact whose `observers`
282        // array is decoration. Until a runtime consumer exists, the honest outcome is to
283        // fail and name the mechanism that does work.
284        if let Some(observers) = intermediate.observers.as_ref().filter(|o| !o.is_empty()) {
285            let names: Vec<&str> = observers.iter().map(|o| o.name.as_str()).collect();
286            anyhow::bail!(
287                "This schema declares {} observer(s) ({}), but declared observers are not \
288                 loaded by the runtime — it reads them from the `tb_observer` table and the \
289                 admin API only, so compiling them would produce a schema whose observers \
290                 never fire.\n\nDefine them in `tb_observer`, or register them at runtime \
291                 with `POST /api/observers`, and remove the `observers` block from the \
292                 authored schema.",
293                observers.len(),
294                names.join(", ")
295            );
296        }
297
298        // Refuse a schema that declares `aggregate_queries`, rather than carrying it through
299        // the seam and dropping it (#956).
300        //
301        // `aggregate_queries` is listed in `AUTHORABLE_ARRAY_SECTIONS`, so it is valid input
302        // on every compile path and every loader and merger carries it faithfully — and then
303        // this converter maps `fact_tables` and never reads it. `CompiledSchema` has no
304        // corresponding field, so the definitions reach the end of the seam and evaporate
305        // under a `✓ Schema compiled successfully`. That is the #755 shape surviving inside
306        // the very seam built to kill it.
307        //
308        // #624 gave `[[analytics.queries]]` real semantics by lowering each entry onto an
309        // ordinary `QueryDefinition`, so the capability exists and has a supported spelling.
310        // Refusing here — the `#779` disposition — names it, instead of accepting a second
311        // spelling that does nothing.
312        if let Some(aggregates) = intermediate.aggregate_queries.as_ref().filter(|a| !a.is_empty())
313        {
314            let names: Vec<&str> = aggregates.iter().map(|a| a.name.as_str()).collect();
315            anyhow::bail!(
316                "This schema declares {} aggregate_queries entry/entries ({}), which no \
317                 compile path lowers into the compiled schema — `CompiledSchema` has no \
318                 field for them, so they would vanish silently.\n\nDeclare them as \
319                 `[[analytics.queries]]` in fraiseql.toml instead (#624): each entry \
320                 becomes a real list-returning, view-backed query. Then remove the \
321                 `aggregate_queries` block from the authored schema.",
322                aggregates.len(),
323                names.join(", ")
324            );
325        }
326
327        // #923: collect the declared enum / interface / union names BEFORE the arrays are
328        // consumed below, so a field or argument naming one resolves to that kind instead of
329        // to a phantom `FieldType::Object`.
330        let declared = DeclaredTypeNames::collect(&intermediate);
331
332        // Split the authored `types` array on `is_input` before converting anything (#848).
333        //
334        // A type marked `is_input` is an input object that four SDKs happen to declare in
335        // the `types` array — Elixir's exporter emits no `input_types` key at all, so this
336        // is its only route. Routing it here rather than converting it as an object type is
337        // what keeps the compiled schema GraphQL-legal: an argument may only be typed with
338        // an input type (§3.10).
339        let (input_marked_types, mut object_types): (Vec<_>, Vec<_>) =
340            intermediate.types.into_iter().partition(|t| t.is_input);
341        let inaccessible_fields = Self::take_inaccessible_fields(&mut object_types);
342
343        let types = object_types
344            .into_iter()
345            .map(|t| Self::convert_type(t, &declared))
346            .collect::<Result<Vec<_>>>()
347            .context("Failed to convert types")?;
348
349        // Extract query_defaults before consuming intermediate.queries.
350        // unwrap_or_default() → all-true, matching historical behaviour when no
351        // [query_defaults] section is present in fraiseql.toml.
352        let defaults = intermediate.query_defaults.unwrap_or_default();
353
354        // Apply `[inject_defaults]` (#847) to any operation that has not named the
355        // parameter itself.
356        //
357        // This runs here, in the converter, rather than in `commands::compile` — the
358        // converter is the single path every caller of the public API goes through, so a
359        // default cannot be lost by reaching the compiled schema some other way.
360        //
361        // It is also deliberately *after* `[fraiseql.tenancy]` validation (which
362        // `commands::compile` performs on the intermediate schema before calling this).
363        // Tenancy auto-injects when `inject_params` is empty and errors when it is
364        // non-empty but lacks the annotated field, so applying defaults first would make a
365        // single unrelated default break every tenancy-annotated operation.
366        let inject_defaults = intermediate.inject_defaults.unwrap_or_default();
367        let query_defaults_inject = inject_defaults.for_queries();
368        let mutation_defaults_inject = inject_defaults.for_mutations();
369
370        // Convert queries. A query that opted into `count` emits a second,
371        // derived `<name>Count` definition (#938) — built by `count_sibling` so
372        // its tenancy, role and filter inheritance has exactly one definition.
373        let mut queries: Vec<fraiseql_core::schema::QueryDefinition> =
374            Vec::with_capacity(intermediate.queries.len());
375        for mut q in intermediate.queries {
376            IntermediateInjectDefaults::apply_to(
377                &query_defaults_inject,
378                &mut q.inject,
379                &q.exclude_inject_defaults,
380                &q.name,
381            )?;
382            let wants_count = q.count;
383            let converted = Self::convert_query(q, &defaults, &declared)
384                .context("Failed to convert queries")?;
385            let sibling = wants_count.then(|| converted.count_sibling());
386            queries.push(converted);
387            queries.extend(sibling);
388        }
389
390        // A generated sibling must not silently displace an author's own query of
391        // the same name — whichever won, the other would vanish from the schema
392        // with no diagnostic.
393        let mut seen: HashSet<&str> = HashSet::new();
394        for q in &queries {
395            if !seen.insert(q.name.as_str()) {
396                anyhow::bail!(
397                    "Query name '{}' is declared twice. A `count = true` query generates \
398                     '<name>Count'; rename the conflicting query or drop count=true.",
399                    q.name
400                );
401            }
402        }
403
404        // Convert mutations
405        let mutations = intermediate
406            .mutations
407            .into_iter()
408            .map(|mut m| {
409                IntermediateInjectDefaults::apply_to(
410                    &mutation_defaults_inject,
411                    &mut m.inject,
412                    &m.exclude_inject_defaults,
413                    &m.name,
414                )?;
415                Self::convert_mutation(m, &declared)
416            })
417            .collect::<Result<Vec<_>>>()
418            .context("Failed to convert mutations")?;
419
420        // Convert enums
421        let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
422
423        // Convert input types — the `input_types` array plus the `is_input`-marked entries
424        // lifted out of `types` above. Both routes land in one registry, so a duplicate
425        // between them is an authoring conflict and is refused rather than shadowed.
426        let mut input_objects = intermediate.input_types;
427        for marked in input_marked_types {
428            let lifted = Self::input_object_from_marked_type(marked)?;
429            if input_objects.iter().any(|existing| existing.name == lifted.name) {
430                anyhow::bail!(
431                    "'{}' is declared both in `input_types` and as a type marked \
432                     `is_input: true`. Keep exactly one declaration — two definitions of one \
433                     input object cannot be reconciled, and whichever won would depend on \
434                     compile order.",
435                    lifted.name
436                );
437            }
438            input_objects.push(lifted);
439        }
440        let input_types =
441            input_objects.into_iter().map(Self::convert_input_object).collect::<Vec<_>>();
442
443        // Convert interfaces
444        let interfaces = intermediate
445            .interfaces
446            .into_iter()
447            .map(|i| Self::convert_interface(i, &declared))
448            .collect::<Result<Vec<_>>>()
449            .context("Failed to convert interfaces")?;
450
451        // Convert unions
452        let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
453
454        // Convert subscriptions
455        let subscriptions = intermediate
456            .subscriptions
457            .into_iter()
458            .map(|s| Self::convert_subscription(s, &declared))
459            .collect::<Result<Vec<_>>>()
460            .context("Failed to convert subscriptions")?;
461
462        // Convert custom directives
463        let directives = intermediate
464            .directives
465            .unwrap_or_default()
466            .into_iter()
467            .map(|d| Self::convert_directive(d, &declared))
468            .collect::<Result<Vec<_>>>()
469            .context("Failed to convert directives")?;
470
471        // Convert fact tables from Vec<IntermediateFactTable> to HashMap<String, FactTableMetadata>
472        let fact_tables = intermediate
473            .fact_tables
474            .unwrap_or_default()
475            .into_iter()
476            .map(|ft| Ok((ft.table_name.clone(), Self::convert_fact_table(ft)?)))
477            .collect::<Result<_>>()?;
478
479        let mut compiled = CompiledSchema {
480            types,
481            enums,
482            input_types,
483            interfaces,
484            unions,
485            queries,
486            mutations,
487            subscriptions,
488            directives,
489            fact_tables, // Analytics metadata
490            // Refused above when non-empty (#779); an empty vector is the only reachable
491            // value, so this is not a drop.
492            observers: Vec::new(),
493            sources: intermediate.sources.clone().unwrap_or_default(), /* #573 scheduled ingress
494                                                                        * sources */
495            subscribable, // @subscribable capture-trigger declarations (#366)
496            federation: Self::with_inaccessible_fields(
497                intermediate
498                    .federation_config
499                    .map(serde_json::from_value)
500                    .transpose()
501                    .context("federation_config: invalid JSON structure")?,
502                inaccessible_fields,
503            )?,
504            security: intermediate
505                .security
506                .map(serde_json::from_value)
507                .transpose()
508                .context("security: invalid JSON structure")?,
509            auth: intermediate
510                .auth
511                .map(serde_json::from_value)
512                .transpose()
513                .context("auth: invalid PKCE OAuth-client structure (#621)")?,
514            observers_config: intermediate
515                .observers_config
516                .map(|mut oc| {
517                    // #631: `handlers` reaching this seam (SDK-authored
518                    // `observers_config.handlers`, bypassing the TOML
519                    // validator's #612 bail) must fail the compile, not land in
520                    // the compiled schema as decoration the runtime never
521                    // reads. tb_observer / the admin API is the single observer
522                    // concept. An EMPTY array is inert boilerplate some
523                    // exporters emit — strip it so `deny_unknown_fields` on the
524                    // compiled type does not reject it.
525                    match oc.get("handlers").and_then(serde_json::Value::as_array) {
526                        Some(handlers) if !handlers.is_empty() => {
527                            anyhow::bail!(
528                                "observers_config declares {} handler(s), but compiled \
529                                 handlers are not a runtime concept (#631): the runtime \
530                                 loads observers only from the `tb_observer` table and the \
531                                 admin API. Define them in `tb_observer` or via \
532                                 `POST /api/observers`, and remove `handlers` from the \
533                                 authored schema.",
534                                handlers.len()
535                            );
536                        },
537                        Some(_) => {
538                            if let Some(map) = oc.as_object_mut() {
539                                map.remove("handlers");
540                            }
541                        },
542                        None => {},
543                    }
544                    if oc.get("redis_url").is_some() {
545                        anyhow::bail!(
546                            "{}",
547                            crate::config::toml_schema::observers::REDIS_URL_REFUSED
548                        );
549                    }
550                    serde_json::from_value(oc).context("observers_config: invalid JSON structure")
551                })
552                .transpose()?,
553            subscriptions_config: intermediate.subscriptions_config, /* Subscriptions config from
554                                                                      * TOML */
555            validation_config: intermediate.validation_config, // Validation limits from TOML
556            debug_config: intermediate.debug_config,           // Debug config from TOML
557            mcp_config: intermediate.mcp_config,               // MCP config from TOML
558            rest_config: intermediate.rest_config,             // REST config from TOML
559            grpc_config: intermediate.grpc_config,             // gRPC config from TOML (#780)
560            naming_convention: intermediate.naming_convention, // Naming convention from TOML
561            session_variables: intermediate.session_variables.unwrap_or_default(),
562            locale: intermediate.locale,
563            hierarchies_config: intermediate.hierarchies_config,
564            changelog: intermediate.changelog_config, // Changelog exposure config from TOML
565            schema_sdl: None,                         // Raw GraphQL SDL
566            ..Default::default()
567        };
568
569        // Populate custom scalars from intermediate schema
570        if let Some(custom_scalars_vec) = intermediate.custom_scalars {
571            for scalar_def in custom_scalars_vec {
572                let custom_type = Self::convert_custom_scalar(scalar_def)?;
573                compiled
574                    .derived
575                    .custom_scalars
576                    .register(custom_type.name.clone(), custom_type)
577                    .context("Failed to register custom scalar")?;
578            }
579        }
580
581        // Changelog exposure requires the observer system: the views it exposes read
582        // from tables (`tb_entity_change_log`, `tb_transport_checkpoint`) that the
583        // observer install convention supplies. Emitting GraphQL types over absent
584        // tables would only fail later, at runtime — reject it here instead.
585        if let Some(ref cl) = compiled.changelog {
586            if cl.expose && !compiled.observers_config.as_ref().is_some_and(|o| o.enabled) {
587                anyhow::bail!(
588                    "[changelog] expose = true requires [observers] to be enabled: the tables \
589                     it exposes (tb_entity_change_log, tb_transport_checkpoint) are installed by \
590                     the observer system."
591                );
592            }
593            // #497: the change-log surface is a per-database stream, not a federated
594            // value type — the injected `EntityChangeLog` type and `entity_change_logs`
595            // root query are not `@shareable`. Two subgraphs in one supergraph that both
596            // `expose` it inject the identical type/root field, which `rover supergraph
597            // compose` rejects with INVALID_FIELD_SHARING. We can't detect the collision
598            // here (each subgraph compiles alone), so warn on the federated+exposed combo
599            // and point at the single-owner pattern. This is a reminder, not an error —
600            // the owning subgraph legitimately sets both.
601            if cl.expose && compiled.federation.as_ref().is_some_and(|f| f.enabled) {
602                warn!(
603                    "[changelog] expose = true on a federation subgraph: EntityChangeLog / \
604                     entity_change_logs are not @shareable, so expose the change-log in exactly \
605                     one subgraph per supergraph (others keep capturing via [changelog] \
606                     write_enabled). Two exposing subgraphs fail `rover supergraph compose` with \
607                     INVALID_FIELD_SHARING. See the changelog-graphql guide for the single-owner \
608                     pattern."
609                );
610            }
611        }
612
613        // Canonicalize the Trinity external identity (`id: UUID` → `id: ID`, ADR-0017)
614        // before the interface-forcing passes, so Relay `Node` / `CascadeNode` see a
615        // conformant `id: ID` on every entity. Wire-transparent (a UUID is an `ID`).
616        identity::normalize_entity_identity(&mut compiled);
617
618        // Inject synthetic Relay types (PageInfo, Node interface, XxxConnection, XxxEdge).
619        relay::inject_relay_types(&mut compiled)?;
620
621        // Inject the changelog GraphQL surface (EntityChangeLog / TransportCheckpoint
622        // types + cursor query + point lookup + upsert mutation) when opted in.
623        fraiseql_core::schema::inject_changelog(&mut compiled);
624
625        // Synthesize the typed cascade surface (CascadeNode interface + envelope
626        // types + per-mutation `<Name>Payload` wrappers) for mutations that opt in
627        // via `cascade = true`. Runs BEFORE error-union synthesis so a cascade
628        // payload becomes the success member of the result union
629        // (`<Name>Result = <Name>Payload | MutationError`). Inert with no cascade
630        // mutations.
631        cascade_types::synthesize_cascade_types(&mut compiled)?;
632
633        // Auto-synthesize a shared MutationError type + per-mutation result unions
634        // when opted in (`[fraiseql.mutations] auto_error_union`), so the runtime's
635        // success/error discrimination has a union to resolve against.
636        if options.auto_error_union {
637            mutation_error_union::synthesize_mutation_error_unions(&mut compiled);
638        }
639
640        // IR honesty (#456): `parse_field_type` has no `Input` variant, so an
641        // argument referencing a declared input type was resolved to
642        // `FieldType::Object`. Rewrite those argument types to `FieldType::Input`
643        // now that every input type is registered (after relay / rich-filter /
644        // changelog / error-union injection) so the IR honestly represents the
645        // GraphQL input position — which also makes introspection report the correct
646        // `INPUT_OBJECT` kind for those args. The runtime already accepts both
647        // shapes, so this is purely representational.
648        Self::promote_input_type_args(&mut compiled);
649
650        // Validate the compiled schema
651        Self::validate(&compiled)?;
652
653        info!("Schema conversion successful");
654        Ok(compiled)
655    }
656
657    /// Merge the `@inaccessible` fields lifted off the authored types into the
658    /// compiled `federation` block.
659    ///
660    /// # Errors
661    ///
662    /// Refuses `@inaccessible` in a schema with no enabled `federation` section: the
663    /// directive hides a field from a supergraph, and without one it would hide nothing
664    /// while reading as if it did.
665    fn with_inaccessible_fields(
666        federation: Option<fraiseql_core::schema::FederationConfig>,
667        hidden: std::collections::BTreeMap<String, Vec<String>>,
668    ) -> Result<Option<fraiseql_core::schema::FederationConfig>> {
669        if hidden.is_empty() {
670            return Ok(federation);
671        }
672        let Some(mut federation) = federation.filter(|f| f.enabled) else {
673            let named: Vec<String> = hidden
674                .iter()
675                .flat_map(|(ty, fields)| fields.iter().map(move |f| format!("{ty}.{f}")))
676                .collect();
677            anyhow::bail!(
678                "`@inaccessible` on {} needs federation: it hides a field from the \
679                 supergraph, and this schema has no enabled `federation` section, so the \
680                 field would stay visible. Enable federation, or remove the directive and \
681                 the field.",
682                named.join(", ")
683            );
684        };
685        for (ty, fields) in hidden {
686            federation.inaccessible_fields.entry(ty).or_default().extend(fields);
687        }
688        Ok(Some(federation))
689    }
690
691    /// Rewrite mutation/query argument types of the form `Object(name)` (and lists
692    /// thereof) to `Input(name)` when `name` is a registered input type, so the IR
693    /// honestly represents input-position references (#456). Input type names are
694    /// disjoint from output type names per the GraphQL spec, so the lookup is exact
695    /// — an output-object argument is never misclassified.
696    fn promote_input_type_args(schema: &mut CompiledSchema) {
697        fn promote(ft: &mut FieldType, input_names: &HashSet<String>) {
698            match ft {
699                FieldType::Object(name) if input_names.contains(name) => {
700                    *ft = FieldType::Input(name.clone());
701                },
702                FieldType::List { item: inner, .. } => promote(inner, input_names),
703                _ => {},
704            }
705        }
706
707        let input_names: HashSet<String> =
708            schema.input_types.iter().map(|i| i.name.clone()).collect();
709        if input_names.is_empty() {
710            return;
711        }
712
713        for mutation in &mut schema.mutations {
714            for arg in &mut mutation.arguments {
715                promote(&mut arg.arg_type, &input_names);
716            }
717        }
718        for query in &mut schema.queries {
719            for arg in &mut query.arguments {
720                promote(&mut arg.arg_type, &input_names);
721            }
722        }
723    }
724
725    #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many field-level checks
726    fn validate(schema: &CompiledSchema) -> Result<()> {
727        info!("Validating compiled schema");
728
729        // Build type registry
730        let mut type_names: HashSet<String> = HashSet::new();
731        for type_def in &schema.types {
732            type_names.insert(type_def.name.to_string());
733        }
734
735        // Build interface registry. Interfaces are *also* valid query/mutation
736        // return types (a field may return an interface, narrowed via inline
737        // fragments), so register them in `type_names` too — previously omitted,
738        // which made a query/mutation returning an interface silently fail the
739        // reference check below.
740        let mut interface_names = HashSet::new();
741        for interface_def in &schema.interfaces {
742            interface_names.insert(interface_def.name.clone());
743            type_names.insert(interface_def.name.clone());
744        }
745
746        // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
747        for input_type in &schema.input_types {
748            type_names.insert(input_type.name.clone());
749        }
750
751        // Add union type names — valid as mutation/query return types
752        for union_def in &schema.unions {
753            type_names.insert(union_def.name.clone());
754        }
755
756        // Add enum type names — valid as field and argument types
757        for enum_def in &schema.enums {
758            type_names.insert(enum_def.name.clone());
759        }
760
761        // Add built-in scalars
762        for scalar in crate::schema::builtin_scalar_names() {
763            type_names.insert(scalar.to_string());
764        }
765
766        // Add **declared** custom scalars, from the registry the compiler actually carries.
767        //
768        // This used to register every type name appearing in any object field, which made a
769        // field-type typo legalize itself as a return type too (#724 item 2) — the same
770        // blindness as `SchemaValidator`, duplicated here.
771        for (name, _) in schema.derived.custom_scalars.list_all() {
772            type_names.insert(name);
773        }
774
775        // Collect every problem, then report them together.
776        //
777        // This tier used to `bail!` on the first error, with no suggestion, after redundantly
778        // `warn!`ing the same text (#724 item 4). Errors that only surface after synthesis —
779        // relay, cascade, changelog-injected types — are validated *here*, so the same class
780        // of mistake got a materially worse experience depending on which tier caught it, and
781        // a user with three typos needed three compile cycles for information the compiler
782        // had in hand on the first.
783        let mut problems: Vec<String> = Vec::new();
784        let mut known: Vec<&str> = type_names.iter().map(String::as_str).collect();
785        known.sort_unstable();
786
787        let describe = |name: &str| -> String {
788            let similar = fraiseql_core::runtime::suggest_similar(name, &known);
789            if similar.is_empty() {
790                String::new()
791            } else {
792                format!(" (did you mean: {}?)", similar.join(", "))
793            }
794        };
795
796        // Validate queries
797        for query in &schema.queries {
798            if !type_names.contains(&query.return_type) {
799                problems.push(format!(
800                    "Query '{}' references unknown type '{}'{}",
801                    query.name,
802                    query.return_type,
803                    describe(&query.return_type)
804                ));
805            }
806
807            for arg in &query.arguments {
808                let type_name = Self::extract_type_name(&arg.arg_type);
809                if !type_names.contains(&type_name) {
810                    problems.push(format!(
811                        "Query '{}' argument '{}' references unknown type '{}'{}",
812                        query.name,
813                        arg.name,
814                        type_name,
815                        describe(&type_name)
816                    ));
817                }
818            }
819        }
820
821        // Validate mutations
822        for mutation in &schema.mutations {
823            if !type_names.contains(&mutation.return_type) {
824                problems.push(format!(
825                    "Mutation '{}' references unknown type '{}'{}",
826                    mutation.name,
827                    mutation.return_type,
828                    describe(&mutation.return_type)
829                ));
830            }
831
832            for arg in &mutation.arguments {
833                let type_name = Self::extract_type_name(&arg.arg_type);
834                if !type_names.contains(&type_name) {
835                    problems.push(format!(
836                        "Mutation '{}' argument '{}' references unknown type '{}'{}",
837                        mutation.name,
838                        arg.name,
839                        type_name,
840                        describe(&type_name)
841                    ));
842                }
843            }
844        }
845
846        // Validate interface implementations
847        for type_def in &schema.types {
848            for interface_name in &type_def.implements {
849                if !interface_names.contains(interface_name) {
850                    let mut candidates: Vec<&str> =
851                        interface_names.iter().map(String::as_str).collect();
852                    candidates.sort_unstable();
853                    let similar =
854                        fraiseql_core::runtime::suggest_similar(interface_name, &candidates);
855                    let hint = if similar.is_empty() {
856                        String::new()
857                    } else {
858                        format!(" (did you mean: {}?)", similar.join(", "))
859                    };
860                    problems.push(format!(
861                        "Type '{}' implements unknown interface '{interface_name}'{hint}",
862                        type_def.name
863                    ));
864                    continue;
865                }
866
867                // Validate that the type has all fields required by the interface
868                if let Some(interface) = schema.find_interface(interface_name) {
869                    for interface_field in &interface.fields {
870                        let type_has_field = type_def.fields.iter().any(|f| {
871                            f.name == interface_field.name
872                                && f.field_type == interface_field.field_type
873                        });
874                        if !type_has_field {
875                            problems.push(format!(
876                                "Type '{}' implements interface '{interface_name}' but is \
877                                 missing field '{}'",
878                                type_def.name, interface_field.name
879                            ));
880                        }
881                    }
882                }
883            }
884        }
885
886        // Relationships the REST embed executor could not follow (#1266) — an undeclared
887        // target, a join column no field publishes, a target no list query returns.
888        //
889        // Borrowed wholesale from the load-time check rather than restated here: the two
890        // must agree, and the way they stop agreeing is one of them being taught a rule
891        // the other is not. `CompiledSchema::finish_load` runs the same function, which
892        // is what covers the hand-edited artifact this compile-time refusal cannot reach.
893        problems.extend(schema.relationship_violations());
894
895        // Federation `@key`s the router would refuse (#1395) — the same function the load
896        // path runs, for the same reason as the relationship check above.
897        problems.extend(schema.federation_key_violations());
898
899        if !problems.is_empty() {
900            let count = problems.len();
901            let noun = if count == 1 { "problem" } else { "problems" };
902            anyhow::bail!(
903                "Schema validation found {count} {noun}:\n{}",
904                problems.iter().map(|p| format!("  - {p}")).collect::<Vec<_>>().join("\n")
905            );
906        }
907
908        info!("Schema validation passed");
909        Ok(())
910    }
911
912    /// Extract type name from `FieldType` for validation
913    ///
914    /// Built-in types return their scalar name, Object types return the object name
915    fn extract_type_name(field_type: &FieldType) -> String {
916        // A built-in scalar's name is the one the author wrote, from the same
917        // table `parse_field_type` reads — this is that function's inverse.
918        if let Some(name) = field_type.authoring_name() {
919            return name.to_string();
920        }
921        match field_type {
922            FieldType::Scalar(name) => name.clone(),
923            FieldType::Object(name) => name.clone(),
924            FieldType::Enum(name) => name.clone(),
925            FieldType::Input(name) => name.clone(),
926            FieldType::Interface(name) => name.clone(),
927            FieldType::Union(name) => name.clone(),
928            FieldType::List { item: inner, .. } => Self::extract_type_name(inner),
929            // Reason: non_exhaustive requires catch-all for cross-crate matches
930            _ => "Unknown".to_string(),
931        }
932    }
933
934    /// Convert `IntermediateFactTable` to `FactTableMetadata`.
935    ///
936    /// # Errors
937    ///
938    /// Returns an error for an inject source that is not `jwt:<claim>` (#1800): the table's
939    /// `inject_params` filter rows, which an absent claim cannot.
940    pub(crate) fn convert_fact_table(ft: IntermediateFactTable) -> Result<FactTableMetadata> {
941        let owner = format!("Fact table '{}'", ft.table_name);
942        let inject_params = ft
943            .inject_params
944            .into_iter()
945            .map(|(column, source)| {
946                let parsed = Self::parse_inject_source(&source)
947                    .with_context(|| format!("{owner}: inject param '{column}'"))?;
948                Self::refuse_optional_row_filter(&owner, &column, &parsed)?;
949                Ok((column, parsed))
950            })
951            .collect::<Result<indexmap::IndexMap<_, _>>>()?;
952        Ok(FactTableMetadata {
953            table_name: ft.table_name,
954            type_name: ft.type_name,
955            row_scope: ft.row_scope,
956            inject_params,
957            measures: ft
958                .measures
959                .into_iter()
960                .map(|m| MeasureColumn {
961                    name:       m.name,
962                    sql_type:   Self::parse_sql_type(&m.sql_type),
963                    nullable:   m.nullable,
964                    additivity: m.additivity,
965                })
966                .collect(),
967            dimensions: DimensionColumn {
968                name:  ft.dimensions.name,
969                paths: ft
970                    .dimensions
971                    .paths
972                    .into_iter()
973                    .map(|p| DimensionPath {
974                        name:      p.name,
975                        json_path: p.json_path,
976                        data_type: p.data_type,
977                    })
978                    .collect(),
979            },
980            denormalized_filters: ft
981                .denormalized_filters
982                .into_iter()
983                .map(|f| FilterColumn {
984                    name:      f.name,
985                    sql_type:  Self::parse_sql_type(&f.sql_type),
986                    indexed:   f.indexed,
987                    hierarchy: f.hierarchy,
988                })
989                .collect(),
990            calendar_dimensions: vec![],
991            native_measures: ft.native_measures,
992            native_dimension_mapping: ft.native_dimension_mapping,
993        })
994    }
995
996    /// Parse a SQL type string into a `SqlType` enum variant.
997    fn parse_sql_type(s: &str) -> SqlType {
998        match s.to_uppercase().as_str() {
999            "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
1000            "BIGINT" | "INT8" => SqlType::BigInt,
1001            "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
1002            "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
1003                SqlType::Float
1004            },
1005            "JSONB" => SqlType::Jsonb,
1006            "JSON" => SqlType::Json,
1007            "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
1008            "UUID" => SqlType::Uuid,
1009            "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
1010                SqlType::Timestamp
1011            },
1012            "DATE" => SqlType::Date,
1013            "BOOLEAN" | "BOOL" => SqlType::Boolean,
1014            "LTREE" => SqlType::Ltree,
1015            _ => SqlType::Other(s.to_string()),
1016        }
1017    }
1018}
1019
1020/// Parse an authored `requires_actor` list into [`ActorType`]s (#966).
1021///
1022/// One parser for queries and mutations, so the two cannot disagree about which
1023/// tokens exist.
1024///
1025/// # Why an unknown token is an error
1026///
1027/// `requires_actor` is an allow-list, so a dropped entry does not open the
1028/// operation — it *closes* it further, and a typo'd `"human"` would refuse every
1029/// human user while the schema reads as though it admits them. And a whole list
1030/// that failed to parse would leave the field empty, which means *unrestricted*:
1031/// the failure mode of silence is a security gate that is not there. Naming the
1032/// bad token at compile time is the only reading where the author finds out.
1033///
1034/// # Errors
1035///
1036/// Returns an error naming the offending token and listing the valid ones.
1037pub(crate) fn parse_requires_actor(
1038    operation_kind: &str,
1039    operation_name: &str,
1040    tokens: &[String],
1041) -> Result<Vec<fraiseql_core::security::ActorType>> {
1042    use fraiseql_core::security::ActorType;
1043
1044    tokens
1045        .iter()
1046        .map(|token| {
1047            ActorType::from_token(token).ok_or_else(|| {
1048                anyhow::anyhow!(
1049                    "{operation_kind} '{operation_name}': requires_actor entry '{token}' is not a \
1050                     known actor type. Valid values: {}",
1051                    ActorType::ALL.map(ActorType::as_str).join(", ")
1052                )
1053            })
1054        })
1055        .collect()
1056}