Skip to main content

fraiseql_functions/
types.rs

1//! Core types for function execution.
2
3use std::{
4    path::{Path, PathBuf},
5    time::Duration,
6};
7
8use serde::{Deserialize, Serialize};
9use sha2::Digest;
10
11/// Marker a function (or a host op) puts in a thrown error's message to signal the
12/// failure is **permanent** — do not retry, dead-letter immediately.
13///
14/// Both runtimes surface a guest failure as a string, so permanence travels as this
15/// sentinel substring: when the error message contains it, the runtime classifies
16/// the failure as a client error (4xx) — which the durable dispatcher dead-letters
17/// on the first attempt — rather than the default transient `Unsupported` (501).
18///
19/// A guest can also throw `Object.assign(new Error(msg), { fraiseqlPermanent: true })`
20/// (Deno); the wrapper folds that into this marker. Host ops that already know a
21/// failure is permanent (e.g. `send_email` on a denied identity or a rejected
22/// recipient) prepend it automatically.
23pub const PERMANENT_ERROR_MARKER: &str = "[fraiseql:permanent]";
24
25/// Supported runtime types for serverless functions.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[non_exhaustive]
28pub enum RuntimeType {
29    /// `WebAssembly` Component Model runtime.
30    Wasm,
31    /// Deno (`JavaScript`/`TypeScript` via V8) runtime.
32    Deno,
33}
34
35impl RuntimeType {
36    /// Get supported file extensions for this runtime.
37    #[must_use]
38    pub const fn supported_extensions(&self) -> &[&str] {
39        match self {
40            RuntimeType::Wasm => &[".wasm"],
41            RuntimeType::Deno => &[".js", ".ts", ".mjs", ".mts"],
42        }
43    }
44
45    /// The module file named `name` under `module_dir` for this runtime, if one is
46    /// there: `<module_dir>/<name>.<ext>` for each supported extension, first existing
47    /// file wins.
48    ///
49    /// The **one** rule for where code lives. A declared function resolves through it
50    /// ([`FunctionDefinition::resolve_module_path`]), and so does a scheduled source's
51    /// connector, which is a Deno module bound by name with no function definition
52    /// (#1399). The server's loader, `fraiseql functions invoke` and the compiler all
53    /// resolve through it, so the compiler cannot approve a layout the server cannot
54    /// load.
55    #[must_use]
56    pub fn resolve_module_path(self, module_dir: &Path, name: &str) -> Option<PathBuf> {
57        self.supported_extensions()
58            .iter()
59            .map(|extension| module_dir.join(format!("{name}{extension}")))
60            .find(|path| path.exists())
61    }
62
63    /// The module-file candidates for `name`, rendered for a diagnostic:
64    /// `<module_dir>/<name>.{ext,ext}`.
65    #[must_use]
66    pub fn module_path_pattern(self, module_dir: &Path, name: &str) -> String {
67        format!(
68            "{}/{name}.{{{}}}",
69            module_dir.display(),
70            self.supported_extensions()
71                .iter()
72                .map(|ext| ext.trim_start_matches('.'))
73                .collect::<Vec<_>>()
74                .join(","),
75        )
76    }
77}
78
79/// A compiled function module ready for execution.
80#[derive(Debug, Clone)]
81pub struct FunctionModule {
82    /// Unique name for this function.
83    pub name:        String,
84    /// Hash of the module source (for caching).
85    pub source_hash: String,
86    /// Compiled bytecode or source text.
87    pub bytecode:    bytes::Bytes,
88    /// Which runtime executes this module.
89    pub runtime:     RuntimeType,
90}
91
92impl FunctionModule {
93    /// Create a new WASM module from compiled bytecode.
94    pub fn from_bytecode(name: String, bytecode: bytes::Bytes) -> Self {
95        let source_hash = hex::encode(sha2::Sha256::digest(&bytecode));
96        Self {
97            name,
98            source_hash,
99            bytecode,
100            runtime: RuntimeType::Wasm,
101        }
102    }
103
104    /// Create a new source-based module (JavaScript/TypeScript).
105    #[must_use]
106    pub fn from_source(name: String, source: String, runtime: RuntimeType) -> Self {
107        let bytecode = bytes::Bytes::from(source);
108        let source_hash = hex::encode(sha2::Sha256::digest(&bytecode));
109        Self {
110            name,
111            source_hash,
112            bytecode,
113            runtime,
114        }
115    }
116}
117
118/// Trigger event payload for a function invocation.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct EventPayload {
121    /// Type of trigger: "mutation", "subscription", "cron", "webhook", etc.
122    pub trigger_type: String,
123    /// Entity name (e.g., "User", "Post").
124    pub entity:       String,
125    /// Event kind (e.g., "created", "updated", "deleted").
126    pub event_kind:   String,
127    /// Event data (JSON).
128    pub data:         serde_json::Value,
129    /// Timestamp when the event occurred.
130    pub timestamp:    chrono::DateTime<chrono::Utc>,
131}
132
133/// The least-privilege authority ceiling a function's `fraiseql_query` bridge
134/// writes run under (#594) — the function's `run_as`.
135///
136/// This is the same authority model scheduled sources use
137/// (`fraiseql_core::schema::RunAs`, `docs/architecture/sources.md:88-109`), applied
138/// to event-dispatched functions: a *ceiling* the function can never exceed. A
139/// [`FunctionDefinition`] with no `run_as` runs **fail-closed** — its host's
140/// `fraiseql_query` executes under an anonymous `SecurityContext::system_job` identity with no
141/// roles/scopes/tenant, so RLS and field-authorization deny every write until an
142/// operator grants a ceiling. Granting authority is a deliberate act, never a
143/// default.
144///
145/// It is a distinct type from the core `RunAs` so the base `fraiseql-functions`
146/// crate (used by the CLI, codegen, and authoring) need not depend on
147/// `fraiseql-core`; the two share an identical JSON shape and the wiring layer
148/// (`host-live`) converts to a `SecurityContext` via `FunctionDefinition::identity`.
149///
150/// Those three items live behind the `host-live` feature, which is what pulls in
151/// `fraiseql-core`; they are named rather than linked so this page resolves in the
152/// default build, which is the one a bare `cargo doc` produces (#1199).
153#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
154pub struct RunAs {
155    /// Roles granted to the function's background write identity (the RBAC ceiling).
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub roles: Vec<String>,
158
159    /// Scopes granted to the function's background write identity.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub scopes: Vec<String>,
162
163    /// The single tenant this function's bridge writes are scoped to, if any. Unset
164    /// ⇒ global/system (NULL tenant).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub tenant: Option<String>,
167}
168
169#[cfg(feature = "host-live")]
170impl RunAs {
171    /// Build the background [`SecurityContext`](fraiseql_core::security::SecurityContext)
172    /// this ceiling grants, identified as `system_job:<job_id>` and correlated by
173    /// `request_id`. Mirrors
174    /// [`SourceDefinition::identity`](fraiseql_core::schema::SourceDefinition::identity):
175    /// the roles/scopes/tenant are the ceiling, the [`ActorType::SystemJob`] is
176    /// recorded for audit, never an authorization input.
177    ///
178    /// [`ActorType::SystemJob`]: fraiseql_core::security::ActorType::SystemJob
179    #[must_use]
180    pub fn identity(
181        &self,
182        job_id: impl Into<String>,
183        request_id: impl Into<String>,
184    ) -> fraiseql_core::security::SecurityContext {
185        fraiseql_core::security::SecurityContext::system_job(
186            job_id,
187            request_id,
188            self.roles.clone(),
189            self.scopes.clone(),
190            self.tenant.clone().map(fraiseql_core::types::TenantId::from),
191        )
192    }
193}
194
195/// Definition of a serverless function for deployment and execution.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct FunctionDefinition {
199    /// Unique name for this function.
200    pub name:       String,
201    /// Trigger type and configuration (e.g., "after:mutation:createUser", "cron:0 * * * *",
202    /// "<http:GET:/users/:id>").
203    pub trigger:    String,
204    /// Which runtime executes this function.
205    pub runtime:    RuntimeType,
206    /// Optional timeout in milliseconds (overrides defaults).
207    /// - For `before:mutation` triggers: defaults to 500ms
208    /// - For other triggers: defaults to 5s
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub timeout_ms: Option<u64>,
211
212    /// The authority ceiling this function's `fraiseql_query` bridge writes run
213    /// under (#594). Absent ⇒ **fail-closed**: the bridge runs under an anonymous
214    /// identity and RLS/field-authz deny writes. See [`RunAs`] and
215    /// `identity()` (feature `host-live`).
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub run_as: Option<RunAs>,
218
219    /// Declarative `when` predicates (#597) — a conjunction the dispatcher evaluates
220    /// on the row images before firing an `after:mutation`/`after:capture` function.
221    /// Empty ⇒ always fire (back-compat). See
222    /// [`TriggerPredicate`](crate::triggers::mutation::TriggerPredicate).
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub when: Vec<crate::triggers::mutation::TriggerPredicate>,
225
226    /// Fire-and-forget opt-out for durable dispatch.
227    ///
228    /// After-mutation function dispatch is durable by default: a transient
229    /// failure is retried with backoff and, once retries are exhausted, the
230    /// invocation is dead-lettered so money- and send-path work is never
231    /// silently lost. Set `re_runnable = true` for work that is safe to simply
232    /// re-run later (e.g. LLM scoring) — such dispatch stays fire-and-forget with
233    /// no retry or dead-letter overhead. See ADR 0015 for the rationale.
234    #[serde(default)]
235    pub re_runnable: bool,
236
237    /// Per-function retry policy for durable dispatch.
238    ///
239    /// `None` uses the server default (overridable via `FRAISEQL_FUNCTIONS_RETRY_*`
240    /// environment variables). Ignored when [`re_runnable`](Self::re_runnable) is
241    /// `true`. Reuses the observer subsystem's [`RetryConfig`] so retry semantics
242    /// are identical across both subsystems.
243    ///
244    /// [`RetryConfig`]: fraiseql_observers::RetryConfig
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub retry: Option<fraiseql_observers::RetryConfig>,
247}
248
249impl FunctionDefinition {
250    /// Create a new function definition.
251    #[must_use]
252    pub fn new(name: &str, trigger: &str, runtime: RuntimeType) -> Self {
253        Self {
254            name: name.to_string(),
255            trigger: trigger.to_string(),
256            runtime,
257            timeout_ms: None,
258            run_as: None,
259            when: Vec::new(),
260            re_runnable: false,
261            retry: None,
262        }
263    }
264
265    /// Set the [`run_as`](Self::run_as) authority ceiling (#594).
266    #[must_use]
267    pub fn with_run_as(mut self, run_as: RunAs) -> Self {
268        self.run_as = Some(run_as);
269        self
270    }
271
272    /// The background [`SecurityContext`] this function's `fraiseql_query` bridge
273    /// writes run under (#594).
274    ///
275    /// Built from [`run_as`](Self::run_as) via [`SecurityContext::system_job`], so a
276    /// function write is audited as `system_job:<function-name>` under
277    /// [`ActorType::SystemJob`] — the same envelope a source write carries. **Absent
278    /// `run_as` yields a fail-closed identity** (no roles, no scopes, no tenant →
279    /// every authz/RLS decision denies). `request_id` correlates one dispatch
280    /// (typically its per-dispatch idempotency token).
281    ///
282    /// [`SecurityContext`]: fraiseql_core::security::SecurityContext
283    /// [`SecurityContext::system_job`]: fraiseql_core::security::SecurityContext::system_job
284    /// [`ActorType::SystemJob`]: fraiseql_core::security::ActorType::SystemJob
285    #[cfg(feature = "host-live")]
286    #[must_use]
287    pub fn identity(
288        &self,
289        request_id: impl Into<String>,
290    ) -> fraiseql_core::security::SecurityContext {
291        self.run_as.clone().unwrap_or_default().identity(&self.name, request_id)
292    }
293
294    /// Mark this function as re-runnable (fire-and-forget) dispatch.
295    ///
296    /// See [`re_runnable`](Self::re_runnable) and ADR 0015.
297    #[must_use]
298    pub const fn re_runnable(mut self) -> Self {
299        self.re_runnable = true;
300        self
301    }
302
303    /// Set a custom timeout for this function.
304    #[must_use]
305    pub const fn with_timeout(mut self, timeout_ms: u64) -> Self {
306        self.timeout_ms = Some(timeout_ms);
307        self
308    }
309
310    /// Get the effective timeout for this function.
311    #[must_use]
312    pub fn effective_timeout(&self) -> Duration {
313        match self.timeout_ms {
314            Some(ms) => Duration::from_millis(ms),
315            None => {
316                // before:mutation defaults to 500ms; others default to 5s
317                if self.trigger.starts_with("before:mutation") {
318                    Duration::from_millis(500)
319                } else {
320                    Duration::from_secs(5)
321                }
322            },
323        }
324    }
325
326    /// The module file this definition resolves to under `module_dir`, if one is
327    /// there — [`RuntimeType::resolve_module_path`] for the declared runtime and name,
328    /// the one rule for where code lives (#1325, #1399).
329    ///
330    /// Returns `None` when no module is present, which the caller reports in its own
331    /// terms: a compile error, a boot failure, or a harness error.
332    #[must_use]
333    pub fn resolve_module_path(&self, module_dir: &Path) -> Option<PathBuf> {
334        self.runtime.resolve_module_path(module_dir, &self.name)
335    }
336
337    /// The module-file candidates this definition names, rendered for a diagnostic:
338    /// `<module_dir>/<name>.{ext,ext}`.
339    #[must_use]
340    pub fn module_path_pattern(&self, module_dir: &Path) -> String {
341        self.runtime.module_path_pattern(module_dir, &self.name)
342    }
343
344    /// Check if this function is a before:mutation trigger.
345    #[must_use]
346    pub fn is_before_mutation(&self) -> bool {
347        self.trigger.starts_with("before:mutation:")
348    }
349
350    /// Check if this function is an after:mutation trigger.
351    #[must_use]
352    pub fn is_after_mutation(&self) -> bool {
353        self.trigger.starts_with("after:mutation:")
354    }
355
356    /// Check if this function is an after:storage trigger.
357    #[must_use]
358    pub fn is_after_storage(&self) -> bool {
359        self.trigger.starts_with("after:storage:")
360    }
361
362    /// Check if this function is an after:ingest trigger.
363    #[must_use]
364    pub fn is_after_ingest(&self) -> bool {
365        self.trigger == "after:ingest" || self.trigger.starts_with("after:ingest:")
366    }
367
368    /// Check if this function is a cron trigger.
369    #[must_use]
370    pub fn is_cron(&self) -> bool {
371        self.trigger.starts_with("cron:")
372    }
373
374    /// Check if this function is an HTTP trigger.
375    #[must_use]
376    pub fn is_http(&self) -> bool {
377        self.trigger.starts_with("http:")
378    }
379}
380
381/// The compiled schema's `"functions"` section — the whole of what an author
382/// declares about functions, and the single definition of that shape.
383///
384/// It is a **sibling** of the core `CompiledSchema` in `schema.compiled.json`, not a
385/// field of it: `fraiseql-core` knows nothing about functions, and the dependency
386/// runs the other way (`fraiseql-functions` optionally uses core, never the
387/// reverse). The server reads it through `ExtendedCompiledSchema`; the CLI writes it
388/// there and reads it back in `fraiseql functions invoke`.
389///
390/// ```json
391/// {
392///   "functions": {
393///     "module_dir": "functions",
394///     "definitions": [
395///       { "name": "on_create_user", "trigger": "after:mutation:createUser", "runtime": "Wasm" }
396///     ]
397///   }
398/// }
399/// ```
400///
401/// # One definition, deliberately
402///
403/// This shape used to exist three times: `fraiseql-server`'s `FunctionsConfig`, the
404/// CLI harness's private `FunctionsSection` ("a local mirror … so the harness does
405/// not pull the whole server crate in"), and — had #1325 followed the same instinct —
406/// an authoring mirror in `IntermediateSchema`. Three copies of a rule lose the
407/// fourth. It lives here, in the crate that owns [`FunctionDefinition`], so the
408/// producer and both consumers deserialize the same struct.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct FunctionsConfig {
412    /// Directory containing the function modules (`.wasm`, `.js`, `.ts`), resolved
413    /// relative to the server's working directory.
414    ///
415    /// Required on the wire. The *authoring* default (`functions/`) lives in the
416    /// compiler, which always emits an explicit value — so a hand-written compiled
417    /// schema that omits it still fails loudly rather than silently resolving
418    /// somewhere the author did not choose.
419    pub module_dir: PathBuf,
420
421    /// The declared functions.
422    #[serde(default)]
423    pub definitions: Vec<FunctionDefinition>,
424
425    /// Which dead-letter store backs function dispatch (#598): `"memory"` (the
426    /// default — dead-letters vanish on restart) or `"postgres"` (durable, survives
427    /// a restart; requires a database pool). Overridable by the
428    /// `FRAISEQL_FUNCTIONS_DLQ_STORE` env var. Absent ⇒ memory.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub dlq_store: Option<String>,
431}
432
433/// Log level for structured logging.
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
435#[non_exhaustive]
436pub enum LogLevel {
437    /// Debug level.
438    Debug,
439    /// Info level.
440    Info,
441    /// Warning level.
442    Warn,
443    /// Error level.
444    Error,
445}
446
447/// A single log entry from function execution.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
449pub struct LogEntry {
450    /// Log level.
451    pub level:     LogLevel,
452    /// Log message.
453    pub message:   String,
454    /// When the log was written.
455    pub timestamp: chrono::DateTime<chrono::Utc>,
456}
457
458/// Result of a function invocation.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct FunctionResult {
461    /// Return value from the function (may be None if function returns void).
462    pub value:             Option<serde_json::Value>,
463    /// All logs captured during execution.
464    pub logs:              Vec<LogEntry>,
465    /// Total execution duration.
466    pub duration:          Duration,
467    /// Peak memory usage in bytes.
468    pub memory_peak_bytes: u64,
469}
470
471/// Resource limits for function execution.
472#[derive(Debug, Clone)]
473pub struct ResourceLimits {
474    /// Maximum memory allocation in bytes.
475    pub max_memory_bytes: u64,
476    /// Maximum execution duration.
477    pub max_duration:     Duration,
478    /// Maximum number of log entries to capture.
479    pub max_log_entries:  usize,
480}
481
482impl Default for ResourceLimits {
483    fn default() -> Self {
484        Self {
485            max_memory_bytes: 128 * 1024 * 1024,      // 128 MB
486            max_duration:     Duration::from_secs(5), // 5 seconds
487            max_log_entries:  10_000,
488        }
489    }
490}
491
492#[cfg(test)]
493mod tests;