Skip to main content

fraiseql_functions/
types.rs

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