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;