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;