Skip to main content

fraiseql_functions/
types.rs

1//! Core types for function execution.
2
3use std::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 [`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/// [`system_job`]: fraiseql_core::security::SecurityContext::system_job
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct RunAs {
117    /// Roles granted to the function's background write identity (the RBAC ceiling).
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub roles: Vec<String>,
120
121    /// Scopes granted to the function's background write identity.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub scopes: Vec<String>,
124
125    /// The single tenant this function's bridge writes are scoped to, if any. Unset
126    /// ⇒ global/system (NULL tenant).
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub tenant: Option<String>,
129}
130
131#[cfg(feature = "host-live")]
132impl RunAs {
133    /// Build the background [`SecurityContext`](fraiseql_core::security::SecurityContext)
134    /// this ceiling grants, identified as `system_job:<job_id>` and correlated by
135    /// `request_id`. Mirrors
136    /// [`SourceDefinition::identity`](fraiseql_core::schema::SourceDefinition::identity):
137    /// the roles/scopes/tenant are the ceiling, the [`ActorType::SystemJob`] is
138    /// recorded for audit, never an authorization input.
139    ///
140    /// [`ActorType::SystemJob`]: fraiseql_core::security::ActorType::SystemJob
141    #[must_use]
142    pub fn identity(
143        &self,
144        job_id: impl Into<String>,
145        request_id: impl Into<String>,
146    ) -> fraiseql_core::security::SecurityContext {
147        fraiseql_core::security::SecurityContext::system_job(
148            job_id,
149            request_id,
150            self.roles.clone(),
151            self.scopes.clone(),
152            self.tenant.clone().map(fraiseql_core::types::TenantId::from),
153        )
154    }
155}
156
157/// Definition of a serverless function for deployment and execution.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct FunctionDefinition {
160    /// Unique name for this function.
161    pub name:       String,
162    /// Trigger type and configuration (e.g., "after:mutation:createUser", "cron:0 * * * *",
163    /// "<http:GET:/users/:id>").
164    pub trigger:    String,
165    /// Which runtime executes this function.
166    pub runtime:    RuntimeType,
167    /// Optional timeout in milliseconds (overrides defaults).
168    /// - For `before:mutation` triggers: defaults to 500ms
169    /// - For other triggers: defaults to 5s
170    pub timeout_ms: Option<u64>,
171
172    /// The authority ceiling this function's `fraiseql_query` bridge writes run
173    /// under (#594). Absent ⇒ **fail-closed**: the bridge runs under an anonymous
174    /// identity and RLS/field-authz deny writes. See [`RunAs`] and
175    /// [`identity`](Self::identity).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub run_as: Option<RunAs>,
178
179    /// Declarative `when` predicates (#597) — a conjunction the dispatcher evaluates
180    /// on the row images before firing an `after:mutation`/`after:capture` function.
181    /// Empty ⇒ always fire (back-compat). See
182    /// [`TriggerPredicate`](crate::triggers::mutation::TriggerPredicate).
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub when: Vec<crate::triggers::mutation::TriggerPredicate>,
185
186    /// Fire-and-forget opt-out for durable dispatch.
187    ///
188    /// After-mutation function dispatch is durable by default: a transient
189    /// failure is retried with backoff and, once retries are exhausted, the
190    /// invocation is dead-lettered so money- and send-path work is never
191    /// silently lost. Set `re_runnable = true` for work that is safe to simply
192    /// re-run later (e.g. LLM scoring) — such dispatch stays fire-and-forget with
193    /// no retry or dead-letter overhead. See ADR 0015 for the rationale.
194    #[serde(default)]
195    pub re_runnable: bool,
196
197    /// Per-function retry policy for durable dispatch.
198    ///
199    /// `None` uses the server default (overridable via `FRAISEQL_FUNCTIONS_RETRY_*`
200    /// environment variables). Ignored when [`re_runnable`](Self::re_runnable) is
201    /// `true`. Reuses the observer subsystem's [`RetryConfig`] so retry semantics
202    /// are identical across both subsystems.
203    ///
204    /// [`RetryConfig`]: fraiseql_observers::RetryConfig
205    #[serde(default)]
206    pub retry: Option<fraiseql_observers::RetryConfig>,
207}
208
209impl FunctionDefinition {
210    /// Create a new function definition.
211    #[must_use]
212    pub fn new(name: &str, trigger: &str, runtime: RuntimeType) -> Self {
213        Self {
214            name: name.to_string(),
215            trigger: trigger.to_string(),
216            runtime,
217            timeout_ms: None,
218            run_as: None,
219            when: Vec::new(),
220            re_runnable: false,
221            retry: None,
222        }
223    }
224
225    /// Set the [`run_as`](Self::run_as) authority ceiling (#594).
226    #[must_use]
227    pub fn with_run_as(mut self, run_as: RunAs) -> Self {
228        self.run_as = Some(run_as);
229        self
230    }
231
232    /// The background [`SecurityContext`] this function's `fraiseql_query` bridge
233    /// writes run under (#594).
234    ///
235    /// Built from [`run_as`](Self::run_as) via [`SecurityContext::system_job`], so a
236    /// function write is audited as `system_job:<function-name>` under
237    /// [`ActorType::SystemJob`] — the same envelope a source write carries. **Absent
238    /// `run_as` yields a fail-closed identity** (no roles, no scopes, no tenant →
239    /// every authz/RLS decision denies). `request_id` correlates one dispatch
240    /// (typically its per-dispatch idempotency token).
241    ///
242    /// [`SecurityContext`]: fraiseql_core::security::SecurityContext
243    /// [`SecurityContext::system_job`]: fraiseql_core::security::SecurityContext::system_job
244    /// [`ActorType::SystemJob`]: fraiseql_core::security::ActorType::SystemJob
245    #[cfg(feature = "host-live")]
246    #[must_use]
247    pub fn identity(
248        &self,
249        request_id: impl Into<String>,
250    ) -> fraiseql_core::security::SecurityContext {
251        self.run_as.clone().unwrap_or_default().identity(&self.name, request_id)
252    }
253
254    /// Mark this function as re-runnable (fire-and-forget) dispatch.
255    ///
256    /// See [`re_runnable`](Self::re_runnable) and ADR 0015.
257    #[must_use]
258    pub const fn re_runnable(mut self) -> Self {
259        self.re_runnable = true;
260        self
261    }
262
263    /// Set a custom timeout for this function.
264    #[must_use]
265    pub const fn with_timeout(mut self, timeout_ms: u64) -> Self {
266        self.timeout_ms = Some(timeout_ms);
267        self
268    }
269
270    /// Get the effective timeout for this function.
271    #[must_use]
272    pub fn effective_timeout(&self) -> Duration {
273        match self.timeout_ms {
274            Some(ms) => Duration::from_millis(ms),
275            None => {
276                // before:mutation defaults to 500ms; others default to 5s
277                if self.trigger.starts_with("before:mutation") {
278                    Duration::from_millis(500)
279                } else {
280                    Duration::from_secs(5)
281                }
282            },
283        }
284    }
285
286    /// Check if this function is a before:mutation trigger.
287    #[must_use]
288    pub fn is_before_mutation(&self) -> bool {
289        self.trigger.starts_with("before:mutation:")
290    }
291
292    /// Check if this function is an after:mutation trigger.
293    #[must_use]
294    pub fn is_after_mutation(&self) -> bool {
295        self.trigger.starts_with("after:mutation:")
296    }
297
298    /// Check if this function is an after:storage trigger.
299    #[must_use]
300    pub fn is_after_storage(&self) -> bool {
301        self.trigger.starts_with("after:storage:")
302    }
303
304    /// Check if this function is an after:ingest trigger.
305    #[must_use]
306    pub fn is_after_ingest(&self) -> bool {
307        self.trigger == "after:ingest" || self.trigger.starts_with("after:ingest:")
308    }
309
310    /// Check if this function is a cron trigger.
311    #[must_use]
312    pub fn is_cron(&self) -> bool {
313        self.trigger.starts_with("cron:")
314    }
315
316    /// Check if this function is an HTTP trigger.
317    #[must_use]
318    pub fn is_http(&self) -> bool {
319        self.trigger.starts_with("http:")
320    }
321}
322
323/// Log level for structured logging.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
325#[non_exhaustive]
326pub enum LogLevel {
327    /// Debug level.
328    Debug,
329    /// Info level.
330    Info,
331    /// Warning level.
332    Warn,
333    /// Error level.
334    Error,
335}
336
337/// A single log entry from function execution.
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339pub struct LogEntry {
340    /// Log level.
341    pub level:     LogLevel,
342    /// Log message.
343    pub message:   String,
344    /// When the log was written.
345    pub timestamp: chrono::DateTime<chrono::Utc>,
346}
347
348/// Result of a function invocation.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct FunctionResult {
351    /// Return value from the function (may be None if function returns void).
352    pub value:             Option<serde_json::Value>,
353    /// All logs captured during execution.
354    pub logs:              Vec<LogEntry>,
355    /// Total execution duration.
356    pub duration:          Duration,
357    /// Peak memory usage in bytes.
358    pub memory_peak_bytes: u64,
359}
360
361/// Resource limits for function execution.
362#[derive(Debug, Clone)]
363pub struct ResourceLimits {
364    /// Maximum memory allocation in bytes.
365    pub max_memory_bytes: u64,
366    /// Maximum execution duration.
367    pub max_duration:     Duration,
368    /// Maximum number of log entries to capture.
369    pub max_log_entries:  usize,
370}
371
372impl Default for ResourceLimits {
373    fn default() -> Self {
374        Self {
375            max_memory_bytes: 128 * 1024 * 1024,      // 128 MB
376            max_duration:     Duration::from_secs(5), // 5 seconds
377            max_log_entries:  10_000,
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests;