use std::path::{Path, PathBuf};
use fraiseql_core::{capability::SectionActivity, 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(&raw)?;
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, PartialEq, Eq)]
pub(crate) struct GatedSection {
pub name: &'static str,
pub feature: &'static str,
pub compiled_in: bool,
pub remedy: String,
pub activity: SectionActivity,
}
pub(crate) fn gated_sections() -> Vec<GatedSection> {
crate::capability::REGISTRY
.iter()
.flat_map(|r| {
r.meta.compiled_sections.iter().map(|section| GatedSection {
name: section.key,
feature: r.meta.feature,
compiled_in: r.compiled_in,
remedy: r.meta.remedy(),
activity: section.activity,
})
})
.collect()
}
pub(crate) fn refuse_unservable_sections(
raw: &serde_json::Value,
sections: &[GatedSection],
) -> Result<(), SchemaLoadError> {
let unservable: Vec<&GatedSection> = sections
.iter()
.filter(|section| !section.compiled_in)
.filter(|section| {
raw.get(section.name)
.filter(|v| !v.is_null())
.is_some_and(|value| section.activity.is_active(value))
})
.collect();
if unservable.is_empty() {
return Ok(());
}
let named = unservable
.iter()
.map(|s| format!("`{}` (needs the `{}` feature: {})", s.name, s.feature, s.remedy))
.collect::<Vec<_>>()
.join("; ");
Err(SchemaLoadError::ValidationError(format!(
"the compiled schema declares sections this build was compiled without, so nothing \
can run them: {named}. They would otherwise be loaded, validated and then silently \
dropped — the server would boot clean and every declared entry would never fire. To \
run without them, remove the sections and recompile the schema."
)))
}
pub fn refuse_unservable(raw: &serde_json::Value) -> Result<(), SchemaLoadError> {
refuse_unservable_sections(raw, &gated_sections())
}
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))
}