Skip to main content

fraiseql_server/subsystems/
mod.rs

1//! Server subsystem assembly and lifecycle management.
2//!
3//! [`ServerSubsystems`] bundles the optional platform extensions —
4//! object storage and serverless functions — into a
5//! single coherent struct that the server can query, route-mount, and shut down
6//! in a controlled order.
7//!
8//! # Assembly
9//!
10//! Use [`builder::ServerSubsystemsBuilder`] to assemble subsystems from their
11//! pre-built parts. The builder validates cross-subsystem dependencies before
12//! returning the final [`ServerSubsystems`]:
13//!
14//! ```rust,ignore
15//! let subsystems = ServerSubsystemsBuilder::new()
16//!     .with_storage(storage_subsystem)
17//!     .with_functions(functions_subsystem)
18//!     .build()?;
19//! ```
20//!
21//! # Shutdown order
22//!
23//! Shutdown proceeds in reverse initialization order:
24//! 1. Stop the cron scheduler (functions)
25//! 2. Drop the functions observer (stops dispatching events)
26//! 3. Drop the storage backend (flushes any pending writes)
27
28pub mod builder;
29pub mod validator;
30
31/// Loads function modules from disk and assembles the functions-runtime subsystem.
32#[cfg(feature = "functions-runtime")]
33pub mod loader;
34
35#[cfg(test)]
36mod tests;
37
38use std::sync::Arc;
39
40pub use builder::{ServerSubsystemsBuilder, SubsystemBuildError};
41use fraiseql_functions::{FunctionObserver, triggers::TriggerRegistry};
42pub use validator::{SubsystemConfigWarning, validate_subsystems_config};
43
44use crate::schema::loader::{FunctionsConfig, SchemaStorageConfig};
45
46// ── Subsystem structs ─────────────────────────────────────────────────────────
47
48/// Storage subsystem: backend, metadata repository, RLS evaluator, and bucket config.
49///
50/// Assembled at server startup from the `[storage]` section of the compiled schema
51/// and the server's `PgPool`. Use the [`fraiseql_storage::storage_router`] with the
52/// contained `state` to mount the `/storage/v1` route tree.
53pub struct StorageSubsystem {
54    /// Runtime storage state (backend + metadata repo + RLS + bucket config map).
55    ///
56    /// Pass this to [`fraiseql_storage::storage_router`] to mount the HTTP routes.
57    pub state: fraiseql_storage::StorageState,
58
59    /// Schema-level bucket definitions from the compiled schema.
60    pub schema_config: SchemaStorageConfig,
61}
62
63/// Functions subsystem: observer and trigger registry.
64///
65/// Assembled at server startup from the `[functions]` section of the compiled schema.
66/// The observer dispatches events to function runtimes; the registry maps triggers to
67/// function definitions and provides HTTP route matchers.
68pub struct FunctionsSubsystem {
69    /// Observer that dispatches trigger events to the appropriate function runtime.
70    pub observer: Arc<FunctionObserver>,
71
72    /// Registry mapping trigger types to function definitions.
73    pub trigger_registry: TriggerRegistry,
74
75    /// Loaded function modules keyed by function name.
76    ///
77    /// Populated at server startup by reading source files from `config.module_dir`.
78    /// Used by the before-mutation chain and the after-mutation dispatcher.
79    pub module_registry: std::collections::HashMap<String, fraiseql_functions::FunctionModule>,
80
81    /// Schema-level functions configuration (definitions + module directory).
82    pub config: FunctionsConfig,
83}
84
85// ── Aggregated container ──────────────────────────────────────────────────────
86
87/// All optional platform subsystems assembled from the compiled schema.
88///
89/// Each field is `None` when the corresponding section is absent from or disabled
90/// in the compiled schema. Callers can use [`is_storage_enabled`][Self::is_storage_enabled]
91/// etc. to check at a glance, or match directly on the `Option` fields.
92#[allow(missing_debug_implementations)] // Reason: inner subsystem types (e.g. FunctionObserver) don't implement Debug
93pub struct ServerSubsystems {
94    /// Object storage subsystem, present when the schema's `"storage"` key is set.
95    pub storage: Option<StorageSubsystem>,
96
97    /// Serverless functions subsystem, present when the schema's `"functions"` key is set.
98    pub functions: Option<FunctionsSubsystem>,
99}
100
101impl ServerSubsystems {
102    /// Create an empty `ServerSubsystems` with all subsystems disabled.
103    ///
104    /// Equivalent to `ServerSubsystemsBuilder::new().build().unwrap()`.
105    #[must_use]
106    pub const fn none() -> Self {
107        Self {
108            storage:   None,
109            functions: None,
110        }
111    }
112
113    /// Returns `true` if the storage subsystem is present.
114    #[must_use]
115    pub const fn is_storage_enabled(&self) -> bool {
116        self.storage.is_some()
117    }
118
119    /// Returns `true` if the functions subsystem is present.
120    #[must_use]
121    pub const fn is_functions_enabled(&self) -> bool {
122        self.functions.is_some()
123    }
124}
125
126// ── Before-mutation hook bundle ───────────────────────────────────────────────
127
128/// Shared bundle of before-mutation state passed into `AppState` for handler access.
129///
130/// This is a lightweight, cloneable snapshot of the parts of [`FunctionsSubsystem`]
131/// that are needed on the hot path for before-mutation checks. It is extracted once
132/// at server startup and stored in `AppState` via an `Arc`.
133pub struct BeforeMutationHooks {
134    /// Registry of all loaded triggers, keyed by trigger type and mutation name.
135    pub trigger_registry: TriggerRegistry,
136    /// Loaded function modules keyed by function name.
137    pub module_registry:  std::collections::HashMap<String, fraiseql_functions::FunctionModule>,
138    /// Observer that dispatches events to the appropriate function runtime.
139    pub observer:         std::sync::Arc<FunctionObserver>,
140
141    /// Dead-letter queue for durable after:mutation dispatch: an invocation that
142    /// exhausts its retries (or fails permanently) is pushed here rather than
143    /// silently lost.
144    #[cfg(feature = "functions-runtime")]
145    pub dlq: std::sync::Arc<dyn fraiseql_observers::DeadLetterQueue>,
146
147    /// Per-function dispatch settings (re-runnable flag + retry policy) resolved
148    /// from the compiled schema, keyed by function name. Functions absent from
149    /// the map fall back to the durable `FunctionDispatchSetting::default`.
150    #[cfg(feature = "functions-runtime")]
151    pub dispatch_settings:
152        std::collections::HashMap<String, crate::routes::after_mutation::FunctionDispatchSetting>,
153
154    /// Host-owned sender-identity resolver for the `send_email` op — resolves the
155    /// `from` from the authenticated context (the #539 seam). `None` → `send_email`
156    /// is unconfigured and fails loud. Set together with `email_transport` via
157    /// [`with_email`](Self::with_email).
158    #[cfg(feature = "functions-runtime")]
159    pub sender_resolver: Option<std::sync::Arc<dyn fraiseql_functions::SenderIdentityResolver>>,
160
161    /// Email transport for the `send_email` op. `None` → `send_email` fails loud.
162    #[cfg(feature = "functions-runtime")]
163    pub email_transport: Option<std::sync::Arc<dyn fraiseql_functions::EmailTransport>>,
164
165    /// HMAC subkey for the per-dispatch idempotency token, derived from the server
166    /// HMAC secret. `Some` → the token is signed (unforgeable, required before it is
167    /// exposed in a VERP Return-Path); `None` → an unsigned digest (zero-config
168    /// default). Set via [`with_idempotency_key`](Self::with_idempotency_key).
169    #[cfg(feature = "functions-runtime")]
170    pub idempotency_key: Option<std::sync::Arc<[u8]>>,
171
172    /// Per-function `run_as` authority ceilings (#594), keyed by function name.
173    /// A function absent from the map has no ceiling ⇒ its `fraiseql_query` bridge
174    /// runs fail-closed (anonymous `system_job`; RLS/field-authz deny writes).
175    /// Populated from the compiled schema's function definitions.
176    #[cfg(feature = "functions-runtime")]
177    pub run_as: std::collections::HashMap<String, fraiseql_functions::RunAs>,
178}
179
180impl BeforeMutationHooks {
181    /// Create a hook bundle with default durable-dispatch wiring: an unbounded
182    /// in-memory dead-letter queue and no per-function overrides (every function
183    /// uses the durable default).
184    ///
185    /// For the full compiled-schema resolution (per-function settings +
186    /// `FRAISEQL_FUNCTIONS_*` env overrides), use
187    /// [`FunctionsSubsystem::into_before_mutation_hooks`] instead.
188    #[must_use]
189    pub fn new(
190        trigger_registry: TriggerRegistry,
191        module_registry: std::collections::HashMap<String, fraiseql_functions::FunctionModule>,
192        observer: Arc<FunctionObserver>,
193    ) -> Self {
194        Self {
195            trigger_registry,
196            module_registry,
197            observer,
198            #[cfg(feature = "functions-runtime")]
199            dlq: Arc::new(crate::observers::runtime::InMemoryDlq::new_with_max(None)),
200            #[cfg(feature = "functions-runtime")]
201            dispatch_settings: std::collections::HashMap::new(),
202            #[cfg(feature = "functions-runtime")]
203            sender_resolver: None,
204            #[cfg(feature = "functions-runtime")]
205            email_transport: None,
206            #[cfg(feature = "functions-runtime")]
207            idempotency_key: None,
208            #[cfg(feature = "functions-runtime")]
209            run_as: std::collections::HashMap::new(),
210        }
211    }
212
213    /// Attach the HMAC subkey that signs the per-dispatch idempotency token.
214    ///
215    /// Derived once from the server HMAC secret
216    /// ([`fraiseql_observers::derive_idempotency_subkey`]). `None` leaves the token
217    /// as an unsigned digest — the zero-config default; a signed token is required
218    /// before it is exposed externally as a VERP Return-Path (P04b).
219    #[cfg(feature = "functions-runtime")]
220    #[must_use]
221    pub fn with_idempotency_key(mut self, key: Option<std::sync::Arc<[u8]>>) -> Self {
222        self.idempotency_key = key;
223        self
224    }
225
226    /// Enable the `send_email` host op for dispatched functions by attaching a
227    /// sender-identity resolver (the host-owned `from`) and an email transport.
228    ///
229    /// Without both, `send_email` fails loud (mirrors the `sql_query`
230    /// fail-loud-until-wired stance). The resolver is the #539 seam —
231    /// `LoginEmailSender` (from = login email) by default, a DB-backed resolver
232    /// where the sending mailbox differs; the transport is the per-connected-account
233    /// SMTP relay ([`SmtpMailboxTransport`](crate::inbound::email::SmtpMailboxTransport)).
234    #[cfg(feature = "functions-runtime")]
235    #[must_use]
236    pub fn with_email(
237        mut self,
238        sender_resolver: Arc<dyn fraiseql_functions::SenderIdentityResolver>,
239        email_transport: Arc<dyn fraiseql_functions::EmailTransport>,
240    ) -> Self {
241        self.sender_resolver = Some(sender_resolver);
242        self.email_transport = Some(email_transport);
243        self
244    }
245
246    /// Replace the dead-letter store (#598).
247    ///
248    /// The default from [`FunctionsSubsystem::into_before_mutation_hooks`] is the
249    /// in-memory store; the serve path swaps in the Postgres-backed
250    /// [`PgFunctionDlq`](crate::observers::pg_function_dlq::PgFunctionDlq) when
251    /// `[functions] dlq_store = "postgres"` and a database pool is available, so a
252    /// dead-lettered dispatch survives a restart.
253    #[cfg(feature = "functions-runtime")]
254    #[must_use]
255    pub fn with_dlq(mut self, dlq: Arc<dyn fraiseql_observers::DeadLetterQueue>) -> Self {
256        self.dlq = dlq;
257        self
258    }
259}
260
261#[cfg(feature = "functions-runtime")]
262impl FunctionsSubsystem {
263    /// Assemble the before-mutation hook bundle for `AppState`.
264    ///
265    /// Resolves each function's durable-dispatch settings (re-runnable flag +
266    /// retry policy) from the compiled schema, layering the
267    /// `FRAISEQL_FUNCTIONS_*` environment overrides via `DispatchDefaults::from_env`,
268    /// and creates the shared dead-letter queue (capped by
269    /// `FRAISEQL_FUNCTIONS_DLQ_MAX_SIZE`). Consumes the subsystem because the hook
270    /// bundle takes ownership of its trigger registry, modules, and observer.
271    #[must_use]
272    pub fn into_before_mutation_hooks(self) -> BeforeMutationHooks {
273        use crate::routes::after_mutation::{DispatchDefaults, resolve_dispatch_settings};
274
275        let defaults = DispatchDefaults::from_env();
276        let dispatch_settings = resolve_dispatch_settings(&self.config.definitions, &defaults);
277        let dlq = std::sync::Arc::new(crate::observers::runtime::InMemoryDlq::new_with_max(
278            defaults.dlq_max_size,
279        ));
280
281        // #594: collect each function's `run_as` ceiling, keyed by name. A function
282        // with no `run_as` is simply absent (fail-closed at dispatch time).
283        let run_as = self
284            .config
285            .definitions
286            .iter()
287            .filter_map(|def| def.run_as.clone().map(|ceiling| (def.name.clone(), ceiling)))
288            .collect();
289
290        BeforeMutationHooks {
291            trigger_registry: self.trigger_registry,
292            module_registry: self.module_registry,
293            observer: self.observer,
294            dlq,
295            dispatch_settings,
296            sender_resolver: None,
297            email_transport: None,
298            idempotency_key: None,
299            run_as,
300        }
301    }
302}