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}