Skip to main content

forge_dioxus/
types.rs

1
2use std::marker::PhantomData;
3use std::rc::Rc;
4use std::time::Duration;
5
6use dioxus::prelude::{ReadableExt, Signal, WritableExt};
7use serde::de::DeserializeOwned;
8use serde::{Deserialize, Serialize};
9
10use crate::ForgeClient;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
13pub struct ForgeError {
14    pub code: String,
15    pub message: String,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub details: Option<serde_json::Value>,
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct ForgeClientError {
22    pub code: String,
23    pub message: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub details: Option<serde_json::Value>,
26}
27
28impl ForgeClientError {
29    pub fn new(
30        code: impl Into<String>,
31        message: impl Into<String>,
32        details: Option<serde_json::Value>,
33    ) -> Self {
34        Self {
35            code: code.into(),
36            message: message.into(),
37            details,
38        }
39    }
40
41    pub fn as_forge_error(&self) -> ForgeError {
42        ForgeError {
43            code: self.code.clone(),
44            message: self.message.clone(),
45            details: self.details.clone(),
46        }
47    }
48}
49
50impl std::fmt::Display for ForgeClientError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "{}: {}", self.code, self.message)
53    }
54}
55
56impl std::error::Error for ForgeClientError {}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
59#[serde(rename_all = "snake_case")]
60pub enum ConnectionState {
61    #[default]
62    Disconnected,
63    Connecting,
64    Connected,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct QueryState<T> {
69    pub loading: bool,
70    pub data: Option<T>,
71    pub error: Option<ForgeError>,
72}
73
74impl<T> Default for QueryState<T> {
75    fn default() -> Self {
76        Self {
77            loading: true,
78            data: None,
79            error: None,
80        }
81    }
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct SubscriptionState<T> {
86    pub loading: bool,
87    pub data: Option<T>,
88    pub error: Option<ForgeError>,
89    pub stale: bool,
90    pub connection_state: ConnectionState,
91}
92
93impl<T> Default for SubscriptionState<T> {
94    fn default() -> Self {
95        Self {
96            loading: true,
97            data: None,
98            error: None,
99            stale: false,
100            connection_state: ConnectionState::Disconnected,
101        }
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum JobStatus {
108    Pending,
109    Claimed,
110    Running,
111    Completed,
112    Retry,
113    Failed,
114    DeadLetter,
115    CancelRequested,
116    Cancelled,
117    NotFound,
118}
119
120impl Default for JobStatus {
121    fn default() -> Self {
122        Self::Pending
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct JobState<TOutput> {
128    pub job_id: String,
129    pub status: JobStatus,
130    pub progress: Option<f64>,
131    pub message: Option<String>,
132    pub output: Option<TOutput>,
133    pub error: Option<String>,
134}
135
136impl<TOutput> Default for JobState<TOutput> {
137    fn default() -> Self {
138        Self {
139            job_id: String::new(),
140            status: JobStatus::Pending,
141            progress: None,
142            message: None,
143            output: None,
144            error: None,
145        }
146    }
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150pub struct JobExecutionState<TOutput> {
151    pub loading: bool,
152    pub connection_state: ConnectionState,
153    pub state: JobState<TOutput>,
154}
155
156impl<TOutput> Default for JobExecutionState<TOutput> {
157    fn default() -> Self {
158        Self {
159            loading: true,
160            connection_state: ConnectionState::Disconnected,
161            state: JobState::default(),
162        }
163    }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum WorkflowStatus {
169    Created,
170    Running,
171    Waiting,
172    Completed,
173    Compensating,
174    Compensated,
175    Failed,
176    NotFound,
177}
178
179impl Default for WorkflowStatus {
180    fn default() -> Self {
181        Self::Created
182    }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub struct WorkflowStepState {
188    pub name: String,
189    pub status: String,
190    pub error: Option<String>,
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct WorkflowState<TOutput> {
195    pub workflow_id: String,
196    pub status: WorkflowStatus,
197    pub step: Option<String>,
198    pub waiting_for: Option<String>,
199    pub steps: Vec<WorkflowStepState>,
200    pub output: Option<TOutput>,
201    pub error: Option<String>,
202}
203
204impl<TOutput> Default for WorkflowState<TOutput> {
205    fn default() -> Self {
206        Self {
207            workflow_id: String::new(),
208            status: WorkflowStatus::Created,
209            step: None,
210            waiting_for: None,
211            steps: Vec::new(),
212            output: None,
213            error: None,
214        }
215    }
216}
217
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219pub struct WorkflowExecutionState<TOutput> {
220    pub loading: bool,
221    pub connection_state: ConnectionState,
222    pub state: WorkflowState<TOutput>,
223}
224
225impl<TOutput> Default for WorkflowExecutionState<TOutput> {
226    fn default() -> Self {
227        Self {
228            loading: true,
229            connection_state: ConnectionState::Disconnected,
230            state: WorkflowState::default(),
231        }
232    }
233}
234
235/// An access token + refresh token pair returned by auth endpoints.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct TokenPair {
238    pub access_token: String,
239    pub refresh_token: String,
240}
241
242/// Mutation handle returned by `use_forge_mutation`. Clone into event handlers,
243/// call `.call(args)` to execute.
244#[derive(Clone)]
245pub struct Mutation<A, R> {
246    client: ForgeClient,
247    function_name: &'static str,
248    _phantom: PhantomData<fn(A) -> R>,
249}
250
251impl<A, R> Mutation<A, R>
252where
253    A: Serialize + 'static,
254    R: DeserializeOwned + 'static,
255{
256    pub(crate) fn new(client: ForgeClient, function_name: &'static str) -> Self {
257        Self {
258            client,
259            function_name,
260            _phantom: PhantomData,
261        }
262    }
263
264    pub async fn call(&self, args: A) -> Result<R, ForgeClientError> {
265        self.client.call(self.function_name, args).await
266    }
267
268    /// Fire-and-forget: spawns the mutation and routes errors to the global
269    /// mutation error handler registered on [`ForgeClient`].
270    pub fn fire(&self, args: A) {
271        let client = self.client.clone();
272        let function_name = self.function_name;
273        dioxus::prelude::spawn(async move {
274            if let Err(err) = client.call::<A, R>(function_name, args).await {
275                client.notify_mutation_error(err);
276            }
277        });
278    }
279
280    /// Fire-and-forget with a one-off error callback that overrides the global
281    /// handler for this invocation.
282    pub fn fire_with(&self, args: A, on_error: impl FnOnce(ForgeClientError) + 'static) {
283        let client = self.client.clone();
284        let function_name = self.function_name;
285        dioxus::prelude::spawn(async move {
286            if let Err(err) = client.call::<A, R>(function_name, args).await {
287                on_error(err);
288            }
289        });
290    }
291}
292
293pub(crate) struct PendingOptimistic<D> {
294    pub(crate) snapshot: Option<D>,
295    pub(crate) generation: u64,
296}
297
298type ApplyFn<D, A> = Rc<dyn Fn(&D, &A) -> D>;
299
300/// Handle returned by [`use_optimistic`](crate::use_optimistic). Provides
301/// `.fire()` that applies an optimistic transform immediately and `.data()`
302/// that returns the derived view layering local patches over subscription data.
303pub struct OptimisticMutation<A: 'static, R: 'static, D: 'static> {
304    pub(crate) mutation: Mutation<A, R>,
305    pub(crate) view: Signal<Option<D>>,
306    pub(crate) apply: ApplyFn<D, A>,
307    pub(crate) subscription: Signal<SubscriptionState<D>>,
308    pub(crate) pending: Signal<Option<PendingOptimistic<D>>>,
309}
310
311impl<A, R, D> OptimisticMutation<A, R, D>
312where
313    A: Serialize + Clone + 'static,
314    R: DeserializeOwned + 'static,
315    D: Clone + 'static,
316{
317    /// The current data, with any pending optimistic patches applied.
318    pub fn data(&self) -> Option<D> {
319        self.view.read().clone()
320    }
321
322    /// Signal accessor for use in RSX.
323    pub fn data_signal(&self) -> Signal<Option<D>> {
324        self.view
325    }
326
327    /// Fire the mutation with the optimistic transform applied immediately.
328    /// On SSE update the server data replaces the optimistic patch. On error
329    /// the view reverts to the pre-mutation snapshot.
330    pub fn fire(&self, args: A) {
331        let mut view = self.view;
332        let mut pending = self.pending;
333        let subscription = self.subscription;
334
335        let current_data = subscription.read().data.clone();
336        let generation = pending
337            .read()
338            .as_ref()
339            .map(|p| p.generation + 1)
340            .unwrap_or(1);
341
342        if let Some(ref data) = current_data {
343            let optimistic = (self.apply)(data, &args);
344            view.set(Some(optimistic));
345        }
346
347        pending.set(Some(PendingOptimistic {
348            snapshot: current_data,
349            generation,
350        }));
351
352        // TTL safety net: revert if SSE hasn't confirmed within 3 seconds
353        let ttl_generation = generation;
354        let mut ttl_pending = pending;
355        let mut ttl_view = view;
356        let ttl_subscription = subscription;
357        dioxus::prelude::spawn(async move {
358            crate::hooks::sleep(Duration::from_secs(3)).await;
359            let still_pending = ttl_pending
360                .read()
361                .as_ref()
362                .is_some_and(|p| p.generation == ttl_generation);
363            if still_pending {
364                ttl_view.set(ttl_subscription.read().data.clone());
365                ttl_pending.set(None);
366            }
367        });
368
369        // Send the actual mutation
370        let client = self.mutation.client.clone();
371        let function_name = self.mutation.function_name;
372        dioxus::prelude::spawn(async move {
373            if let Err(err) = client.call::<A, R>(function_name, args).await {
374                let should_rollback = pending
375                    .read()
376                    .as_ref()
377                    .is_some_and(|p| p.generation == generation);
378                if should_rollback
379                    && let Some(p) = pending.write().take()
380                {
381                    view.set(p.snapshot);
382                }
383                client.notify_mutation_error(err);
384            }
385        });
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use serde_json::json;
393
394    #[test]
395    fn client_error_as_forge_error_preserves_code_message_and_details() {
396        let err = ForgeClientError::new(
397            "VALIDATION",
398            "Name is required",
399            Some(json!({"field": "name"})),
400        );
401
402        assert_eq!(
403            err.as_forge_error(),
404            ForgeError {
405                code: "VALIDATION".into(),
406                message: "Name is required".into(),
407                details: Some(json!({"field": "name"})),
408            }
409        );
410    }
411
412    #[test]
413    fn subscription_state_default_is_loading_and_disconnected() {
414        let state = SubscriptionState::<Vec<String>>::default();
415
416        assert!(state.loading);
417        assert_eq!(state.data, None);
418        assert_eq!(state.error, None);
419        assert!(!state.stale);
420        assert_eq!(state.connection_state, ConnectionState::Disconnected);
421    }
422
423    #[test]
424    fn job_and_workflow_status_serialize_in_snake_case() {
425        assert_eq!(serde_json::to_string(&JobStatus::CancelRequested).unwrap(), "\"cancel_requested\"");
426        assert_eq!(serde_json::to_string(&WorkflowStatus::NotFound).unwrap(), "\"not_found\"");
427    }
428
429    #[test]
430    fn query_and_subscription_state_defaults_are_safe_for_initial_render() {
431        let query = QueryState::<Vec<String>>::default();
432        let subscription = SubscriptionState::<Vec<String>>::default();
433
434        assert!(query.loading);
435        assert!(query.data.is_none());
436        assert!(query.error.is_none());
437
438        assert!(subscription.loading);
439        assert!(subscription.data.is_none());
440        assert!(subscription.error.is_none());
441        assert!(!subscription.stale);
442        assert_eq!(subscription.connection_state, ConnectionState::Disconnected);
443    }
444
445    #[test]
446    fn job_and_workflow_execution_state_defaults_start_disconnected() {
447        let job = JobExecutionState::<serde_json::Value>::default();
448        let workflow = WorkflowExecutionState::<serde_json::Value>::default();
449
450        assert!(job.loading);
451        assert_eq!(job.connection_state, ConnectionState::Disconnected);
452        assert_eq!(job.state.status, JobStatus::Pending);
453
454        assert!(workflow.loading);
455        assert_eq!(workflow.connection_state, ConnectionState::Disconnected);
456        assert_eq!(workflow.state.status, WorkflowStatus::Created);
457    }
458}
459
460#[derive(Debug, Clone)]
461pub enum StreamEvent<T> {
462    Connection(ConnectionState),
463    Data(T),
464    Error(ForgeClientError),
465}
466
467#[derive(Debug, Clone, Deserialize)]
468pub(crate) struct RpcEnvelopeRaw {
469    pub success: bool,
470    #[serde(default)]
471    pub data: Option<serde_json::Value>,
472    #[serde(default)]
473    pub error: Option<ForgeError>,
474}
475
476#[derive(Debug, Clone, Deserialize)]
477pub(crate) struct ConnectedEvent {
478    pub session_id: Option<String>,
479    pub session_secret: Option<String>,
480}
481
482#[derive(Debug, Clone, Deserialize)]
483pub(crate) struct SseEnvelopeRaw {
484    #[serde(default)]
485    pub target: Option<String>,
486    #[serde(default)]
487    pub payload: Option<serde_json::Value>,
488    #[serde(default)]
489    pub code: Option<String>,
490    #[serde(default)]
491    pub message: Option<String>,
492}