Skip to main content

fraiseql_functions/triggers/registry/
mod.rs

1//! Trigger registry: Central coordinator for all trigger types and lifecycle.
2//!
3//! The `TriggerRegistry` loads function definitions from a schema, parses trigger strings,
4//! builds internal structures (matchers, chains, schedulers), and manages startup/shutdown.
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    FunctionDefinition,
10    triggers::{
11        ingest::{InboundMessage, IngestTrigger},
12        mutation::{AfterMutationTrigger, BeforeMutationTrigger, TriggerMatcher},
13    },
14};
15
16/// Error type for trigger registry operations.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RegistryError {
19    /// Error message.
20    pub message: String,
21}
22
23impl std::fmt::Display for RegistryError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "{}", self.message)
26    }
27}
28
29impl std::error::Error for RegistryError {}
30
31/// Parsed trigger configuration extracted from trigger string.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ParsedTrigger {
34    /// After mutation: `after:mutation:<entity_type>:<operation>`
35    AfterMutation {
36        /// Entity type name (e.g., "User", "Post").
37        entity_type: String,
38        /// Operation kind: "insert", "update", "delete", or None for all.
39        operation:   Option<String>,
40    },
41    /// After capture: `after:capture:<entity_type>[:<operation>]` (#366) — fires on
42    /// an **externally-captured** write to a `@subscribable` table (a third-party
43    /// daemon / `psql` INSERT), driven from the change-log reader, distinct from
44    /// `after:mutation` (which fires on FraiseQL's own committed mutations).
45    AfterCapture {
46        /// Entity type name (must be a `@subscribable`/captured entity).
47        entity_type: String,
48        /// Operation kind: "insert", "update", "delete", or None for all.
49        operation:   Option<String>,
50    },
51    /// Before mutation: `before:mutation:<mutation_name>`
52    BeforeMutation {
53        /// Mutation name (e.g., "createUser").
54        mutation_name: String,
55    },
56    /// After storage: `after:storage:<bucket>:<operation>`
57    AfterStorage {
58        /// Bucket name.
59        bucket:    String,
60        /// Operation: "upload", "delete", or "all".
61        operation: String,
62    },
63    /// After ingest: `after:ingest[:<source>]` (e.g. `after:ingest:webhook:stripe`).
64    AfterIngest {
65        /// Source discriminant (`webhook:<provider>` / `email`); `None` matches
66        /// every source.
67        source: Option<String>,
68    },
69    /// Cron: `cron:<expression>`
70    Cron {
71        /// POSIX cron expression.
72        expression: String,
73    },
74    /// HTTP: `http:<method>:<path>`
75    Http {
76        /// HTTP method (GET, POST, etc.).
77        method: String,
78        /// URL path pattern.
79        path:   String,
80    },
81    /// Request-serving: `request:query` (#1329).
82    ///
83    /// The one trigger that is not an event. Every other kind names something that
84    /// *happened* — a write committed, a schedule fired, a message arrived — and the
85    /// dispatcher decides from the event which functions to run. This one names a
86    /// capability: the function answers a GraphQL root query field, and the binding
87    /// lives on the query, which declares `function = "<name>"` in place of
88    /// `sql_source`.
89    ///
90    /// # Why the trigger does not name the query
91    ///
92    /// It would be the second copy of one fact. The engine reads the binding off the
93    /// compiled `QueryDefinition` — it has to, since `fraiseql-core` knows nothing
94    /// about functions — so a query named here as well could disagree with the query
95    /// that actually resolves to this function, and one of the two spellings would
96    /// silently win. Naming the kind and leaving the binding to the query keeps one
97    /// fact in one place, and the pair is still checked in both directions by
98    /// [`validate_query_bindings`](TriggerRegistry::validate_query_bindings).
99    RequestQuery,
100}
101
102impl ParsedTrigger {
103    /// Parse a trigger string into a structured trigger configuration.
104    ///
105    /// # Errors
106    ///
107    /// Returns `RegistryError` if the trigger string format is invalid or unrecognized.
108    pub fn parse(trigger: &str) -> Result<Self, RegistryError> {
109        let parts: Vec<&str> = trigger.split(':').collect();
110
111        match parts.first().copied() {
112            Some("after") if parts.len() >= 3 && parts[1] == "mutation" => {
113                let entity_type = parts[2].to_string();
114                let operation = if parts.len() > 3 {
115                    Some(parts[3].to_string())
116                } else {
117                    None
118                };
119                Ok(ParsedTrigger::AfterMutation {
120                    entity_type,
121                    operation,
122                })
123            },
124            Some("after") if parts.len() >= 3 && parts[1] == "capture" => {
125                let entity_type = parts[2].to_string();
126                let operation = if parts.len() > 3 {
127                    Some(parts[3].to_string())
128                } else {
129                    None
130                };
131                Ok(ParsedTrigger::AfterCapture {
132                    entity_type,
133                    operation,
134                })
135            },
136            Some("before") if parts.len() >= 3 && parts[1] == "mutation" => {
137                let mutation_name = parts[2].to_string();
138                Ok(ParsedTrigger::BeforeMutation { mutation_name })
139            },
140            Some("after") if parts.len() >= 4 && parts[1] == "storage" => {
141                let bucket = parts[2].to_string();
142                let operation = parts[3].to_string();
143                Ok(ParsedTrigger::AfterStorage { bucket, operation })
144            },
145            Some("after") if parts.len() >= 2 && parts[1] == "ingest" => {
146                // The source discriminant may itself contain a colon
147                // (`webhook:stripe`), so rejoin everything past `after:ingest`.
148                let source = if parts.len() > 2 {
149                    Some(parts[2..].join(":"))
150                } else {
151                    None
152                };
153                Ok(ParsedTrigger::AfterIngest { source })
154            },
155            Some("cron") if parts.len() >= 2 => {
156                // Cron expressions can have colons in them (e.g., "cron:0 2 * * * :30")
157                // So we need to rejoin the remaining parts
158                let expression = parts[1..].join(":");
159                Ok(ParsedTrigger::Cron { expression })
160            },
161            Some("http") if parts.len() >= 3 => {
162                let method = parts[1].to_string();
163                let path = parts[2..].join(":");
164                Ok(ParsedTrigger::Http { method, path })
165            },
166            // Exactly two parts, deliberately. `request:query:quotePreview` is the
167            // natural mistake — every other trigger takes a selector — and it must
168            // fail here rather than parse to a bare `RequestQuery` that ignores the
169            // name the author wrote, which would leave the query bound to whatever
170            // the `function` key says while the declaration reads otherwise.
171            Some("request") if parts.len() == 2 && parts[1] == "query" => {
172                Ok(ParsedTrigger::RequestQuery)
173            },
174            _ => Err(RegistryError {
175                message: format!("Invalid trigger format: {}", trigger),
176            }),
177        }
178    }
179
180    /// Get the trigger type name (e.g., "after:mutation", "http").
181    #[must_use]
182    pub const fn trigger_type(&self) -> &'static str {
183        match self {
184            ParsedTrigger::AfterMutation { .. } => "after:mutation",
185            ParsedTrigger::AfterCapture { .. } => "after:capture",
186            ParsedTrigger::BeforeMutation { .. } => "before:mutation",
187            ParsedTrigger::AfterStorage { .. } => "after:storage",
188            ParsedTrigger::AfterIngest { .. } => "after:ingest",
189            ParsedTrigger::Cron { .. } => "cron",
190            ParsedTrigger::Http { .. } => "http",
191            ParsedTrigger::RequestQuery => "request:query",
192        }
193    }
194
195    /// Check if this is an after:mutation trigger.
196    #[must_use]
197    pub const fn is_after_mutation(&self) -> bool {
198        matches!(self, ParsedTrigger::AfterMutation { .. })
199    }
200
201    /// Check if this is a before:mutation trigger.
202    #[must_use]
203    pub const fn is_before_mutation(&self) -> bool {
204        matches!(self, ParsedTrigger::BeforeMutation { .. })
205    }
206
207    /// Check if this is an HTTP trigger.
208    #[must_use]
209    pub const fn is_http(&self) -> bool {
210        matches!(self, ParsedTrigger::Http { .. })
211    }
212
213    /// Check if this is a cron trigger.
214    #[must_use]
215    pub const fn is_cron(&self) -> bool {
216        matches!(self, ParsedTrigger::Cron { .. })
217    }
218
219    /// Check if this is an after:storage trigger.
220    #[must_use]
221    pub const fn is_after_storage(&self) -> bool {
222        matches!(self, ParsedTrigger::AfterStorage { .. })
223    }
224
225    /// Check if this is an after:ingest trigger.
226    #[must_use]
227    pub const fn is_after_ingest(&self) -> bool {
228        matches!(self, ParsedTrigger::AfterIngest { .. })
229    }
230
231    /// Check if this is a `request:query` trigger (#1329).
232    #[must_use]
233    pub const fn is_request_query(&self) -> bool {
234        matches!(self, ParsedTrigger::RequestQuery)
235    }
236}
237
238/// One query's declared binding to a request-serving function (#1329).
239///
240/// Borrowed rather than owned so both call sites can build the slice straight from
241/// the compiled schema they already hold, without cloning names to check them.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub struct QueryFunctionBinding<'a> {
244    /// The root query field that declares `function = "<name>"`.
245    pub query:    &'a str,
246    /// The function name it declares.
247    pub function: &'a str,
248}
249
250/// Render a de-duplicated, sorted name list for a diagnostic, or say there are none.
251fn name_list<'a>(names: impl Iterator<Item = &'a str>) -> String {
252    let mut names: Vec<&str> = names.collect();
253    names.sort_unstable();
254    names.dedup();
255    if names.is_empty() {
256        "(none declared)".to_string()
257    } else {
258        names.join(", ")
259    }
260}
261
262/// Central registry for all triggers in the system.
263#[derive(Debug, Default)]
264pub struct TriggerRegistry {
265    /// After-mutation triggers indexed by entity and operation.
266    pub after_mutation_triggers:  TriggerMatcher,
267    /// After-capture triggers (#366) — fire on externally-captured writes, indexed
268    /// by entity and operation. Structurally identical to after:mutation triggers
269    /// (entity + operation + `when` predicates), kept in a separate matcher because
270    /// they fire from the change-log reader, not the mutation route.
271    pub after_capture_triggers:   TriggerMatcher,
272    /// Before-mutation triggers indexed by mutation name.
273    pub before_mutation_triggers: Vec<BeforeMutationTrigger>,
274    /// Cron-scheduled triggers.
275    pub cron_triggers:            Vec<crate::triggers::cron::CronTrigger>,
276    /// `after:ingest` triggers for inbound-message ingestion.
277    pub ingest_triggers:          Vec<IngestTrigger>,
278    /// Names of the `request:query` functions (#1329), in declaration order.
279    ///
280    /// Not a matcher, because nothing matches on them: a request-serving function
281    /// is reached from the compiled query that names it, never from an event. The
282    /// list exists so a dispatcher can answer "may this name be invoked to serve a
283    /// read?" without re-parsing every trigger string.
284    pub request_query_functions:  Vec<String>,
285    /// Total function definitions loaded.
286    pub function_count:           usize,
287}
288
289impl TriggerRegistry {
290    /// Create a new empty trigger registry.
291    #[must_use]
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    /// Resolve an `after:mutation` / `after:capture` operation token into an
297    /// event filter, failing loud on anything unrecognized (#842).
298    ///
299    /// `None` and the documented `*` wildcard mean "every event kind"; the only
300    /// narrowing tokens are exactly `insert` / `update` / `delete`. Anything
301    /// else (`created`, `INSERT`, a typo) used to collapse to `None` via
302    /// `and_then`, silently widening the trigger to all kinds — a welcome-email
303    /// function declared for `:created` also fired on every delete.
304    fn resolve_event_filter(
305        function_name: &str,
306        trigger: &str,
307        operation: Option<&str>,
308    ) -> Result<Option<crate::EventKind>, RegistryError> {
309        match operation {
310            None | Some("*") => Ok(None),
311            Some("insert") => Ok(Some(crate::EventKind::Insert)),
312            Some("update") => Ok(Some(crate::EventKind::Update)),
313            Some("delete") => Ok(Some(crate::EventKind::Delete)),
314            Some(other) => Err(RegistryError {
315                message: format!(
316                    "function `{function_name}` trigger `{trigger}`: unknown operation \
317                     `{other}` (expected `insert`, `update`, `delete`, or `*` for all)"
318                ),
319            }),
320        }
321    }
322
323    /// Validate a set of function definitions without keeping the registry.
324    ///
325    /// The **one** definition of "is this set of declarations loadable" — trigger
326    /// grammar, `when` predicates against the trigger's operation (#597), event-kind
327    /// tokens (#842), the `after:ingest` source selector, and the `http:` /
328    /// `after:storage` refusals (#871). The compiler calls it so a bad declaration
329    /// fails `fraiseql compile`; the server's schema loader calls it so a
330    /// hand-written or stale artifact still fails at boot rather than dispatching
331    /// something nobody validated.
332    ///
333    /// Two *call sites*, one rule. The loader used to carry its own
334    /// `VALID_TRIGGER_PREFIXES` list instead, and it had already fallen behind by two
335    /// trigger kinds: `after:capture:` (#366) and `after:ingest:` parse and dispatch
336    /// here, and were refused there (#1325).
337    ///
338    /// # Errors
339    ///
340    /// Returns the first [`RegistryError`] any definition produces, naming the
341    /// function.
342    pub fn validate_definitions(functions: &[FunctionDefinition]) -> Result<(), RegistryError> {
343        Self::load_from_definitions(functions).map(|_| ())
344    }
345
346    /// Validate the pairing between function-backed queries and `request:query`
347    /// functions — in **both** directions (#1329).
348    ///
349    /// The **one** definition of "does this schema's function-backed surface hold
350    /// together". Two call sites, as with
351    /// [`validate_definitions`](Self::validate_definitions): the compiler, so a bad
352    /// declaration fails `fraiseql compile`; and the server's schema loader, because
353    /// a compiled schema is an input the server does not produce and a hand-written
354    /// or stale artifact must still fail at boot.
355    ///
356    /// Three rules, and the third is the one that is easy to leave out:
357    ///
358    /// 1. a query's `function` names a **declared** function;
359    /// 2. that function's trigger is `request:query` — an `after:mutation` handler bound to a read
360    ///    would be invoked with the wrong contract and would never fire for the reason it was
361    ///    written;
362    /// 3. every `request:query` function is named by **at least one** query. This is #871's rule
363    ///    applied to the new kind: a declared function that can never serve is a misconfiguration,
364    ///    and this one fails silently in the worst way — the function loads, the server boots, and
365    ///    nothing ever calls it.
366    ///
367    /// Reports every failing pair rather than the first, so one compile names the
368    /// whole list.
369    ///
370    /// # Errors
371    ///
372    /// Returns a [`RegistryError`] naming each query or function that does not pair.
373    pub fn validate_query_bindings(
374        bindings: &[QueryFunctionBinding<'_>],
375        functions: &[FunctionDefinition],
376    ) -> Result<(), RegistryError> {
377        let mut failures: Vec<String> = Vec::new();
378
379        for binding in bindings {
380            match functions.iter().find(|f| f.name == binding.function) {
381                None => failures.push(format!(
382                    "  query `{}`: function = `{}` names no declared function. Declared \
383                     request-serving functions are: {}",
384                    binding.query,
385                    binding.function,
386                    name_list(Self::request_query_names(functions)),
387                )),
388                Some(declared) if !Self::serves_requests(declared) => failures.push(format!(
389                    "  query `{}`: function = `{}` names a function whose trigger is `{}`. A \
390                     query may only be backed by a function declared `request:query` — an \
391                     event-triggered function is invoked with an event payload, not with \
392                     this query's arguments, so it would never answer the read it is bound \
393                     to",
394                    binding.query, binding.function, declared.trigger,
395                )),
396                Some(_) => {},
397            }
398        }
399
400        for definition in functions.iter().filter(|f| Self::serves_requests(f)) {
401            if !bindings.iter().any(|b| b.function == definition.name) {
402                failures.push(format!(
403                    "  function `{}`: trigger `request:query` is served by the query that \
404                     declares `function = \"{}\"`, and no query does. Declare it on a \
405                     root query field, or remove the function — as declared it loads, the \
406                     server boots, and nothing ever invokes it",
407                    definition.name, definition.name,
408                ));
409            }
410        }
411
412        if failures.is_empty() {
413            return Ok(());
414        }
415        Err(RegistryError {
416            message: format!(
417                "{} function-backed query binding(s) do not pair:\n{}",
418                failures.len(),
419                failures.join("\n")
420            ),
421        })
422    }
423
424    /// Whether this definition declares the request-serving trigger.
425    ///
426    /// A trigger that does not parse is not one: it is refused by
427    /// [`validate_definitions`](Self::validate_definitions), whose diagnosis is the
428    /// useful one, so treating it as "not request-serving" here cannot mask it.
429    fn serves_requests(definition: &FunctionDefinition) -> bool {
430        ParsedTrigger::parse(&definition.trigger).is_ok_and(|t| t.is_request_query())
431    }
432
433    /// The names of the `request:query` functions, for a diagnostic.
434    fn request_query_names(functions: &[FunctionDefinition]) -> impl Iterator<Item = &str> {
435        functions.iter().filter(|f| Self::serves_requests(f)).map(|f| f.name.as_str())
436    }
437
438    /// Load triggers from function definitions.
439    ///
440    /// # Errors
441    ///
442    /// Returns `RegistryError` if any function's trigger string is invalid or if loading a trigger
443    /// type fails.
444    pub fn load_from_definitions(functions: &[FunctionDefinition]) -> Result<Self, RegistryError> {
445        let mut registry = Self::new();
446        registry.function_count = functions.len();
447
448        for func in functions {
449            // Name the function in every trigger diagnosis. The parse error knows
450            // only the string; a compiled schema with twenty functions needs to say
451            // which one.
452            let parsed = ParsedTrigger::parse(&func.trigger).map_err(|error| RegistryError {
453                message: format!("function `{}`: {}", func.name, error.message),
454            })?;
455
456            match parsed {
457                ParsedTrigger::AfterMutation {
458                    entity_type,
459                    operation,
460                } => {
461                    // #842: an unrecognized token is a load error, never a
462                    // silent widening to every event kind.
463                    let event_filter = Self::resolve_event_filter(
464                        &func.name,
465                        &func.trigger,
466                        operation.as_deref(),
467                    )?;
468                    // #597: validate each `when` predicate against the trigger's
469                    // operation at load — `changed_to` is UPDATE-only, exactly one
470                    // operator per predicate, unknown keys already rejected by
471                    // `deny_unknown_fields` on `TriggerPredicate`. The `*`
472                    // wildcard means "all kinds", like the token-less form.
473                    let canonical_op = operation.as_deref().filter(|&op| op != "*");
474                    for predicate in &func.when {
475                        predicate.validate(canonical_op).map_err(|message| RegistryError {
476                            message: format!(
477                                "function `{}` trigger `{}`: {message}",
478                                func.name, func.trigger
479                            ),
480                        })?;
481                    }
482                    let trigger = AfterMutationTrigger {
483                        function_name: func.name.clone(),
484                        entity_type,
485                        event_filter,
486                        predicates: func.when.clone(),
487                    };
488                    registry.after_mutation_triggers.add(trigger);
489                },
490                ParsedTrigger::AfterCapture {
491                    entity_type,
492                    operation,
493                } => {
494                    // #842: same loud rejection as after:mutation.
495                    let event_filter = Self::resolve_event_filter(
496                        &func.name,
497                        &func.trigger,
498                        operation.as_deref(),
499                    )?;
500                    // #366: same `when` validation as after:mutation.
501                    let canonical_op = operation.as_deref().filter(|&op| op != "*");
502                    for predicate in &func.when {
503                        predicate.validate(canonical_op).map_err(|message| RegistryError {
504                            message: format!(
505                                "function `{}` trigger `{}`: {message}",
506                                func.name, func.trigger
507                            ),
508                        })?;
509                    }
510                    let trigger = AfterMutationTrigger {
511                        function_name: func.name.clone(),
512                        entity_type,
513                        event_filter,
514                        predicates: func.when.clone(),
515                    };
516                    registry.after_capture_triggers.add(trigger);
517                },
518                ParsedTrigger::BeforeMutation { mutation_name } => {
519                    let trigger = BeforeMutationTrigger {
520                        function_name: func.name.clone(),
521                        mutation_name,
522                    };
523                    registry.before_mutation_triggers.push(trigger);
524                },
525                ParsedTrigger::Http { .. } => {
526                    // #871 item 2: `http_routes` has no consumer — no server code
527                    // mounts the matcher. A declared function that can never serve is
528                    // a misconfiguration; fail loud like `after:storage`.
529                    //
530                    // The remedy changed in #1329. It used to be "invoke it via
531                    // POST /functions/v1/<name>", which was library-only — the stock
532                    // binary never mounted that route — so the advice named a door
533                    // almost nobody had. That route is retired; a function that
534                    // answers a request now does it as a typed root query field, where
535                    // it is introspectable, argument-checked and cache-governed.
536                    return Err(RegistryError {
537                        message: format!(
538                            "function `{}` trigger `{}`: http triggers are not mounted by the \
539                             server (the declared route would never serve). To answer a request \
540                             from a function, declare it `request:query` and point a root query \
541                             field at it with `function = \"{}\"` (#1329)",
542                            func.name, func.trigger, func.name
543                        ),
544                    });
545                },
546                ParsedTrigger::AfterStorage {
547                    bucket: _,
548                    operation: _,
549                } => {
550                    return Err(RegistryError {
551                        message: "after:storage triggers not yet implemented".to_string(),
552                    });
553                },
554                ParsedTrigger::AfterIngest { source } => {
555                    // A `None` source matches every inbound source; a named source
556                    // must be a recognised selector (fail loud otherwise).
557                    let source = match source {
558                        None => None,
559                        Some(key) => Some(
560                            crate::triggers::ingest::IngestSelector::from_key(&key).ok_or_else(
561                                || RegistryError {
562                                    message: format!(
563                                        "unknown after:ingest source '{key}' (expected \
564                                         'email', 'email:<mailbox>' or 'webhook:<provider>')"
565                                    ),
566                                },
567                            )?,
568                        ),
569                    };
570                    registry.ingest_triggers.push(IngestTrigger {
571                        function_name: func.name.clone(),
572                        source,
573                    });
574                },
575                ParsedTrigger::RequestQuery => {
576                    // The settings that describe a *dispatch* are refused here, where
577                    // both call sites see them. None of them is inert decoration:
578                    // `run_as` is an authority ceiling, and a security setting that is
579                    // accepted and never applied is the failure this seam exists to
580                    // remove (#1329). A request-serving function reads as its caller
581                    // through the bridge, so there is no background identity to grant.
582                    for (setting, declared, why) in [
583                        (
584                            "run_as",
585                            func.run_as.is_some(),
586                            "a request:query function reads as its caller, through the read                              bridge — there is no background identity for a ceiling to bound,                              and a ceiling that bounds nothing reads as a granted authority",
587                        ),
588                        (
589                            "when",
590                            !func.when.is_empty(),
591                            "a `when` predicate is evaluated against a row image, and a                              request-serving invocation has none: it is handed the query's                              arguments",
592                        ),
593                        (
594                            "re_runnable",
595                            func.re_runnable,
596                            "it opts out of durable dispatch, and a request-serving invocation                              is not dispatched — it answers a client that is waiting",
597                        ),
598                        (
599                            "retry",
600                            func.retry.is_some(),
601                            "a retry policy governs durable dispatch; a failed read is reported                              to the caller, who decides whether to ask again",
602                        ),
603                    ] {
604                        if declared {
605                            return Err(RegistryError {
606                                message: format!(
607                                    "function `{}` trigger `request:query`: `{setting}` cannot be                                      declared on a request-serving function — {why}",
608                                    func.name
609                                ),
610                            });
611                        }
612                    }
613                    // No matcher and no schedule: the compiled query that declares
614                    // `function = "<name>"` is what reaches this function, and that
615                    // pairing is checked by `validate_query_bindings`, which needs
616                    // the schema this call does not have.
617                    registry.request_query_functions.push(func.name.clone());
618                },
619                ParsedTrigger::Cron { expression } => {
620                    let trigger = crate::triggers::cron::CronTrigger {
621                        function_name: func.name.clone(),
622                        schedule:      expression,
623                        timezone:      "UTC".to_string(),
624                    };
625                    registry.cron_triggers.push(trigger);
626                },
627            }
628        }
629
630        Ok(registry)
631    }
632
633    /// Get the number of after:mutation triggers.
634    #[must_use]
635    pub const fn after_mutation_count(&self) -> usize {
636        // This is approximate; TriggerMatcher doesn't expose count
637        0
638    }
639
640    /// Get the number of before:mutation triggers.
641    #[must_use]
642    pub const fn before_mutation_count(&self) -> usize {
643        self.before_mutation_triggers.len()
644    }
645
646    /// Get the number of cron triggers.
647    #[must_use]
648    pub const fn cron_trigger_count(&self) -> usize {
649        self.cron_triggers.len()
650    }
651
652    /// Get the number of `after:ingest` triggers.
653    #[must_use]
654    pub const fn ingest_trigger_count(&self) -> usize {
655        self.ingest_triggers.len()
656    }
657
658    /// Find all `after:ingest` triggers matching the given inbound message.
659    ///
660    /// A source-agnostic trigger (`after:ingest`) matches every message; a
661    /// source-specific one (`after:ingest:webhook:stripe`) matches only its
662    /// source.
663    #[must_use]
664    pub fn find_ingest_triggers(&self, message: &InboundMessage) -> Vec<IngestTrigger> {
665        self.ingest_triggers
666            .iter()
667            .filter(|trigger| trigger.matches(message))
668            .cloned()
669            .collect()
670    }
671
672    /// Build a [`CronScheduler`] from all registered cron triggers.
673    ///
674    /// Returns `None` when no cron triggers are registered (the fast path —
675    /// avoids spawning a background task when no schedules exist).
676    ///
677    /// [`CronScheduler`]: crate::triggers::cron::CronScheduler
678    #[must_use]
679    pub fn cron_scheduler(&self) -> Option<crate::triggers::cron::CronScheduler> {
680        if self.cron_triggers.is_empty() {
681            None
682        } else {
683            Some(crate::triggers::cron::CronScheduler::new(self.cron_triggers.clone()))
684        }
685    }
686
687    /// Get all before:mutation triggers for a specific mutation.
688    #[must_use]
689    pub fn before_mutation_triggers_for(&self, mutation_name: &str) -> Vec<&BeforeMutationTrigger> {
690        self.before_mutation_triggers
691            .iter()
692            .filter(|t| t.mutation_name == mutation_name)
693            .collect()
694    }
695
696    /// Check if there are any before:mutation triggers for a mutation.
697    #[must_use]
698    pub fn has_before_mutation_triggers(&self, mutation_name: &str) -> bool {
699        self.before_mutation_triggers.iter().any(|t| t.mutation_name == mutation_name)
700    }
701
702    /// Build a [`BeforeMutationChain`](crate::BeforeMutationChain) for the named mutation.
703    ///
704    /// Returns `None` when no `before:mutation` triggers are registered for this mutation
705    /// (the fast path — zero overhead when hooks are absent).
706    #[must_use]
707    pub fn before_chain(
708        &self,
709        mutation_name: &str,
710    ) -> Option<crate::triggers::mutation::BeforeMutationChain> {
711        let triggers: Vec<_> = self
712            .before_mutation_triggers
713            .iter()
714            .filter(|t| t.mutation_name == mutation_name)
715            .cloned()
716            .collect();
717        if triggers.is_empty() {
718            None
719        } else {
720            Some(crate::triggers::mutation::BeforeMutationChain { triggers })
721        }
722    }
723}
724
725#[cfg(test)]
726mod tests;