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;