use std::{sync::Arc, time::Duration};
use fraiseql_core::{
error::{FraiseQLError, Result},
security::{BeforeMutationGate, BeforeMutationOutcome, BeforeMutationRequest},
};
use crate::subsystems::BeforeMutationHooks;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BeforeMutationBudget(Duration);
impl BeforeMutationBudget {
pub const DEFAULT_MS: u64 = 500;
pub const ENV: &'static str = "FRAISEQL_FUNCTIONS_BEFORE_MUTATION_BUDGET_MS";
#[must_use]
pub const fn from_millis(millis: u64) -> Self {
Self(Duration::from_millis(millis))
}
#[must_use]
pub fn from_getter(get: impl Fn(&str) -> Option<String>) -> Self {
Self::from_millis(
get(Self::ENV).and_then(|value| value.parse().ok()).unwrap_or(Self::DEFAULT_MS),
)
}
#[must_use]
pub fn from_env() -> Self {
Self::from_getter(|key| std::env::var(key).ok())
}
#[must_use]
pub const fn duration(self) -> Duration {
self.0
}
#[must_use]
pub const fn is_enforced(self) -> bool {
!self.0.is_zero()
}
}
impl Default for BeforeMutationBudget {
fn default() -> Self {
Self::from_millis(Self::DEFAULT_MS)
}
}
pub struct FunctionChainGate {
hooks: Arc<BeforeMutationHooks>,
budget: BeforeMutationBudget,
}
impl FunctionChainGate {
#[must_use]
pub fn new(hooks: Arc<BeforeMutationHooks>) -> Self {
Self {
hooks,
budget: BeforeMutationBudget::default(),
}
}
#[must_use]
pub const fn with_budget(mut self, budget: BeforeMutationBudget) -> Self {
self.budget = budget;
self
}
}
#[async_trait::async_trait]
impl BeforeMutationGate for FunctionChainGate {
async fn before_mutation(
&self,
request: &BeforeMutationRequest<'_>,
) -> Result<BeforeMutationOutcome> {
let Some(chain) = self.hooks.trigger_registry.before_chain(request.mutation) else {
return Ok(BeforeMutationOutcome::Proceed);
};
let outcome =
run_within_budget(request.mutation, self.budget, self.run_chain(&chain, request)).await;
map_chain_outcome(request.mutation, outcome)
}
}
impl FunctionChainGate {
#[cfg(feature = "functions-runtime")]
async fn run_chain(
&self,
chain: &fraiseql_functions::BeforeMutationChain,
request: &BeforeMutationRequest<'_>,
) -> Result<fraiseql_functions::BeforeMutationResult> {
let input = request.arguments.clone();
let host = fraiseql_functions::host::before_mutation::BeforeMutationHost::new(
fraiseql_functions::EventPayload {
trigger_type: format!("before:mutation:{}", request.mutation),
entity: request.mutation.to_string(),
event_kind: "before".to_string(),
data: input.clone(),
timestamp: chrono::Utc::now(),
},
request.reader.clone(),
request.principal,
);
let limits = fraiseql_functions::ResourceLimits {
max_duration: self.budget.duration(),
..fraiseql_functions::ResourceLimits::default()
};
chain
.execute(
input,
&self.hooks.module_registry,
&self.hooks.observer,
Arc::new(host),
limits,
)
.await
}
#[cfg(not(feature = "functions-runtime"))]
#[allow(clippy::unused_async)] async fn run_chain(
&self,
_chain: &fraiseql_functions::BeforeMutationChain,
request: &BeforeMutationRequest<'_>,
) -> Result<fraiseql_functions::BeforeMutationResult> {
Err(FraiseQLError::Unsupported {
message: format!(
"mutation `{}` declares a before:mutation chain, but this build has no \
function runtime (feature `functions-runtime`) — the write is refused rather \
than run unadjudicated",
request.mutation
),
})
}
}
async fn run_within_budget<F>(
mutation: &str,
budget: BeforeMutationBudget,
chain: F,
) -> Result<fraiseql_functions::BeforeMutationResult>
where
F: std::future::Future<Output = Result<fraiseql_functions::BeforeMutationResult>>,
{
if !budget.is_enforced() {
return chain.await;
}
match tokio::time::timeout(budget.duration(), chain).await {
Ok(outcome) => outcome,
Err(_) => Err(FraiseQLError::Timeout {
timeout_ms: u64::try_from(budget.duration().as_millis()).unwrap_or(u64::MAX),
query: Some(format!("before:mutation:{mutation}")),
}),
}
}
fn map_chain_outcome(
mutation: &str,
outcome: Result<fraiseql_functions::BeforeMutationResult>,
) -> Result<BeforeMutationOutcome> {
match outcome {
Ok(fraiseql_functions::BeforeMutationResult::Proceed(arguments)) => {
Ok(BeforeMutationOutcome::ProceedWith { arguments })
},
Ok(fraiseql_functions::BeforeMutationResult::Abort(reason)) => {
Ok(BeforeMutationOutcome::Abort { reason })
},
Ok(unknown) => {
tracing::error!(
mutation = %mutation,
result = ?unknown,
"before:mutation chain returned an unrecognised decision — refusing the write"
);
Err(FraiseQLError::Internal {
message: "before:mutation hook returned an unrecognised decision".to_string(),
source: None,
})
},
Err(error @ FraiseQLError::Timeout { .. }) => {
tracing::error!(
error = %error,
mutation = %mutation,
"before:mutation chain exceeded its budget — refusing the write"
);
Err(error)
},
Err(error) => {
tracing::error!(
error = %error,
mutation = %mutation,
"before:mutation chain failed"
);
Err(FraiseQLError::Internal {
message: "before:mutation hook execution failed".to_string(),
source: None,
})
},
}
}
#[cfg(test)]
mod tests;