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**: the *whole chain* runs inside a wall-clock ceiling, 500 ms by
18//! default — shorter than the general 5 s function timeout because before-hooks
19//! are on the critical mutation path. It is enforced by the caller, not here:
20//! `fraiseql-server`'s `BeforeMutationBudget` wraps this method and also passes
21//! the same ceiling down as each hook's [`ResourceLimits::max_duration`]. Until
22//! #1328 this paragraph said 500 ms and nothing enforced anything — the gate
23//! passed `ResourceLimits::default()`, so each hook got 5 s and a chain of *n*
24//! hooks got 5*n*.
25//!
26//! **Host surface**: a before-hook runs on `BeforeMutationHost` — a read-only
27//! `fraiseql_query` bridge executed as the requesting principal, plus logging and
28//! the caller's auth context. Every side-effecting op refuses by name; see
29//! `docs/architecture/functions.md`.
30//!
31//! [`ResourceLimits::max_duration`]: crate::types::ResourceLimits::max_duration
32
33use std::{collections::HashMap, sync::Arc};
34
35use serde::{Deserialize, Serialize};
36
37use crate::types::EventPayload;
38
39/// Types of mutations that can trigger events.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[non_exhaustive]
42pub enum EventKind {
43    /// Insert operation.
44    Insert,
45    /// Update operation.
46    Update,
47    /// Delete operation.
48    Delete,
49}
50
51impl EventKind {
52    /// Convert to string representation.
53    #[must_use]
54    pub const fn as_str(&self) -> &'static str {
55        match self {
56            EventKind::Insert => "insert",
57            EventKind::Update => "update",
58            EventKind::Delete => "delete",
59        }
60    }
61}
62
63impl std::fmt::Display for EventKind {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(self.as_str())
66    }
67}
68
69/// Entity event with old and new row data.
70///
71/// Represents a mutation event from the database. Used by the observer pipeline
72/// to dispatch to `after:mutation` triggers asynchronously.
73///
74/// # Dispatch Semantics
75///
76/// - Fire after mutation completes (mutation response already sent)
77/// - Async dispatch: doesn't block mutation response
78/// - Failure doesn't affect mutation (error logged only)
79/// - Execution order: in declaration order from schema
80#[derive(Debug, Clone)]
81pub struct EntityEvent {
82    /// Entity type (e.g., "User", "Post").
83    pub entity:     String,
84    /// Kind of mutation.
85    pub event_kind: EventKind,
86    /// Old row data (None for Insert).
87    pub old:        Option<serde_json::Value>,
88    /// New row data (None for Delete).
89    pub new:        Option<serde_json::Value>,
90    /// Timestamp of the event.
91    pub timestamp:  chrono::DateTime<chrono::Utc>,
92}
93
94/// A single declarative field/transition predicate on an after:mutation trigger
95/// (#597).
96///
97/// The `when` condition that decides whether a function fires, evaluated by the
98/// dispatcher against the built payload **before** any runtime spins.
99///
100/// Deliberately small: a `field` plus exactly one operator — `eq` (state) or
101/// `changed_to` (transition). Anything richer stays guest code; this is a dispatch
102/// filter, not a rules engine. A list of predicates is a **conjunction** (all must
103/// hold); an empty list always fires (back-compat).
104///
105/// ```jsonc
106/// { "field": "status", "changed_to": "approved" }  // UPDATE-only transition test
107/// { "field": "kind",   "eq": "standard" }           // state test (INSERT + UPDATE)
108/// ```
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct TriggerPredicate {
112    /// The field (a JSON key in the row image) to test.
113    pub field: String,
114
115    /// **State test**: the field currently equals this value. Evaluated on the
116    /// after-image (INSERT/UPDATE) or, for a DELETE, the pre-image. An absent field
117    /// never equals a value (missing ⇒ `false`).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub eq: Option<serde_json::Value>,
120
121    /// **Transition test** (UPDATE-only): the field *changed to* this value —
122    /// `old.field != v && new.field == v`. A DELETE (no after-image) never matches;
123    /// `changed_to` on a non-`update` trigger is a load error.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub changed_to: Option<serde_json::Value>,
126}
127
128impl TriggerPredicate {
129    /// Evaluate this predicate against a row's `old`/`new` images.
130    ///
131    /// - `eq`: the current image (`new`, or `old` on a DELETE) has `field == v`. Missing field ⇒
132    ///   `false`.
133    /// - `changed_to`: `old.field != v && new.field == v`.
134    ///
135    /// A predicate with neither operator set (rejected at load) never matches.
136    #[must_use]
137    pub fn matches(
138        &self,
139        old: Option<&serde_json::Value>,
140        new: Option<&serde_json::Value>,
141    ) -> bool {
142        if let Some(value) = &self.eq {
143            // Prefer the after-image; fall back to the pre-image for a DELETE.
144            let image = new.or(old);
145            return image.and_then(|row| row.get(&self.field)) == Some(value);
146        }
147        if let Some(value) = &self.changed_to {
148            let before = old.and_then(|row| row.get(&self.field));
149            let after = new.and_then(|row| row.get(&self.field));
150            return before != Some(value) && after == Some(value);
151        }
152        false
153    }
154
155    /// Validate the predicate at load time against the trigger's `operation`
156    /// (`insert`/`update`/`delete`, or `None` for all).
157    ///
158    /// # Errors
159    ///
160    /// - Neither `eq` nor `changed_to` set, or both set (exactly one operator).
161    /// - `changed_to` on a non-`update` trigger (a transition needs a before + after).
162    pub fn validate(&self, operation: Option<&str>) -> Result<(), String> {
163        match (&self.eq, &self.changed_to) {
164            (Some(_), Some(_)) => Err(format!(
165                "predicate on field `{}` sets both `eq` and `changed_to` — use exactly one",
166                self.field
167            )),
168            (None, None) => Err(format!(
169                "predicate on field `{}` sets neither `eq` nor `changed_to`",
170                self.field
171            )),
172            (None, Some(_)) if operation != Some("update") => Err(format!(
173                "predicate on field `{}` uses `changed_to`, which is UPDATE-only, but the \
174                 trigger operation is `{}`",
175                self.field,
176                operation.unwrap_or("all")
177            )),
178            _ => Ok(()),
179        }
180    }
181}
182
183/// Whether *all* predicates in a conjunction hold for a row's `old`/`new` images.
184/// An empty conjunction always holds (back-compat: no `when` ⇒ always fire).
185#[must_use]
186pub fn predicates_match(
187    predicates: &[TriggerPredicate],
188    old: Option<&serde_json::Value>,
189    new: Option<&serde_json::Value>,
190) -> bool {
191    predicates.iter().all(|predicate| predicate.matches(old, new))
192}
193
194/// Trigger that fires after a mutation completes.
195///
196/// When a mutation completes, the observer pipeline emits an `EntityEvent`.
197/// If an `AfterMutationTrigger` matches the entity type and event kind,
198/// the corresponding function is invoked asynchronously without blocking
199/// the mutation response.
200///
201/// # Matching
202///
203/// - Must match `entity_type` exactly
204/// - If `event_filter` is `None`, matches all event kinds (Insert/Update/Delete)
205/// - If `event_filter` is `Some`, matches only that specific event kind
206/// - `predicates` (the `when` clause) must all hold on the row images (#597)
207///
208/// # Dispatch
209///
210/// - Invoked in declaration order from `schema.compiled.json`
211/// - Spawned as an async task (mutation response returns immediately)
212/// - Function execution timeout: 5s default (can be overridden per function)
213/// - Failure doesn't affect mutation (error logged to tracing subscriber)
214#[derive(Debug, Clone)]
215pub struct AfterMutationTrigger {
216    /// Name of the function to invoke.
217    pub function_name: String,
218    /// Entity type to trigger on (e.g., "User").
219    pub entity_type:   String,
220    /// Optional filter on event kind (None = all).
221    pub event_filter:  Option<EventKind>,
222    /// The `when` conjunction (#597); empty ⇒ always fire (back-compat).
223    pub predicates:    Vec<TriggerPredicate>,
224}
225
226impl AfterMutationTrigger {
227    /// Check if this trigger matches the given entity and event kind. Does **not**
228    /// evaluate the `when` predicates — the dispatcher applies
229    /// [`predicates_hold`](Self::predicates_hold) against the payload afterwards.
230    #[must_use]
231    pub fn matches(&self, entity: &str, event_kind: EventKind) -> bool {
232        self.entity_type == entity && self.event_filter.is_none_or(|filter| filter == event_kind)
233    }
234
235    /// Whether this trigger's `when` predicates all hold for the event's row images
236    /// (#597). Evaluated by the dispatcher before spawning the runtime — a `false`
237    /// result means the function does not fire (no dispatch record at all).
238    #[must_use]
239    pub fn predicates_hold(&self, event: &EntityEvent) -> bool {
240        predicates_match(&self.predicates, event.old.as_ref(), event.new.as_ref())
241    }
242
243    /// Build an `EventPayload` from an entity event.
244    #[must_use]
245    pub fn build_payload(&self, event: &EntityEvent) -> EventPayload {
246        EventPayload {
247            trigger_type: format!("after:mutation:{}", self.function_name),
248            entity:       event.entity.clone(),
249            event_kind:   event.event_kind.to_string(),
250            data:         serde_json::json!({
251                "event_kind": event.event_kind.as_str(),
252                "old": event.old,
253                "new": event.new,
254            }),
255            timestamp:    event.timestamp,
256        }
257    }
258}
259
260/// Result of a before-mutation trigger execution.
261///
262/// # Semantics
263///
264/// - `Proceed`: Allows mutation to continue with the provided input
265///   - Input may be modified from original
266///   - Passed to next trigger in chain (if any)
267/// - `Abort`: Prevents mutation from executing
268///   - Returns error to client immediately
269///   - Short-circuits remaining triggers in chain
270///   - Side-effects from aborted triggers are NOT rolled back
271///
272/// # Important: Side-Effects Not Rolled Back
273///
274/// If a `before:mutation` trigger abort is triggered, any side-effects
275/// (HTTP calls, storage writes, logs) from earlier triggers in the chain
276/// are NOT rolled back. Only the mutation itself is prevented.
277///
278/// This is by design: function side-effects are intended to be independent
279/// of mutation success. For example, if a function logs an audit entry and
280/// then a later trigger aborts, the audit entry remains.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282#[non_exhaustive]
283pub enum BeforeMutationResult {
284    /// Proceed with the mutation using the provided (possibly modified) input.
285    Proceed(serde_json::Value),
286    /// Abort the mutation with an error message.
287    Abort(String),
288}
289
290/// Trigger that fires before a mutation executes.
291#[derive(Debug, Clone)]
292pub struct BeforeMutationTrigger {
293    /// Name of the function to invoke.
294    pub function_name: String,
295    /// Name of the mutation to trigger on (e.g., "createUser").
296    pub mutation_name: String,
297}
298
299impl BeforeMutationTrigger {
300    /// Check if this trigger matches the given mutation.
301    #[must_use]
302    pub fn matches(&self, mutation: &str) -> bool {
303        self.mutation_name == mutation
304    }
305}
306
307/// Chain of before-mutation triggers for a single mutation.
308///
309/// Executes multiple `before:mutation` triggers in declaration order.
310/// Each trigger can modify the input and pass it to the next trigger,
311/// or abort the mutation by returning an error.
312///
313/// # Execution Semantics
314///
315/// - Synchronous: blocks the mutation (execution is on the hot path)
316/// - Sequential: triggers execute in declaration order
317/// - Propagating: each trigger receives the modified input from previous trigger
318/// - Short-circuit: first abort stops the chain immediately
319/// - Budget: 500 ms by default for the **whole chain**, enforced by the caller (`fraiseql-server`'s
320///   `BeforeMutationBudget`), not per trigger
321/// - Side-effects: any side-effects from aborted triggers are NOT rolled back
322///
323/// # Example
324///
325/// ```ignore
326/// let chain = BeforeMutationChain {
327///     triggers: vec![
328///         validateInput,  // checks required fields
329///         checkDuplicates, // checks uniqueness
330///         auditLog,       // logs the attempt
331///     ]
332/// };
333///
334/// let result = chain.execute(input, &observer).await?;
335/// match result {
336///     Proceed(modified) => { /* mutation continues */ }
337///     Abort(error) => { /* mutation cancelled */ }
338/// }
339/// ```
340#[derive(Debug, Clone)]
341pub struct BeforeMutationChain {
342    /// Triggers in declaration order.
343    pub triggers: Vec<BeforeMutationTrigger>,
344}
345
346impl BeforeMutationChain {
347    /// Execute the before-mutation chain with the given input.
348    ///
349    /// Runs all triggers in declaration order. Each trigger receives the
350    /// (possibly modified) output of the previous trigger as its input.
351    /// The first `Abort` short-circuits the chain.
352    ///
353    /// # Convention for function return values
354    ///
355    /// Functions signal their intent via the returned JSON object:
356    /// - `{"abort": "message"}` → abort the mutation with `message`
357    /// - `{"input": {...}}` → proceed with modified input
358    /// - Any other value (or `null`) → proceed with the input unchanged
359    ///
360    /// # Errors
361    ///
362    /// Returns `Err` if a trigger's function name is not found in `modules`, or if
363    /// function execution itself returns an error.
364    pub async fn execute(
365        &self,
366        input: serde_json::Value,
367        modules: &std::collections::HashMap<String, crate::types::FunctionModule>,
368        observer: &crate::observer::FunctionObserver,
369        host: std::sync::Arc<dyn crate::host::dyn_context::DynHostContext>,
370        limits: crate::types::ResourceLimits,
371    ) -> fraiseql_error::Result<BeforeMutationResult> {
372        let mut current = input;
373        for trigger in &self.triggers {
374            let module = modules.get(&trigger.function_name).ok_or_else(|| {
375                fraiseql_error::FraiseQLError::Validation {
376                    message: format!(
377                        "before:mutation function '{}' not found in module registry",
378                        trigger.function_name,
379                    ),
380                    path:    None,
381                }
382            })?;
383
384            let payload = crate::types::EventPayload {
385                trigger_type: format!("before:mutation:{}", trigger.mutation_name),
386                entity:       trigger.mutation_name.clone(),
387                event_kind:   "before".to_string(),
388                data:         current.clone(),
389                timestamp:    chrono::Utc::now(),
390            };
391
392            // `invoke_with_context`, not `invoke` (#1328). The sync path snapshots
393            // the host into a view whose every I/O op answers `Unsupported` — the
394            // Deno backend drops the host argument outright — so a before-hook
395            // could not read no matter what host it was handed. The read bridge
396            // exists to be reachable; this is the call that makes it so.
397            let result = observer
398                .invoke_with_context(module, payload, Arc::clone(&host), limits.clone())
399                .await?;
400
401            match interpret_guest_decision(result.value.as_ref(), current) {
402                abort @ BeforeMutationResult::Abort(_) => return Ok(abort),
403                BeforeMutationResult::Proceed(next) => current = next,
404            }
405        }
406        Ok(BeforeMutationResult::Proceed(current))
407    }
408}
409
410/// Map one guest's return value onto the decision the chain acts on.
411///
412/// The convention a function author writes against:
413///
414/// - `{"abort": "message"}` → abort the mutation with `message`;
415/// - `{"input": {…}}` → proceed with that input, replacing what was threaded in;
416/// - anything else, including `null` and a function that returns nothing → proceed unchanged.
417///
418/// Public and separate from [`BeforeMutationChain::execute`] because the authoring
419/// harness (`fraiseql functions invoke`) has to report the same verdict the server
420/// would reach. A harness that re-implemented the three cases would be a second
421/// copy of the convention, free to drift from the one that decides.
422#[must_use]
423pub fn interpret_guest_decision(
424    value: Option<&serde_json::Value>,
425    input: serde_json::Value,
426) -> BeforeMutationResult {
427    match value {
428        Some(value) if value.get("abort").is_some() => BeforeMutationResult::Abort(
429            value["abort"]
430                .as_str()
431                .unwrap_or("Aborted by before:mutation trigger")
432                .to_string(),
433        ),
434        Some(value) if value.get("input").is_some() => {
435            BeforeMutationResult::Proceed(value["input"].clone())
436        },
437        _ => BeforeMutationResult::Proceed(input),
438    }
439}
440
441/// Matcher for efficiently finding triggers by (`entity_type`, `event_kind`).
442///
443/// Uses a nested `HashMap` for O(1) lookup:
444/// - `entity_type` → `event_kind` → `Vec<AfterMutationTrigger>`
445/// - When `event_kind` is None (matches all), stored separately for fallback
446///
447/// # Integration with `FunctionObserver`
448///
449/// When the `FunctionObserver` receives an `EntityEvent` from the mutation pipeline,
450/// it calls `find()` to get all matching `AfterMutationTrigger`s. For each matching
451/// trigger, the observer spawns an async task to invoke the function without blocking
452/// the mutation response. Task completion is tracked to prevent leaks on shutdown.
453///
454/// # Example
455///
456/// ```ignore
457/// let mut matcher = TriggerMatcher::new();
458/// matcher.add(AfterMutationTrigger {
459///     function_name: "onUserCreated".to_string(),
460///     entity_type: "User".to_string(),
461///     event_filter: Some(EventKind::Insert),
462/// });
463///
464/// // Later, when a User insert occurs:
465/// let triggers = matcher.find("User", EventKind::Insert);
466/// for trigger in triggers {
467///     // Spawn async task to invoke function
468/// }
469/// ```
470#[derive(Debug, Clone)]
471pub struct TriggerMatcher {
472    /// Map of `entity_type` → `event_kind` → triggers
473    specific:  HashMap<String, HashMap<String, Vec<AfterMutationTrigger>>>,
474    /// Map of `entity_type` → triggers that match all event kinds
475    all_kinds: HashMap<String, Vec<AfterMutationTrigger>>,
476}
477
478impl TriggerMatcher {
479    /// Create a new empty trigger matcher.
480    #[must_use]
481    pub fn new() -> Self {
482        Self {
483            specific:  HashMap::new(),
484            all_kinds: HashMap::new(),
485        }
486    }
487
488    /// Add a trigger to the matcher.
489    pub fn add(&mut self, trigger: AfterMutationTrigger) {
490        match trigger.event_filter {
491            Some(event_kind) => {
492                self.specific
493                    .entry(trigger.entity_type.clone())
494                    .or_default()
495                    .entry(event_kind.as_str().to_string())
496                    .or_default()
497                    .push(trigger);
498            },
499            None => {
500                self.all_kinds.entry(trigger.entity_type.clone()).or_default().push(trigger);
501            },
502        }
503    }
504
505    /// Find all triggers matching the given entity and event kind.
506    #[must_use]
507    pub fn find(&self, entity: &str, event_kind: EventKind) -> Vec<AfterMutationTrigger> {
508        let event_str = event_kind.as_str();
509        let mut result = Vec::new();
510
511        // Get specific triggers for this event kind
512        if let Some(entity_map) = self.specific.get(entity) {
513            if let Some(triggers) = entity_map.get(event_str) {
514                result.extend(triggers.clone());
515            }
516        }
517
518        // Get all-kinds triggers for this entity
519        if let Some(triggers) = self.all_kinds.get(entity) {
520            result.extend(triggers.clone());
521        }
522
523        result
524    }
525}
526
527impl Default for TriggerMatcher {
528    fn default() -> Self {
529        Self::new()
530    }
531}
532
533#[cfg(test)]
534mod tests;