use std::path::{Path, PathBuf};
use fraiseql_core::schema::CompiledSchema;
use tracing::{debug, info, warn};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SchemaLoadError {
#[error("Schema file not found: {0}")]
NotFound(PathBuf),
#[error("Failed to read schema file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse schema JSON: {0}")]
ParseError(#[from] serde_json::Error),
#[error("Invalid schema: {0}")]
ValidationError(String),
}
pub use fraiseql_functions::FunctionsConfig;
#[derive(Debug)]
pub struct ExtendedCompiledSchema {
pub schema: CompiledSchema,
pub functions: Option<FunctionsConfig>,
}
#[derive(Debug, Clone)]
pub struct CompiledSchemaLoader {
path: PathBuf,
}
impl CompiledSchemaLoader {
#[must_use]
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Self {
path: path.as_ref().to_path_buf(),
}
}
pub async fn load(&self) -> Result<CompiledSchema, SchemaLoadError> {
info!(path = %self.path.display(), "Loading compiled schema");
if !self.path.exists() {
return Err(SchemaLoadError::NotFound(self.path.clone()));
}
let contents =
tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
debug!(
path = %self.path.display(),
size_bytes = contents.len(),
"Schema file read successfully"
);
serde_json::from_str::<serde_json::Value>(&contents)?;
let schema = CompiledSchema::from_json(&contents, false)
.map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
info!(path = %self.path.display(), "Schema loaded successfully");
Ok(schema)
}
pub async fn load_extended(&self) -> Result<ExtendedCompiledSchema, SchemaLoadError> {
info!(path = %self.path.display(), "Loading extended compiled schema");
if !self.path.exists() {
return Err(SchemaLoadError::NotFound(self.path.clone()));
}
let contents =
tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
debug!(
path = %self.path.display(),
size_bytes = contents.len(),
"Schema file read for extended loading"
);
let raw: serde_json::Value = serde_json::from_str(&contents)?;
let schema = CompiledSchema::from_json(&contents, false)
.map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
if raw.get("storage").is_some_and(|v| !v.is_null()) {
return Err(SchemaLoadError::ValidationError(
"the compiled schema declares a `storage` section, which no part of the \
server reads: the storage backend is built from `[storage]` in the server \
config file (or its FRAISEQL_STORAGE_* environment overrides). Move the \
bucket configuration there and remove this section, which would otherwise \
be silently dropped at boot."
.to_string(),
));
}
let functions = raw
.get("functions")
.filter(|v| !v.is_null())
.map(|v| {
let cfg: FunctionsConfig = serde_json::from_value(v.clone())?;
validate_functions_config(&cfg)?;
Ok::<_, SchemaLoadError>(cfg)
})
.transpose()?;
if raw.get("realtime").is_some_and(|v| !v.is_null()) {
warn!(
path = %self.path.display(),
"compiled-schema `realtime` section is no longer supported and is ignored; \
recompile with the current fraiseql-cli"
);
}
validate_query_bindings(&schema, functions.as_ref())?;
refuse_unservable_sections(&raw, &gated_sections())?;
info!(
path = %self.path.display(),
has_functions = functions.is_some(),
"Extended schema loaded successfully"
);
Ok(ExtendedCompiledSchema { schema, functions })
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Activity {
EnabledFlag,
NonEmptyDefinitions,
AnyEntryEnabled,
}
impl Activity {
fn is_active(self, value: &serde_json::Value) -> bool {
match self {
Activity::EnabledFlag => {
value.get("enabled").and_then(serde_json::Value::as_bool).unwrap_or(false)
},
Activity::NonEmptyDefinitions => value
.get("definitions")
.and_then(|d| d.as_array())
.is_some_and(|d| !d.is_empty()),
Activity::AnyEntryEnabled => value.as_array().is_some_and(|entries| {
entries
.iter()
.any(|e| e.get("enabled").and_then(serde_json::Value::as_bool).unwrap_or(false))
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GatedSection {
pub name: &'static str,
pub feature: &'static str,
pub compiled_in: bool,
pub remedy: &'static str,
pub activity: Activity,
}
pub(crate) fn gated_sections() -> Vec<GatedSection> {
vec![
GatedSection {
name: "functions",
feature: "functions-runtime",
compiled_in: cfg!(feature = "functions-runtime"),
remedy: "pull the `-platform` image tag, or rebuild with \
`--features functions-runtime`",
activity: Activity::NonEmptyDefinitions,
},
GatedSection {
name: "sources",
feature: "sources",
compiled_in: cfg!(feature = "sources"),
remedy: "pull the `-platform` image tag, or rebuild with \
`--features sources`",
activity: Activity::AnyEntryEnabled,
},
GatedSection {
name: "mcp_config",
feature: "mcp",
compiled_in: cfg!(feature = "mcp"),
remedy: "rebuild with `--features mcp`",
activity: Activity::EnabledFlag,
},
GatedSection {
name: "rest_config",
feature: "rest",
compiled_in: cfg!(feature = "rest"),
remedy: "rebuild with `--features rest`",
activity: Activity::EnabledFlag,
},
GatedSection {
name: "grpc_config",
feature: "grpc",
compiled_in: cfg!(feature = "grpc"),
remedy: "rebuild with `--features grpc`",
activity: Activity::EnabledFlag,
},
GatedSection {
name: "federation",
feature: "federation",
compiled_in: cfg!(feature = "federation"),
remedy: "rebuild with `--features federation`",
activity: Activity::EnabledFlag,
},
GatedSection {
name: "observers_config",
feature: "observers",
compiled_in: cfg!(feature = "observers"),
remedy: "rebuild with `--features observers`",
activity: Activity::EnabledFlag,
},
]
}
pub(crate) fn refuse_unservable_sections(
raw: &serde_json::Value,
sections: &[GatedSection],
) -> Result<(), SchemaLoadError> {
for section in sections {
if section.compiled_in {
continue;
}
let Some(value) = raw.get(section.name).filter(|v| !v.is_null()) else {
continue;
};
if !section.activity.is_active(value) {
continue;
}
return Err(SchemaLoadError::ValidationError(format!(
"the compiled schema declares a `{name}` section, but this build was compiled \
without the `{feature}` feature, so nothing can run it. It would otherwise be \
loaded, validated and then silently dropped — the server would boot clean and \
every declared entry would never fire. To run it: {remedy}. To run without it: \
remove the `{name}` section and recompile the schema.",
name = section.name,
feature = section.feature,
remedy = section.remedy,
)));
}
Ok(())
}
fn validate_functions_config(config: &FunctionsConfig) -> Result<(), SchemaLoadError> {
fraiseql_functions::triggers::registry::TriggerRegistry::validate_definitions(
&config.definitions,
)
.map_err(|error| SchemaLoadError::ValidationError(error.message))
}
fn validate_query_bindings(
schema: &CompiledSchema,
functions: Option<&FunctionsConfig>,
) -> Result<(), SchemaLoadError> {
use fraiseql_functions::triggers::registry::{QueryFunctionBinding, TriggerRegistry};
let bindings: Vec<QueryFunctionBinding<'_>> = schema
.queries
.iter()
.filter_map(|query| {
query.function.as_deref().map(|function| QueryFunctionBinding {
query: query.name.as_str(),
function,
})
})
.collect();
TriggerRegistry::validate_query_bindings(
&bindings,
functions.map_or(&[], |f| f.definitions.as_slice()),
)
.map_err(|error| SchemaLoadError::ValidationError(error.message))
}