#![cfg(feature = "sqlite")]
use durare::{
DurableContext, DurableEngine, Error, PortableWorkflowError, Result, Serializer,
SqliteProvider, WorkflowOptions,
};
use std::sync::Arc;
fn temp_db_url(tag: &str) -> (String, std::path::PathBuf) {
let mut p = std::env::temp_dir();
p.push(format!("durare-ser-{tag}-{}.db", uuid::Uuid::new_v4()));
(format!("sqlite://{}", p.display()), p)
}
async fn engine_with(url: &str, fmt: Serializer) -> Result<DurableEngine> {
let provider = SqliteProvider::connect(url).await?.with_serializer(fmt);
let mut engine = DurableEngine::new(Arc::new(provider)).await?;
engine.register("greet", |ctx: DurableContext, name: String| async move {
let msg = ctx
.step("build", || async { Ok::<_, Error>(format!("hi {name}")) })
.await?;
Ok::<_, Error>(msg)
});
Ok(engine)
}
async fn cross_format(writer: Serializer, reader: Serializer, tag: &str) -> Result<()> {
let (url, path) = temp_db_url(tag);
{
let engine = engine_with(&url, writer).await?;
let out: String = engine
.start::<_, String>(
"greet",
"ada".to_string(),
WorkflowOptions::with_id("wf-ser"),
)
.await?
.result()
.await?;
assert_eq!(out, "hi ada");
}
{
let engine = engine_with(&url, reader).await?;
let handle = engine.retrieve_workflow::<String>("wf-ser").await?;
let status = handle.get_status().await?;
assert_eq!(status.input, serde_json::json!("ada"));
assert_eq!(status.output, Some(serde_json::json!("hi ada")));
assert_eq!(handle.result().await?, "hi ada");
}
let _ = std::fs::remove_file(path);
Ok(())
}
#[tokio::test]
async fn portable_error_is_stored_as_envelope() -> Result<()> {
let (url, path) = temp_db_url("err-p");
let provider = SqliteProvider::connect(&url)
.await?
.with_serializer(Serializer::Portable);
let mut engine = DurableEngine::new(Arc::new(provider)).await?;
engine.register("boom", |_ctx: DurableContext, _: ()| async move {
Err::<(), _>(Error::app("kaboom"))
});
let outcome = engine
.start::<_, ()>("boom", (), WorkflowOptions::with_id("wf-err"))
.await?
.result()
.await;
assert!(outcome.is_err());
let handle = engine.retrieve_workflow::<()>("wf-err").await?;
let status = handle.get_status().await?;
assert_eq!(status.status, "ERROR");
assert_eq!(status.error.as_deref(), Some("kaboom"));
let info = status
.error_info
.expect("a portable error carries structured info");
assert_eq!(info.name, "Portable Error");
assert_eq!(info.message, "kaboom");
assert!(info.code.is_none() && info.data.is_none());
let _ = std::fs::remove_file(path);
Ok(())
}
#[tokio::test]
async fn portable_typed_error_round_trips() -> Result<()> {
let (url, path) = temp_db_url("err-typed");
{
let provider = SqliteProvider::connect(&url)
.await?
.with_serializer(Serializer::Portable);
let mut engine = DurableEngine::new(Arc::new(provider)).await?;
engine.register("validate", |_ctx: DurableContext, _: ()| async move {
Err::<(), _>(Error::Portable(Box::new(PortableWorkflowError {
name: "ValidationError".to_string(),
message: "bad email".to_string(),
code: Some(serde_json::json!(400)),
data: Some(serde_json::json!({"field": "email"})),
})))
});
let outcome = engine
.start::<_, ()>("validate", (), WorkflowOptions::with_id("wf-typed"))
.await?
.result()
.await;
assert!(matches!(outcome, Err(Error::Portable(ref pe)) if pe.name == "ValidationError"));
}
{
let provider = SqliteProvider::connect(&url)
.await?
.with_serializer(Serializer::Portable);
let engine = DurableEngine::new(Arc::new(provider)).await?;
let handle = engine.retrieve_workflow::<()>("wf-typed").await?;
let status = handle.get_status().await?;
let info = status
.error_info
.expect("structured error survives storage");
assert_eq!(info.name, "ValidationError");
assert_eq!(info.code, Some(serde_json::json!(400)));
assert_eq!(info.data, Some(serde_json::json!({"field": "email"})));
let handle = handle;
match handle.result().await {
Err(Error::Portable(pe)) => {
assert_eq!(pe.name, "ValidationError");
assert_eq!(pe.message, "bad email");
assert_eq!(pe.code, Some(serde_json::json!(400)));
}
other => panic!("expected a reconstructed portable error, got {other:?}"),
}
}
let _ = std::fs::remove_file(path);
Ok(())
}
#[tokio::test]
async fn default_error_stays_bare() -> Result<()> {
let (url, path) = temp_db_url("err-d");
let provider = SqliteProvider::connect(&url).await?;
let mut engine = DurableEngine::new(Arc::new(provider)).await?;
engine.register("boom", |_ctx: DurableContext, _: ()| async move {
Err::<(), _>(Error::app("kaboom"))
});
let outcome = engine
.start::<_, ()>("boom", (), WorkflowOptions::with_id("wf-err"))
.await?
.result()
.await;
assert!(outcome.is_err());
let handle = engine.retrieve_workflow::<()>("wf-err").await?;
let status = handle.get_status().await?;
assert_eq!(status.error.as_deref(), Some("kaboom"));
assert!(status.error_info.is_none());
let _ = std::fs::remove_file(path);
Ok(())
}
#[tokio::test]
async fn portable_written_default_read() -> Result<()> {
cross_format(Serializer::Portable, Serializer::Json, "p2d").await
}
#[tokio::test]
async fn default_written_portable_read() -> Result<()> {
cross_format(Serializer::Json, Serializer::Portable, "d2p").await
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Clone)]
struct Row {
id: i64,
tags: Vec<String>,
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Clone)]
struct Report {
title: String,
rows: Vec<Row>,
meta: std::collections::HashMap<String, i64>,
note: Option<String>,
}
fn sample_report() -> Report {
let mut meta = std::collections::HashMap::new();
meta.insert("total".to_string(), 3);
meta.insert("ok".to_string(), 2);
Report {
title: "quarterly".to_string(),
rows: vec![
Row {
id: 1,
tags: vec!["a".to_string(), "b".to_string()],
},
Row {
id: 2,
tags: vec![],
},
],
meta,
note: None,
}
}
#[tokio::test]
async fn nested_value_round_trips_through_step_checkpoint() -> Result<()> {
use std::sync::atomic::{AtomicUsize, Ordering};
static BUILDS: AtomicUsize = AtomicUsize::new(0);
let (url, path) = temp_db_url("nested");
let provider = SqliteProvider::connect(&url).await?;
let mut engine = DurableEngine::new(Arc::new(provider)).await?;
engine.register("report", |ctx: DurableContext, _: ()| async move {
let r = ctx
.step("build", || async {
BUILDS.fetch_add(1, Ordering::SeqCst);
Ok::<_, Error>(sample_report())
})
.await?;
Ok::<_, Error>(r)
});
let a: Report = engine
.start("report", (), WorkflowOptions::with_id("wf-nested"))
.await?
.result()
.await?;
assert_eq!(a, sample_report());
let b: Report = engine
.start("report", (), WorkflowOptions::with_id("wf-nested"))
.await?
.result()
.await?;
assert_eq!(b, sample_report());
assert_eq!(
BUILDS.load(Ordering::SeqCst),
1,
"the step ran once; the resubmit returns the stored output, not a re-run"
);
drop(engine);
let _ = std::fs::remove_file(path);
Ok(())
}