use fraiseql_core::schema::CompiledSchema;
fn differs<T: serde::Serialize>(current: &T, next: &T) -> bool {
match (serde_json::to_value(current), serde_json::to_value(next)) {
(Ok(a), Ok(b)) => a != b,
_ => true,
}
}
fn cache_ttl_projection(schema: &CompiledSchema) -> Vec<(&str, Option<u64>)> {
let mut projection: Vec<(&str, Option<u64>)> = schema
.queries
.iter()
.filter_map(|q| q.sql_source.as_deref().map(|view| (view, q.cache_ttl_seconds)))
.collect();
projection.sort_unstable();
projection
}
#[must_use]
pub fn boot_frozen_drift(current: &CompiledSchema, next: &CompiledSchema) -> Vec<&'static str> {
let CompiledSchema {
types: _,
enums: _,
input_types: _,
interfaces: _,
unions: _,
queries: _, mutations: _,
subscriptions: _,
directives: _,
subscribable: _,
operation_cost_weights: _,
session_variables: _,
custom_scalars: _,
changelog: _, schema_sdl: _,
fraiseql_version: _,
query_index: _,
mutation_index: _,
subscription_index: _,
where_relation_fields: _,
hierarchies_config: _,
security: current_security,
auth: current_auth,
validation_config: current_validation,
observers: current_observers,
observers_config: current_observers_config,
sources: current_sources,
subscriptions_config: current_subscriptions_config,
mcp_config: current_mcp,
rest_config: current_rest,
grpc_config: current_grpc,
federation: current_federation,
naming_acronyms: current_acronyms,
naming_convention: current_naming_convention,
debug_config: current_debug,
fact_tables: current_fact_tables,
} = current;
let mut drifted = Vec::new();
if differs(current_security, &next.security) {
drifted.push("security");
}
if differs(current_auth, &next.auth) {
drifted.push("auth");
}
if differs(current_validation, &next.validation_config) {
drifted.push("validation_config");
}
if differs(current_observers, &next.observers) {
drifted.push("observers");
}
if differs(current_observers_config, &next.observers_config) {
drifted.push("observers_config");
}
if differs(current_sources, &next.sources) {
drifted.push("sources");
}
if differs(current_subscriptions_config, &next.subscriptions_config) {
drifted.push("subscriptions_config");
}
if differs(current_mcp, &next.mcp_config) {
drifted.push("mcp_config");
}
if differs(current_rest, &next.rest_config) {
drifted.push("rest_config");
}
if differs(current_grpc, &next.grpc_config) {
drifted.push("grpc_config");
}
if differs(current_federation, &next.federation) {
drifted.push("federation");
}
if differs(current_acronyms, &next.naming_acronyms) {
drifted.push("naming_acronyms");
}
if differs(current_naming_convention, &next.naming_convention) {
drifted.push("naming_convention");
}
if differs(current_debug, &next.debug_config) {
drifted.push("debug_config");
}
if differs(current_fact_tables, &next.fact_tables) {
drifted.push("fact_tables");
}
if cache_ttl_projection(current) != cache_ttl_projection(next) {
drifted.push("queries[].cache_ttl_seconds");
}
drifted
}
pub fn check_reloadable(current: &CompiledSchema, next: &CompiledSchema) -> Result<(), String> {
let drifted = boot_frozen_drift(current, next);
if drifted.is_empty() {
return Ok(());
}
Err(format!(
"Schema reload refused: {} changed, and {} read only at startup. \
A hot-reload swaps the query executor; it cannot rebuild the request validator, \
rate limiter, route tables, pollers, cache TTL map or the process-global acronym \
set. Restart the server to apply this schema. (Reloading it anyway would report \
success while serving the previous configuration.)",
drifted.join(", "),
if drifted.len() == 1 {
"that section is"
} else {
"those sections are"
},
))
}