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#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct TokenPair {
238 pub access_token: String,
239 pub refresh_token: String,
240}
241
242#[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 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 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
300pub 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 pub fn data(&self) -> Option<D> {
319 self.view.read().clone()
320 }
321
322 pub fn data_signal(&self) -> Signal<Option<D>> {
324 self.view
325 }
326
327 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 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 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}