use std::{collections::HashMap, sync::Arc};
use arc_swap::ArcSwap;
use fraiseql_core::{runtime::Executor, schema::SourceDefinition};
use fraiseql_functions::{
FunctionModule, ResourceLimits,
host::live::{HostContextConfig, QueryExecutor},
triggers::CronSchedule,
};
use fraiseql_observers::{LeaseGuardedRunner, PostgresSourceCursorStore};
use super::{SourcePoller, SourceQueryExecutor};
use crate::{ServerError, server_config::SourcesConfig, subsystems::BeforeMutationHooks};
#[must_use]
pub fn sources_enabled(config: &SourcesConfig) -> bool {
sources_enabled_from(config, |key| std::env::var(key).ok())
}
fn sources_enabled_from(config: &SourcesConfig, get: impl Fn(&str) -> Option<String>) -> bool {
match get("FRAISEQL_SOURCES_ENABLED") {
Some(value) => {
!matches!(value.trim().to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off")
},
None => config.enabled,
}
}
#[must_use]
pub fn source_host_config(config: &SourcesConfig) -> HostContextConfig {
source_host_config_from(config, |key| std::env::var(key).ok())
}
fn source_host_config_from(
config: &SourcesConfig,
get: impl Fn(&str) -> Option<String>,
) -> HostContextConfig {
let allowed_domains = match get("FRAISEQL_SOURCES_ALLOWED_DOMAINS") {
Some(value) => value
.split(',')
.map(str::trim)
.filter(|domain| !domain.is_empty())
.map(String::from)
.collect(),
None => config.allowed_domains.clone(),
};
let allowed_env_vars = match get("FRAISEQL_SOURCES_ALLOWED_ENV_VARS") {
Some(value) => value
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(String::from)
.collect(),
None => config.allowed_env_vars.iter().cloned().collect(),
};
HostContextConfig {
allowed_domains,
allowed_env_vars,
..HostContextConfig::default()
}
}
fn schedulable<'a>(
sources: &'a [SourceDefinition],
modules: &HashMap<String, FunctionModule>,
) -> Result<Vec<(&'a SourceDefinition, FunctionModule, CronSchedule)>, ServerError> {
sources
.iter()
.filter(|source| source.enabled)
.map(|source| {
let module = modules.get(&source.function).ok_or_else(|| {
ServerError::ConfigError(format!(
"source {:?} runs the connector {:?}, which was not loaded — every \
enabled source's connector is loaded at boot from `[functions] \
module_dir`",
source.name, source.function
))
})?;
let schedule = CronSchedule::parse(&source.schedule).map_err(|error| {
ServerError::ConfigError(format!(
"source {:?} has an invalid cron schedule {:?}: {error}",
source.name, source.schedule
))
})?;
Ok((source, module.clone(), schedule))
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn build_source_pollers(
sources: &[SourceDefinition],
db_pool: &sqlx::PgPool,
executor: &Arc<ArcSwap<Executor>>,
hooks: &BeforeMutationHooks,
host_config: &HostContextConfig,
limits: &ResourceLimits,
log_payloads: bool,
) -> Result<Vec<SourcePoller>, ServerError> {
Ok(schedulable(sources, &hooks.module_registry)?
.into_iter()
.map(|(source, module, schedule)| {
let identity = source.identity(source.name.as_str());
let query_executor: Arc<dyn QueryExecutor> =
Arc::new(SourceQueryExecutor::new(Arc::clone(executor), identity.clone()));
SourcePoller::new(
source.name.clone(),
source.cursor_name().to_string(),
schedule,
module,
Arc::clone(&hooks.observer),
PostgresSourceCursorStore::new(db_pool.clone()),
query_executor,
identity,
LeaseGuardedRunner::postgres(db_pool.clone(), source.name.clone()),
host_config.clone(),
limits.clone(),
hooks.idempotency_key.clone(),
log_payloads,
)
})
.collect())
}
#[cfg(test)]
mod tests;