Skip to main content

fraiseql_functions/runtime/wasm/store/
mod.rs

1//! Store data and host import implementations for WASM component execution.
2//!
3//! This module defines the `StoreData` struct which holds all per-invocation state
4//! and implements the host import traits that allow WASM components to call back
5//! into the host for logging, context, and I/O operations.
6
7use std::sync::Arc;
8
9use super::{
10    bindings::fraiseql::host::{context, io, logging},
11    host_bridge::DynHostContext,
12};
13use crate::types::{EventPayload, LogEntry, LogLevel, ResourceLimits};
14
15/// Per-invocation state for WASM component execution.
16///
17/// This struct is attached to the wasmtime `Store` as user data and implements
18/// the host import traits for `fraiseql:host/*` interfaces.
19pub struct StoreData {
20    /// The event that triggered this function invocation.
21    pub event_payload: EventPayload,
22
23    /// Reference to the host context for I/O and auth operations.
24    pub host_context: Option<Arc<dyn DynHostContext>>,
25
26    /// Logs captured during execution.
27    pub logs: Vec<LogEntry>,
28
29    /// Resource limits for this invocation.
30    pub limits: ResourceLimits,
31
32    /// Peak memory usage observed during execution (in bytes).
33    pub memory_peak_bytes: u64,
34
35    /// Current memory usage (for tracking).
36    pub memory_current_bytes: u64,
37
38    /// WASI context for guests compiled with wasm32-wasip2.
39    wasi_ctx: wasmtime_wasi::WasiCtx,
40
41    /// WASI resource table for file/stream handles.
42    wasi_table: wasmtime::component::ResourceTable,
43}
44
45impl StoreData {
46    /// Create a new store data for an invocation.
47    #[must_use]
48    pub fn new(event_payload: EventPayload, limits: ResourceLimits) -> Self {
49        Self {
50            event_payload,
51            host_context: None,
52            logs: Vec::new(),
53            limits,
54            memory_peak_bytes: 0,
55            memory_current_bytes: 0,
56            wasi_ctx: wasmtime_wasi::WasiCtxBuilder::new().build(),
57            wasi_table: wasmtime::component::ResourceTable::new(),
58        }
59    }
60
61    /// Set the host context reference for this store.
62    pub fn set_host_context(&mut self, context: Arc<dyn DynHostContext>) {
63        self.host_context = Some(context);
64    }
65
66    /// Get a reference to the host context, or return an error string.
67    fn require_host_context(&self) -> Result<&dyn DynHostContext, String> {
68        self.host_context
69            .as_deref()
70            .ok_or_else(|| "host context not available".to_string())
71    }
72
73    /// Log a message at the specified level.
74    ///
75    /// Respects the `max_log_entries` limit and silently drops excess logs.
76    pub fn log_message(&mut self, level: LogLevel, message: &str) {
77        if self.logs.len() < self.limits.max_log_entries {
78            let entry = LogEntry {
79                level,
80                message: message.to_string(),
81                timestamp: chrono::Utc::now(),
82            };
83            self.logs.push(entry);
84
85            match level {
86                LogLevel::Debug => tracing::debug!("{}", message),
87                LogLevel::Info => tracing::info!("{}", message),
88                LogLevel::Warn => tracing::warn!("{}", message),
89                LogLevel::Error => tracing::error!("{}", message),
90            }
91        }
92    }
93
94    /// Get a reference to the event payload.
95    #[must_use]
96    pub const fn event_payload_ref(&self) -> &EventPayload {
97        &self.event_payload
98    }
99
100    /// Get the event payload as a JSON string.
101    ///
102    /// # Errors
103    ///
104    /// Returns `Err` if serialization fails (should not happen for valid `EventPayload`).
105    pub fn get_event_payload_json(&self) -> wasmtime::Result<String> {
106        serde_json::to_string(&self.event_payload).map_err(|e| wasmtime::Error::msg(e.to_string()))
107    }
108}
109
110impl wasmtime_wasi::WasiView for StoreData {
111    fn ctx(&mut self) -> wasmtime_wasi::WasiCtxView<'_> {
112        wasmtime_wasi::WasiCtxView {
113            ctx:   &mut self.wasi_ctx,
114            table: &mut self.wasi_table,
115        }
116    }
117}
118
119/// Map WIT log-level to internal `LogLevel`.
120const fn map_log_level(level: logging::LogLevel) -> LogLevel {
121    match level {
122        logging::LogLevel::Debug => LogLevel::Debug,
123        logging::LogLevel::Info => LogLevel::Info,
124        logging::LogLevel::Warn => LogLevel::Warn,
125        logging::LogLevel::Error => LogLevel::Error,
126    }
127}
128
129impl logging::Host for StoreData {
130    async fn log(&mut self, level: logging::LogLevel, message: String) {
131        self.log_message(map_log_level(level), &message);
132    }
133}
134
135// Reason: the `Host` traits are generated from the WIT world by wasmtime and are
136// async by construction. The signature is not ours to narrow, whatever any one
137// method's body ends up doing.
138#[allow(unknown_lints, clippy::unused_async_trait_impl)]
139impl context::Host for StoreData {
140    async fn get_event_payload(&mut self) -> String {
141        self.get_event_payload_json()
142            .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
143    }
144
145    async fn get_auth_context(&mut self) -> Result<String, String> {
146        let host = self.require_host_context()?;
147        let value = host.auth_context().map_err(|e| e.to_string())?;
148        serde_json::to_string(&value).map_err(|e| e.to_string())
149    }
150
151    async fn get_env_var(&mut self, name: String) -> Result<Option<String>, String> {
152        // Propagate a blocked-name refusal instead of collapsing it into `None`
153        // (#840): the guest must be able to distinguish "the allowlist refused
154        // you" (an error it should surface) from "allowlisted but unset".
155        let host = self.require_host_context()?;
156        host.env_var(&name).map_err(host_err_string)
157    }
158
159    async fn get_idempotency_token(&mut self) -> Option<String> {
160        let Ok(host) = self.require_host_context() else {
161            return None;
162        };
163        host.idempotency_token()
164    }
165}
166
167/// Stringify a host error for the WIT `result<_, string>` boundary, tagging a
168/// client error (4xx) as permanent so the runtime dead-letters it immediately
169/// instead of retrying (parity with the Deno op path).
170#[allow(clippy::needless_pass_by_value)] // Reason: consumed via `.map_err` at each WIT call site
171fn host_err_string(error: fraiseql_error::FraiseQLError) -> String {
172    if error.is_client_error() {
173        format!("{} {error}", crate::types::PERMANENT_ERROR_MARKER)
174    } else {
175        error.to_string()
176    }
177}
178
179impl io::Host for StoreData {
180    async fn query(&mut self, graphql: String, variables: String) -> Result<String, String> {
181        let host = self.require_host_context()?;
182        let vars: serde_json::Value =
183            serde_json::from_str(&variables).map_err(|e| e.to_string())?;
184        let result = host.query(&graphql, vars).await.map_err(host_err_string)?;
185        serde_json::to_string(&result).map_err(|e| e.to_string())
186    }
187
188    async fn sql_query(&mut self, sql: String, params: String) -> Result<String, String> {
189        let host = self.require_host_context()?;
190        let params_vec: Vec<serde_json::Value> =
191            serde_json::from_str(&params).map_err(|e| e.to_string())?;
192        let result = host.sql_query(&sql, &params_vec).await.map_err(host_err_string)?;
193        serde_json::to_string(&result).map_err(|e| e.to_string())
194    }
195
196    async fn http_request(
197        &mut self,
198        method: String,
199        url: String,
200        headers: Vec<(String, String)>,
201        body: Option<Vec<u8>>,
202    ) -> Result<io::HttpResponse, String> {
203        let host = self.require_host_context()?;
204        let response = host
205            .http_request(&method, &url, &headers, body.as_deref())
206            .await
207            .map_err(host_err_string)?;
208        Ok(io::HttpResponse {
209            status:  response.status,
210            headers: response.headers,
211            body:    response.body,
212        })
213    }
214
215    async fn storage_get(&mut self, bucket: String, key: String) -> Result<Vec<u8>, String> {
216        let host = self.require_host_context()?;
217        host.storage_get(&bucket, &key).await.map_err(host_err_string)
218    }
219
220    async fn storage_put(
221        &mut self,
222        bucket: String,
223        key: String,
224        body: Vec<u8>,
225        content_type: String,
226    ) -> Result<(), String> {
227        let host = self.require_host_context()?;
228        host.storage_put(&bucket, &key, &body, &content_type)
229            .await
230            .map_err(host_err_string)
231    }
232
233    async fn send_email(&mut self, request: String) -> Result<String, String> {
234        let host = self.require_host_context()?;
235        let req: crate::outbound::SendEmailRequest =
236            serde_json::from_str(&request).map_err(|e| e.to_string())?;
237        let response = host.send_email(&req).await.map_err(host_err_string)?;
238        serde_json::to_string(&response).map_err(|e| e.to_string())
239    }
240}
241
242impl wasmtime::ResourceLimiter for StoreData {
243    fn memory_growing(
244        &mut self,
245        _current: usize,
246        desired: usize,
247        _maximum: Option<usize>,
248    ) -> wasmtime::Result<bool> {
249        self.memory_current_bytes = desired as u64;
250        if self.memory_current_bytes > self.memory_peak_bytes {
251            self.memory_peak_bytes = self.memory_current_bytes;
252        }
253        let max = self.limits.max_memory_bytes;
254        if self.memory_current_bytes > max {
255            return Err(wasmtime::Error::msg(format!(
256                "Memory limit exceeded: {} > {max}",
257                self.memory_current_bytes
258            )));
259        }
260        Ok(true)
261    }
262
263    fn table_growing(
264        &mut self,
265        _current: usize,
266        _desired: usize,
267        _maximum: Option<usize>,
268    ) -> wasmtime::Result<bool> {
269        Ok(true)
270    }
271}
272
273#[cfg(test)]
274mod tests;