use std::{collections::HashMap, sync::Arc};
use arc_swap::ArcSwap;
use fraiseql_core::{db::traits::DatabaseAdapter, runtime::Executor, schema::SourceDefinition};
use fraiseql_functions::{
FunctionModule, ResourceLimits,
host::live::{HostContextConfig, QueryExecutor},
triggers::CronSchedule,
};
use fraiseql_observers::{LeaseGuardedRunner, PostgresSourceCursorStore};
use tracing::warn;
use super::{SourcePoller, SourceQueryExecutor};
use crate::{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(),
};
HostContextConfig {
allowed_domains,
..HostContextConfig::default()
}
}
fn schedulable<'a>(
sources: &'a [SourceDefinition],
modules: &HashMap<String, FunctionModule>,
) -> Vec<(&'a SourceDefinition, FunctionModule, CronSchedule)> {
sources
.iter()
.filter_map(|source| {
if !source.enabled {
return None;
}
let Some(module) = modules.get(&source.function) else {
warn!(
source = %source.name,
function = %source.function,
"source function has no loaded module — skipping (native source, or the \
module did not load)"
);
return None;
};
match CronSchedule::parse(&source.schedule) {
Ok(schedule) => Some((source, module.clone(), schedule)),
Err(error) => {
warn!(
source = %source.name,
schedule = %source.schedule,
%error,
"invalid cron schedule — skipping source"
);
None
},
}
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn build_source_pollers<A: DatabaseAdapter + Send + Sync + 'static>(
sources: &[SourceDefinition],
db_pool: &sqlx::PgPool,
executor: &Arc<ArcSwap<Executor<A>>>,
hooks: &BeforeMutationHooks,
host_config: &HostContextConfig,
limits: &ResourceLimits,
log_payloads: bool,
) -> Vec<SourcePoller> {
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));
SourcePoller::new(
source.name.clone(),
schedule,
module,
Arc::clone(&hooks.observer),
PostgresSourceCursorStore::new(db_pool.clone()),
query_executor,
LeaseGuardedRunner::postgres(db_pool.clone(), source.name.clone()),
host_config.clone(),
limits.clone(),
hooks.idempotency_key.clone(),
log_payloads,
)
})
.collect()
}
#[cfg(test)]
mod tests;