This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
SpacetimeDB Workflow Engine - durable state machines for games. Workflows survive server restarts, with persistent timers, queryable state, and hierarchical composition.
This project uses mise for tool management and tasks.
# First-time setup
mise trust && mise install # Install Rust (stable), Node.js 22
mise run setup # Install dependencies, verify environment
# Build
mise run build # Debug build
mise run release # Release build
# Test
mise run test # All unit tests
mise run test-core # Core library tests only
mise run tv # Verbose test output
mise run test-ts # TypeScript integration tests
mise run integration # Full suite (deploy + TypeScript tests)
# Lint & Format
mise run fmt # Format code
mise run clippy # Run lints
mise run verify # fmt + clippy + test
# SpacetimeDB
mise run publish # Deploy to SpacetimeDB
mise run logs # View logs (follow mode)
mise run generate-bindings # Regenerate TypeScript bindings-
core/(workflow-core): Pure Rust library with no SpacetimeDB dependencieshandler.rs:WorkflowHandlertrait +DynWorkflowHandlerfor type erasuretypes.rs:WorkflowResult,WorkflowContext,TimerRequestregistry.rs: Global workflow registry with lazy initializationtraits.rs:Timer,Signaltraits for type-safe events
-
macros/(workflow-macros): Proc macros#[workflow]: Transforms sequential async-style code into state machines#[derive(Timer)]: Generatesname(),from_name()for unit enums#[derive(Signal)]: Generatesfrom_name_and_payload()with auto-deserializationinstall!{}: Generates SpacetimeDB tables, reducers, and workflow registration
-
example/(workflow-example): SpacetimeDB module demonstrating integration
Macro-Based Workflows: The #[workflow] macro transforms sequential code into state machines:
- Identifies await points (
timer!,signal!,spawn!,procedure!,select!) - Generates state struct with phase tracking and mutable variable persistence
- Creates
WorkflowHandlerimplementation automatically
Lazy Registration: The install! macro generates __ensure_workflows_registered() with an AtomicBool flag. Each generated reducer calls this first, ensuring workflows survive module updates (WASM reloads reset static state, init doesn't re-run).
Workflow: Primary state table (id, workflow_type, entity_id, status, current_step, state_data)WorkflowTimer: Scheduled table withscheduled_atfor timer firingWorkflowSubscription: Signal subscriptions for broadcast routing (signal_type, filter_value)LastWorkflowId: Stores last created workflow ID (workaround for reducer return limitations)
Each workflow automatically generates a {Name}WorkflowView struct containing init + all tracked mutable variables. Use {Name}Workflow::view(state_data) to parse workflow state for querying (e.g., quest progress for NPC dialogs).
workflow_start: Create and start a workflowworkflow_signal: Send signal to running workflow (uses snake_case signal name)workflow_broadcast_signal: Send signal to all matching subscriptionsworkflow_cancel: Cancel a workflowworkflow_timer_fire: Called automatically by SpacetimeDB scheduler
Write workflows as sequential code with the #[workflow] macro:
use workflow_core::prelude::*;
use workflow_macros::{workflow, Timer, Signal};
#[derive(Timer)]
enum BuffTimer { Expire }
#[derive(Signal)]
enum BuffSignal {
Dispel,
Stack(u32), // Payload auto-deserialized
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BuffInit { duration_secs: u64 }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BuffResult { final_stacks: u32 }
#[workflow]
fn buff(init: BuffInit) -> Result<BuffResult> {
let mut stacks: u32 = 1; // Mutable vars need explicit type
loop {
select! {
timer!(BuffTimer::Expire, init.duration_secs.secs()) => break,
signal!(BuffSignal::Dispel) => break,
signal!(BuffSignal::Stack(n)) => {
stacks += n;
continue
},
}.await;
}
Ok(BuffResult { final_stacks: stacks })
}
// Register in install! macro
install! {
"buff" => BuffWorkflow,
}| Feature | Syntax |
|---|---|
| Timer await | timer!(Timer::Variant, duration).await |
| Signal in select | signal!(Signal::Variant) |
| Payload binding | signal!(Signal::Stack(n)) |
| Spawn child | spawn!("workflow", init).await |
| Procedure call | procedure!("name", args).await |
| Select | select! { timer!(...) => {...}, signal!(...) => {...} }.await |
| For loops | for i in 0..n { spawn!(...).await } |
| Conditionals | if cond { timer!(...).await } else { timer!(...).await } |
| Mutable vars | let mut x: Type = val; (explicit type required) |
| Reducer call | reducer!(reducer_ctx, my_reducer(args)) (fire-and-forget) |
| Subscribe | subscribe!(workflow_subs, Signal::Variant(filter)) (for broadcast) |
- Unit tests:
core/src/has tests for handlers, types, traits - Property tests:
core/src/proptest_tests.rsfor serialization roundtrips - Integration tests:
tests/typescript/integration.test.tsuses SpacetimeDB TypeScript SDK - Test reducers:
example/src/integration_tests.rsprovides test entry points
- Module updates reset static state: Always register workflows in
install!{}, notinit - Reducer return types: SpacetimeDB reducers can only return
()orResult<(), E>- useLastWorkflowIdtable to retrieve created IDs - Timer variants must be unit:
#[derive(Timer)]only works on unit enums; use#[derive(Signal)]for data - Workflow status after timer: Workflows with pending timers are
Suspended, notRunning - Mutable variables need explicit types:
let mut count: u32 = 0;notlet mut count = 0; - Signal names are snake_case:
EnemyKilled→"enemy_killed"forworkflow_signalandworkflow_broadcast_signal