A hands-on guide from zero to working workflow.
A buff workflow that:
- Starts with 1 stack
- Expires after a duration
- Can be dispelled via signal
- Can have stacks added via signal
Simple, but demonstrates all core concepts.
- Rust installed
- SpacetimeDB CLI installed (install guide)
- This project cloned
Let's start with the absolute minimum: a workflow that waits and then completes.
Every workflow needs init and result types:
// src/workflows/buff.rs
use workflow_core::prelude::*;
use workflow_macros::{workflow, Timer};
#[derive(Timer)]
pub enum BuffTimer {
Expire,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffInit {
pub duration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffResult {
pub expired: bool,
}#[workflow]
fn buff(init: BuffInit) -> Result<BuffResult> {
// Wait for the timer to expire
timer!(BuffTimer::Expire, init.duration_secs.secs()).await;
// Return result
Ok(BuffResult { expired: true })
}That's it! The #[workflow] macro transforms this into a state machine that:
- Schedules a timer when started
- Persists the waiting state to the database
- Completes when the timer fires
Add it to your install! macro:
// src/lib.rs
use workflow_macros::install;
install! {
"buff" => BuffWorkflow,
}# Build
cargo build --release
# Start SpacetimeDB (if not running)
spacetime start
# Publish
spacetime publish workflow-engine --project-path . --clear-database
# Start a buff with 5 second duration
spacetime call workflow-engine workflow_start '["buff", null, null, "{\"duration_secs\":5}"]'
# Check the status (should be Suspended, waiting for timer)
spacetime sql workflow-engine "SELECT id, status FROM workflow"
# Wait 5 seconds, check again (should be Completed)
spacetime sql workflow-engine "SELECT id, status FROM workflow"Checkpoint: You have a working (if simple) workflow!
Now let's add the ability to dispel the buff early.
use workflow_macros::{workflow, Timer, Signal};
#[derive(Timer)]
pub enum BuffTimer {
Expire,
}
#[derive(Signal)]
pub enum BuffSignal {
Dispel,
}#[workflow]
fn buff(init: BuffInit) -> Result<BuffResult> {
// Wait for EITHER timer OR signal
select! {
timer!(BuffTimer::Expire, init.duration_secs.secs()) => {
Ok(BuffResult { expired: true, was_dispelled: false })
},
signal!(BuffSignal::Dispel) => {
Ok(BuffResult { expired: false, was_dispelled: true })
},
}.await
}The select! macro waits for the first event to occur. If the timer fires first, the buff expired naturally. If a dispel signal arrives first, it was dispelled.
# Rebuild and republish
cargo build --release
spacetime publish workflow-engine --project-path . --clear-database
# Start a buff with 60 second duration
spacetime call workflow-engine workflow_start '["buff", null, null, "{\"duration_secs\":60}"]'
# Get the workflow ID
spacetime sql workflow-engine "SELECT id FROM workflow"
# Let's say it's ID 1
# Dispel it before the timer expires
spacetime call workflow-engine workflow_signal '[1, "dispel", "[]"]'
# Check - should be Completed with was_dispelled = true
spacetime sql workflow-engine "SELECT status FROM workflow WHERE id = 1"Checkpoint: Your workflow responds to external events!
Let's add stacking - the buff can have multiple stacks added.
#[derive(Signal)]
pub enum BuffSignal {
Dispel,
Stack(u32), // Payload: number of stacks to add
}#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffResult {
pub final_stacks: u32,
pub was_dispelled: bool,
}
#[workflow]
fn buff(init: BuffInit) -> Result<BuffResult> {
// Mutable variable - MUST have explicit type annotation
let mut stacks: u32 = 1;
loop {
select! {
timer!(BuffTimer::Expire, init.duration_secs.secs()) => break,
signal!(BuffSignal::Dispel) => {
return Ok(BuffResult {
final_stacks: stacks,
was_dispelled: true,
})
},
signal!(BuffSignal::Stack(n)) => {
stacks += n;
continue // Keep looping
},
}.await;
}
Ok(BuffResult {
final_stacks: stacks,
was_dispelled: false,
})
}Key points:
let mut stacks: u32— Mutable variables need explicit type annotationsstacksis persisted across await points — It survives server restarts!continue— Re-enters the loop, waiting for the next eventbreak— Exits the loop, continues to the code after
# Rebuild and republish
cargo build --release
spacetime publish workflow-engine --project-path . --clear-database
# Start a buff
spacetime call workflow-engine workflow_start '["buff", null, null, "{\"duration_secs\":60}"]'
# Assume ID 1
# Add 5 stacks
spacetime call workflow-engine workflow_signal '[1, "stack", "5"]'
# Add 3 more stacks
spacetime call workflow-engine workflow_signal '[1, "stack", "3"]'
# Dispel it
spacetime call workflow-engine workflow_signal '[1, "dispel", "[]"]'
# Check result - should show 9 stacks (1 initial + 5 + 3)
spacetime logs workflow-engine | grep "final_stacks"Checkpoint: Your workflow maintains state across multiple events!
Let's add different behavior based on the init data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffInit {
pub duration_secs: u64,
pub is_debuff: bool, // Debuffs can't be dispelled
}
#[workflow]
fn buff(init: BuffInit) -> Result<BuffResult> {
let mut stacks: u32 = 1;
if init.is_debuff {
// Debuffs just wait for expiration
timer!(BuffTimer::Expire, init.duration_secs.secs()).await;
} else {
// Regular buffs can be dispelled or stacked
loop {
select! {
timer!(BuffTimer::Expire, init.duration_secs.secs()) => break,
signal!(BuffSignal::Dispel) => {
return Ok(BuffResult {
final_stacks: stacks,
was_dispelled: true,
})
},
signal!(BuffSignal::Stack(n)) => {
stacks += n;
continue
},
}.await;
}
}
Ok(BuffResult {
final_stacks: stacks,
was_dispelled: false,
})
}The if/else with await points inside works! Each branch can have different timers/signals.
Workflows are just table rows. Clients can subscribe:
-- Find all active buffs
SELECT * FROM workflow
WHERE workflow_type = 'buff'
AND status IN ('Running', 'Suspended')
-- Get a specific workflow's state
SELECT id, status, state_data
FROM workflow
WHERE id = 1import { DbConnection } from './bindings';
const conn = await DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('workflow-engine')
.build();
// Subscribe to buff workflows
conn.subscriptionBuilder()
.subscribe("SELECT * FROM workflow WHERE workflow_type = 'buff'");
// React to changes
conn.db.workflow.onInsert((workflow) => {
console.log(`Buff ${workflow.id}: ${workflow.status}`);
});
// Start a buff
await conn.reducers.workflowStart('buff', null, null,
JSON.stringify({ duration_secs: 30 }));
// Dispel a buff
await conn.reducers.workflowSignal(workflowId, 'dispel', '[]');Here's the final buff.rs:
use workflow_core::prelude::*;
use workflow_macros::{workflow, Timer, Signal};
#[derive(Timer)]
pub enum BuffTimer {
Expire,
}
#[derive(Signal)]
pub enum BuffSignal {
Dispel,
Stack(u32),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffInit {
pub effect_type: String,
pub magnitude: i32,
pub duration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffResult {
pub effect_type: String,
pub was_dispelled: bool,
pub final_stacks: u32,
}
#[workflow]
fn buff(init: BuffInit) -> Result<BuffResult> {
let mut stacks: u32 = 1;
loop {
select! {
timer!(BuffTimer::Expire, init.duration_secs.secs()) => break,
signal!(BuffSignal::Dispel) => break,
signal!(BuffSignal::Stack(n)) => {
stacks += n;
continue
},
}.await;
}
Ok(BuffResult {
effect_type: init.effect_type.clone(),
was_dispelled: false,
final_stacks: stacks,
})
}| Concept | How We Used It |
|---|---|
| #[workflow] | Transform sequential code into state machine |
| timer!().await | Wait for a timed event |
| select! | Wait for first of multiple events |
| signal!() | Handle external signals |
| Signal payloads | Stack(n) extracts the value |
| Mutable variables | let mut stacks: u32 persists across awaits |
| Loops | loop { select! { ... }.await } for continuous behavior |
| #[derive(Timer)] | Type-safe timer names |
| #[derive(Signal)] | Type-safe signals with auto-deserialized payloads |
Now that you've built a workflow:
→ Examples — See real-world patterns (patrol, combat, production) → API Reference — Complete API documentation