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;