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;