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;