Skip to main content

fraiseql_functions/triggers/mutation/
mod.rs

1//! Mutation triggers: `after:mutation` and `before:mutation`.
2//!
3//! ## `after:mutation` Triggers
4//!
5//! Fire asynchronously after a mutation completes (insert, update, or delete).
6//! The function receives the old and new row data. Failures do not block the mutation.
7//!
8//! ## `before:mutation` Triggers
9//!
10//! Fire synchronously before a mutation executes. The function can:
11//! - Return `Proceed(modified_input)` to allow the mutation with possibly modified input
12//! - Return `Abort(error_message)` to cancel the mutation
13//!
14//! Multiple before-hooks execute in declaration order. The first abort short-circuits remaining
15//! hooks.
16//!
17//! **Timeout**: Defaults to 500ms (shorter than general function timeout of 5s)
18//! because before-hooks are on the critical mutation path.
19
20use std::collections::HashMap;
21
22use serde::{Deserialize, Serialize};
23
24use crate::types::EventPayload;
25
26/// Types of mutations that can trigger events.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[non_exhaustive]
29pub enum EventKind {
30    /// Insert operation.
31    Insert,
32    /// Update operation.
33    Update,
34    /// Delete operation.
35    Delete,
36}
37
38impl EventKind {
39    /// Convert to string representation.
40    #[must_use]
41    pub const fn as_str(&self) -> &'static str {
42        match self {
43            EventKind::Insert => "insert",
44            EventKind::Update => "update",
45            EventKind::Delete => "delete",
46        }
47    }
48}
49
50impl std::fmt::Display for EventKind {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(self.as_str())
53    }
54}
55
56/// Entity event with old and new row data.
57///
58/// Represents a mutation event from the database. Used by the observer pipeline
59/// to dispatch to `after:mutation` triggers asynchronously.
60///
61/// # Dispatch Semantics
62///
63/// - Fire after mutation completes (mutation response already sent)
64/// - Async dispatch: doesn't block mutation response
65/// - Failure doesn't affect mutation (error logged only)
66/// - Execution order: in declaration order from schema
67#[derive(Debug, Clone)]
68pub struct EntityEvent {
69    /// Entity type (e.g., "User", "Post").
70    pub entity:     String,
71    /// Kind of mutation.
72    pub event_kind: EventKind,
73    /// Old row data (None for Insert).
74    pub old:        Option<serde_json::Value>,
75    /// New row data (None for Delete).
76    pub new:        Option<serde_json::Value>,
77    /// Timestamp of the event.
78    pub timestamp:  chrono::DateTime<chrono::Utc>,
79}
80
81/// Trigger that fires after a mutation completes.
82///
83/// When a mutation completes, the observer pipeline emits an `EntityEvent`.
84/// If an `AfterMutationTrigger` matches the entity type and event kind,
85/// the corresponding function is invoked asynchronously without blocking
86/// the mutation response.
87///
88/// # Matching
89///
90/// - Must match `entity_type` exactly
91/// - If `event_filter` is `None`, matches all event kinds (Insert/Update/Delete)
92/// - If `event_filter` is `Some`, matches only that specific event kind
93///
94/// # Dispatch
95///
96/// - Invoked in declaration order from `schema.compiled.json`
97/// - Spawned as an async task (mutation response returns immediately)
98/// - Function execution timeout: 5s default (can be overridden per function)
99/// - Failure doesn't affect mutation (error logged to tracing subscriber)
100#[derive(Debug, Clone)]
101pub struct AfterMutationTrigger {
102    /// Name of the function to invoke.
103    pub function_name: String,
104    /// Entity type to trigger on (e.g., "User").
105    pub entity_type:   String,
106    /// Optional filter on event kind (None = all).
107    pub event_filter:  Option<EventKind>,
108}
109
110impl AfterMutationTrigger {
111    /// Check if this trigger matches the given entity and event.
112    #[must_use]
113    pub fn matches(&self, entity: &str, event_kind: EventKind) -> bool {
114        self.entity_type == entity && self.event_filter.is_none_or(|filter| filter == event_kind)
115    }
116
117    /// Build an `EventPayload` from an entity event.
118    #[must_use]
119    pub fn build_payload(&self, event: &EntityEvent) -> EventPayload {
120        EventPayload {
121            trigger_type: format!("after:mutation:{}", self.function_name),
122            entity:       event.entity.clone(),
123            event_kind:   event.event_kind.to_string(),
124            data:         serde_json::json!({
125                "event_kind": event.event_kind.as_str(),
126                "old": event.old,
127                "new": event.new,
128            }),
129            timestamp:    event.timestamp,
130        }
131    }
132}
133
134/// Result of a before-mutation trigger execution.
135///
136/// # Semantics
137///
138/// - `Proceed`: Allows mutation to continue with the provided input
139///   - Input may be modified from original
140///   - Passed to next trigger in chain (if any)
141/// - `Abort`: Prevents mutation from executing
142///   - Returns error to client immediately
143///   - Short-circuits remaining triggers in chain
144///   - Side-effects from aborted triggers are NOT rolled back
145///
146/// # Important: Side-Effects Not Rolled Back
147///
148/// If a `before:mutation` trigger abort is triggered, any side-effects
149/// (HTTP calls, storage writes, logs) from earlier triggers in the chain
150/// are NOT rolled back. Only the mutation itself is prevented.
151///
152/// This is by design: function side-effects are intended to be independent
153/// of mutation success. For example, if a function logs an audit entry and
154/// then a later trigger aborts, the audit entry remains.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[non_exhaustive]
157pub enum BeforeMutationResult {
158    /// Proceed with the mutation using the provided (possibly modified) input.
159    Proceed(serde_json::Value),
160    /// Abort the mutation with an error message.
161    Abort(String),
162}
163
164/// Trigger that fires before a mutation executes.
165#[derive(Debug, Clone)]
166pub struct BeforeMutationTrigger {
167    /// Name of the function to invoke.
168    pub function_name: String,
169    /// Name of the mutation to trigger on (e.g., "createUser").
170    pub mutation_name: String,
171}
172
173impl BeforeMutationTrigger {
174    /// Check if this trigger matches the given mutation.
175    #[must_use]
176    pub fn matches(&self, mutation: &str) -> bool {
177        self.mutation_name == mutation
178    }
179}
180
181/// Chain of before-mutation triggers for a single mutation.
182///
183/// Executes multiple `before:mutation` triggers in declaration order.
184/// Each trigger can modify the input and pass it to the next trigger,
185/// or abort the mutation by returning an error.
186///
187/// # Execution Semantics
188///
189/// - Synchronous: blocks the mutation (execution is on the hot path)
190/// - Sequential: triggers execute in declaration order
191/// - Propagating: each trigger receives the modified input from previous trigger
192/// - Short-circuit: first abort stops the chain immediately
193/// - Default timeout: 500ms per trigger (shorter than general 5s default)
194/// - Side-effects: any side-effects from aborted triggers are NOT rolled back
195///
196/// # Example
197///
198/// ```ignore
199/// let chain = BeforeMutationChain {
200///     triggers: vec![
201///         validateInput,  // checks required fields
202///         checkDuplicates, // checks uniqueness
203///         auditLog,       // logs the attempt
204///     ]
205/// };
206///
207/// let result = chain.execute(input, &observer).await?;
208/// match result {
209///     Proceed(modified) => { /* mutation continues */ }
210///     Abort(error) => { /* mutation cancelled */ }
211/// }
212/// ```
213#[derive(Debug, Clone)]
214pub struct BeforeMutationChain {
215    /// Triggers in declaration order.
216    pub triggers: Vec<BeforeMutationTrigger>,
217}
218
219impl BeforeMutationChain {
220    /// Execute the before-mutation chain with the given input.
221    ///
222    /// Runs all triggers in declaration order. Each trigger receives the
223    /// (possibly modified) output of the previous trigger as its input.
224    /// The first `Abort` short-circuits the chain.
225    ///
226    /// # Convention for function return values
227    ///
228    /// Functions signal their intent via the returned JSON object:
229    /// - `{"abort": "message"}` → abort the mutation with `message`
230    /// - `{"input": {...}}` → proceed with modified input
231    /// - Any other value (or `null`) → proceed with the input unchanged
232    ///
233    /// # Errors
234    ///
235    /// Returns `Err` if a trigger's function name is not found in `modules`, or if
236    /// function execution itself returns an error.
237    pub async fn execute<H>(
238        &self,
239        input: serde_json::Value,
240        modules: &std::collections::HashMap<String, crate::types::FunctionModule>,
241        observer: &crate::observer::FunctionObserver,
242        host: &H,
243        limits: crate::types::ResourceLimits,
244    ) -> fraiseql_error::Result<BeforeMutationResult>
245    where
246        H: crate::HostContext + ?Sized,
247    {
248        let mut current = input;
249        for trigger in &self.triggers {
250            let module = modules.get(&trigger.function_name).ok_or_else(|| {
251                fraiseql_error::FraiseQLError::Validation {
252                    message: format!(
253                        "before:mutation function '{}' not found in module registry",
254                        trigger.function_name,
255                    ),
256                    path:    None,
257                }
258            })?;
259
260            let payload = crate::types::EventPayload {
261                trigger_type: format!("before:mutation:{}", trigger.mutation_name),
262                entity:       trigger.mutation_name.clone(),
263                event_kind:   "before".to_string(),
264                data:         current.clone(),
265                timestamp:    chrono::Utc::now(),
266            };
267
268            let result = observer.invoke(module, payload, host, limits.clone()).await?;
269
270            match result.value {
271                Some(ref v) if v.get("abort").is_some() => {
272                    let msg = v["abort"]
273                        .as_str()
274                        .unwrap_or("Aborted by before:mutation trigger")
275                        .to_string();
276                    return Ok(BeforeMutationResult::Abort(msg));
277                },
278                Some(ref v) if v.get("input").is_some() => {
279                    current = v["input"].clone();
280                },
281                _ => {},
282            }
283        }
284        Ok(BeforeMutationResult::Proceed(current))
285    }
286}
287
288/// Matcher for efficiently finding triggers by (`entity_type`, `event_kind`).
289///
290/// Uses a nested `HashMap` for O(1) lookup:
291/// - `entity_type` → `event_kind` → `Vec<AfterMutationTrigger>`
292/// - When `event_kind` is None (matches all), stored separately for fallback
293///
294/// # Integration with `FunctionObserver`
295///
296/// When the `FunctionObserver` receives an `EntityEvent` from the mutation pipeline,
297/// it calls `find()` to get all matching `AfterMutationTrigger`s. For each matching
298/// trigger, the observer spawns an async task to invoke the function without blocking
299/// the mutation response. Task completion is tracked to prevent leaks on shutdown.
300///
301/// # Example
302///
303/// ```ignore
304/// let mut matcher = TriggerMatcher::new();
305/// matcher.add(AfterMutationTrigger {
306///     function_name: "onUserCreated".to_string(),
307///     entity_type: "User".to_string(),
308///     event_filter: Some(EventKind::Insert),
309/// });
310///
311/// // Later, when a User insert occurs:
312/// let triggers = matcher.find("User", EventKind::Insert);
313/// for trigger in triggers {
314///     // Spawn async task to invoke function
315/// }
316/// ```
317#[derive(Debug, Clone)]
318pub struct TriggerMatcher {
319    /// Map of `entity_type` → `event_kind` → triggers
320    specific:  HashMap<String, HashMap<String, Vec<AfterMutationTrigger>>>,
321    /// Map of `entity_type` → triggers that match all event kinds
322    all_kinds: HashMap<String, Vec<AfterMutationTrigger>>,
323}
324
325impl TriggerMatcher {
326    /// Create a new empty trigger matcher.
327    #[must_use]
328    pub fn new() -> Self {
329        Self {
330            specific:  HashMap::new(),
331            all_kinds: HashMap::new(),
332        }
333    }
334
335    /// Add a trigger to the matcher.
336    pub fn add(&mut self, trigger: AfterMutationTrigger) {
337        match trigger.event_filter {
338            Some(event_kind) => {
339                self.specific
340                    .entry(trigger.entity_type.clone())
341                    .or_default()
342                    .entry(event_kind.as_str().to_string())
343                    .or_default()
344                    .push(trigger);
345            },
346            None => {
347                self.all_kinds.entry(trigger.entity_type.clone()).or_default().push(trigger);
348            },
349        }
350    }
351
352    /// Find all triggers matching the given entity and event kind.
353    #[must_use]
354    pub fn find(&self, entity: &str, event_kind: EventKind) -> Vec<AfterMutationTrigger> {
355        let event_str = event_kind.as_str();
356        let mut result = Vec::new();
357
358        // Get specific triggers for this event kind
359        if let Some(entity_map) = self.specific.get(entity) {
360            if let Some(triggers) = entity_map.get(event_str) {
361                result.extend(triggers.clone());
362            }
363        }
364
365        // Get all-kinds triggers for this entity
366        if let Some(triggers) = self.all_kinds.get(entity) {
367            result.extend(triggers.clone());
368        }
369
370        result
371    }
372}
373
374impl Default for TriggerMatcher {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380#[cfg(test)]
381mod tests;