Transform the workflow engine from an event-handler model to a Temporal-like model where:
- Workflows are sequential code that can suspend at side effects
- Reducers are fire-and-forget side effects (no return value, workflow continues)
- Procedures are side effects that return values (workflow suspends until callback)
| Construct | Syntax | Suspends? | Behavior |
|---|---|---|---|
| Reducer | reducer!(fn(args)) |
No | Fire-and-forget, same transaction |
| Procedure | procedure!(fn(args)).await |
Yes | Schedules, suspends, resumes via callback |
| Timer | timer!(Timer::Variant, duration).await |
Yes | Suspends until timer fires |
| Signal | signal!(Signal::Variant(payload)).await |
Yes | Suspends until signal received |
| Select | select! { ... }.await |
Yes | Suspends until first event matches |
| Spawn | spawn!(workflow, init).await |
Yes | Spawns child, suspends until complete |
| Subscribe | subscribe!(Signal::Variant(filter)) |
No | Registers filtered signal subscription |
#[workflow]
fn buff(init: BuffInit) -> WorkflowResult<BuffResult> {
let mut stacks = 1u32;
let duration = init.duration_secs.secs();
loop {
select! {
timer!(BuffTimer::Expire, duration) => break,
signal!(BuffSignal::Refresh) => continue,
signal!(BuffSignal::Stack(n)) => { stacks += n; continue; },
signal!(BuffSignal::Dispel) => break,
}.await;
}
Ok(BuffResult { final_stacks: stacks })
}#[workflow]
fn child_task(init: ChildInit) -> WorkflowResult<i32> {
timer!(ChildTimer::Complete, init.delay_secs.secs()).await;
Ok(init.result_value)
}
#[workflow]
fn parent(task_count: u32) -> WorkflowResult<i32> {
let mut total = 0;
for i in 0..task_count {
let result = spawn!(child_task, ChildInit {
task_name: format!("task_{}", i),
delay_secs: 1,
result_value: 10 * (i as i32 + 1),
}).await;
total += result;
}
Ok(total)
}#[workflow]
fn combat(player_id: u64, enemy_id: u64) -> WorkflowResult<CombatOutcome> {
let player = procedure!(get_combatant_stats(player_id)).await;
let enemy = procedure!(get_combatant_stats(enemy_id)).await;
let mut player_hp = player.health;
let mut enemy_hp = enemy.health;
loop {
select! {
timer!(CombatTimer::TurnTimeout, 30.secs()) => {
reducer!(spawn_effect(player_id, "defend"));
},
signal!(CombatSignal::Attack) => {
let calc = procedure!(calculate_damage(player_id, enemy_id)).await;
reducer!(apply_damage(enemy_id, calc.damage));
reducer!(spawn_effect(enemy_id, if calc.is_crit { "crit" } else { "hit" }));
enemy_hp -= calc.damage;
},
signal!(CombatSignal::Defend) => {
reducer!(spawn_effect(player_id, "defend"));
},
signal!(CombatSignal::Flee) => {
return Ok(CombatOutcome::Fled);
},
}.await;
let enemy_calc = procedure!(calculate_damage(enemy_id, player_id)).await;
reducer!(apply_damage(player_id, enemy_calc.damage));
player_hp -= enemy_calc.damage;
timer!(CombatTimer::AnimDelay, 300.millis()).await;
if enemy_hp <= 0 { return Ok(CombatOutcome::Victory); }
if player_hp <= 0 { return Ok(CombatOutcome::Defeat); }
}
}#[workflow]
fn quest(init: QuestInit) -> WorkflowResult<QuestOutcome> {
let objectives = procedure!(get_quest_objectives(init.quest_id)).await;
let mut progress: HashMap<ObjectiveId, u32> = HashMap::new();
// Register only the specific events we care about
for obj in &objectives {
match obj {
Objective::Kill { enemy_type, .. } => {
subscribe!(QuestSignal::EnemyKilled(enemy_type));
},
Objective::Collect { item_id, .. } => {
subscribe!(QuestSignal::ItemCollected(item_id));
},
Objective::Location { location_id, .. } => {
subscribe!(QuestSignal::LocationReached(location_id));
},
}
}
reducer!(show_quest_tracker(init.player_id, &objectives));
loop {
let event = signal!(QuestSignal).await;
match event {
QuestSignal::EnemyKilled(t) => { /* update progress */ },
QuestSignal::ItemCollected(i) => { /* update progress */ },
QuestSignal::LocationReached(l) => { /* update progress */ },
QuestSignal::Abandon => {
reducer!(hide_quest_tracker(init.player_id));
return Ok(QuestOutcome::Abandoned);
},
}
if objectives.all_complete(&progress) {
let rewards = procedure!(get_quest_rewards(init.quest_id)).await;
reducer!(grant_rewards(init.player_id, rewards));
reducer!(hide_quest_tracker(init.player_id));
return Ok(QuestOutcome::Completed { rewards });
}
}
}#[workflow]
fn patrol(init: PatrolInit) -> WorkflowResult<()> {
let waypoints = init.waypoints;
let home = init.home_position;
timer!(PatrolTimer::StartPatrol, 2.secs()).await;
loop {
for waypoint in &waypoints {
reducer!(move_to(ctx.entity_id, waypoint));
select! {
timer!(PatrolTimer::Arrival, waypoint.travel_time()) => {},
signal!(PatrolSignal::ThreatDetected(enemy_id)) => {
spawn!(combat, CombatInit { enemy_id }).await;
reducer!(move_to(ctx.entity_id, home));
timer!(PatrolTimer::Arrival, home.travel_time()).await;
continue;
},
}.await;
timer!(PatrolTimer::Wait, 3.secs()).await;
}
}
}#[workflow]
fn respawn(init: RespawnInit) -> WorkflowResult<()> {
timer!(RespawnTimer::Spawn, init.delay_secs.secs()).await;
reducer!(spawn_entity(init.entity_template, init.spawn_room_id));
Ok(())
}#[workflow]
fn production(init: ProductionInit) -> WorkflowResult<()> {
let blueprint = init.blueprint;
loop {
select! {
signal!(ProductionSignal::InventoryChange) => {
if !procedure!(check_inputs(ctx.entity_id, &blueprint)).await {
continue;
}
},
signal!(ProductionSignal::Halt(reason)) => {
signal!(ProductionSignal::Resume).await;
continue;
},
signal!(ProductionSignal::Stop) => {
return Ok(());
},
}.await;
reducer!(consume_inputs(ctx.entity_id, &blueprint));
timer!(ProductionTimer::CycleComplete, blueprint.cycle_time).await;
reducer!(deposit_outputs(ctx.entity_id, &blueprint));
}
}#[workflow]
fn npc_dialog(init: DialogInit) -> WorkflowResult<DialogOutcome> {
let mut node = init.start_node;
loop {
let dialog = procedure!(get_dialog_node(init.npc_id, node)).await;
reducer!(show_dialog(init.player_id, dialog.text, dialog.options));
select! {
timer!(DialogTimer::Timeout, 60.secs()) => {
reducer!(close_dialog(init.player_id));
return Ok(DialogOutcome::Abandoned);
},
signal!(DialogSignal::SelectOption(choice)) => {
match dialog.options[choice].action {
DialogAction::Continue(next) => { node = next; },
DialogAction::GiveQuest(quest_id) => {
reducer!(assign_quest(init.player_id, quest_id));
node = dialog.options[choice].next_node;
},
DialogAction::OpenShop => {
reducer!(open_shop_ui(init.player_id, init.npc_id));
return Ok(DialogOutcome::OpenedShop);
},
DialogAction::End => {
reducer!(close_dialog(init.player_id));
return Ok(DialogOutcome::Completed);
},
}
},
signal!(DialogSignal::Cancel) => {
reducer!(close_dialog(init.player_id));
return Ok(DialogOutcome::Abandoned);
},
}.await;
}
}#[workflow]
fn auction(init: AuctionInit) -> WorkflowResult<AuctionOutcome> {
let item = init.item;
let mut current_bid = init.starting_price;
let mut high_bidder: Option<PlayerId> = None;
let mut end_time = 60.secs();
reducer!(announce_auction(item, current_bid));
loop {
select! {
timer!(AuctionTimer::End, end_time) => {
match high_bidder {
Some(winner) => {
reducer!(transfer_item(init.seller, winner, item));
reducer!(transfer_gold(winner, init.seller, current_bid));
return Ok(AuctionOutcome::Sold { winner, price: current_bid });
},
None => {
reducer!(return_item(init.seller, item));
return Ok(AuctionOutcome::NoBids);
},
}
},
signal!(AuctionSignal::Bid { bidder, amount }) => {
if amount <= current_bid {
reducer!(notify_player(bidder, "Bid too low"));
continue;
}
if let Some(prev) = high_bidder {
reducer!(notify_player(prev, "You've been outbid!"));
}
high_bidder = Some(bidder);
current_bid = amount;
reducer!(announce_bid(item, bidder, amount));
if end_time < 10.secs() {
end_time = 10.secs();
}
},
signal!(AuctionSignal::Cancel) => {
if high_bidder.is_none() {
reducer!(return_item(init.seller, item));
return Ok(AuctionOutcome::Cancelled);
}
},
}.await;
}
}#[workflow]
fn trade(init: TradeInit) -> WorkflowResult<TradeOutcome> {
let player_a = init.initiator;
let player_b = init.target;
reducer!(lock_items(player_a, init.offer_a));
reducer!(lock_items(player_b, init.offer_b));
reducer!(show_trade_ui(player_a, player_b, &init.offer_a, &init.offer_b));
let mut a_accepted = false;
let mut b_accepted = false;
loop {
select! {
timer!(TradeTimer::Timeout, 60.secs()) => {
reducer!(unlock_items(player_a, init.offer_a));
reducer!(unlock_items(player_b, init.offer_b));
reducer!(close_trade_ui(player_a));
reducer!(close_trade_ui(player_b));
return Ok(TradeOutcome::Timeout);
},
signal!(TradeSignal::Accept(player)) => {
if player == player_a { a_accepted = true; }
if player == player_b { b_accepted = true; }
if a_accepted && b_accepted {
reducer!(transfer_items(player_a, player_b, init.offer_a));
reducer!(transfer_items(player_b, player_a, init.offer_b));
reducer!(close_trade_ui(player_a));
reducer!(close_trade_ui(player_b));
return Ok(TradeOutcome::Completed);
}
reducer!(show_accepted(player));
},
signal!(TradeSignal::Cancel(player)) => {
reducer!(unlock_items(player_a, init.offer_a));
reducer!(unlock_items(player_b, init.offer_b));
reducer!(close_trade_ui(player_a));
reducer!(close_trade_ui(player_b));
return Ok(TradeOutcome::Cancelled { by: player });
},
signal!(TradeSignal::ModifyOffer(player, new_items)) => {
a_accepted = false;
b_accepted = false;
if player == player_a {
reducer!(unlock_items(player_a, init.offer_a));
reducer!(lock_items(player_a, new_items));
}
reducer!(update_trade_ui(player_a, player_b));
},
}.await;
}
}#[workflow]
fn boss_fight(init: BossFightInit) -> WorkflowResult<BossFightOutcome> {
let boss_id = init.boss_id;
let raid_id = init.raid_id;
subscribe!(BossSignal::HealthChanged(boss_id));
subscribe!(BossSignal::Wipe(raid_id));
let enrage_deadline = 480.secs();
// Phase 1: 100% - 70%
reducer!(announce("Phase 1: The boss awakens!"));
reducer!(set_boss_phase(boss_id, 1));
loop {
select! {
timer!(BossTimer::Enrage, enrage_deadline) => {
reducer!(trigger_wipe(raid_id, "Enrage"));
return Ok(BossFightOutcome::Wipe);
},
signal!(BossSignal::HealthChanged(_, hp)) => {
if hp <= 70 { break; }
},
signal!(BossSignal::Wipe(_)) => {
return Ok(BossFightOutcome::Wipe);
},
}.await;
}
// Phase 2: Adds
reducer!(announce("Phase 2: Minions, attack!"));
reducer!(set_boss_phase(boss_id, 2));
reducer!(set_boss_immune(boss_id, true));
spawn!(boss_add, AddInit { add_type: "left" }).await;
spawn!(boss_add, AddInit { add_type: "right" }).await;
reducer!(set_boss_immune(boss_id, false));
// Phase 3: 70% - 30%, periodic fire
reducer!(announce("Phase 3: Flames erupt!"));
reducer!(set_boss_phase(boss_id, 3));
loop {
select! {
timer!(BossTimer::Enrage, enrage_deadline) => {
reducer!(trigger_wipe(raid_id, "Enrage"));
return Ok(BossFightOutcome::Wipe);
},
timer!(BossTimer::FireBreath, 15.secs()) => {
reducer!(cast_fire_breath(boss_id));
},
signal!(BossSignal::HealthChanged(_, hp)) => {
if hp <= 30 { break; }
},
signal!(BossSignal::Wipe(_)) => {
return Ok(BossFightOutcome::Wipe);
},
}.await;
}
// Phase 4: Burn
reducer!(announce("Phase 4: Finish him!"));
reducer!(set_boss_phase(boss_id, 4));
reducer!(increase_boss_damage(boss_id, 50));
loop {
select! {
timer!(BossTimer::Enrage, enrage_deadline) => {
reducer!(trigger_wipe(raid_id, "Enrage"));
return Ok(BossFightOutcome::Wipe);
},
signal!(BossSignal::HealthChanged(_, hp)) => {
if hp <= 0 { break; }
},
signal!(BossSignal::Wipe(_)) => {
return Ok(BossFightOutcome::Wipe);
},
}.await;
}
reducer!(announce("Victory!"));
let loot = procedure!(generate_loot(boss_id)).await;
reducer!(spawn_loot_chest(boss_id, loot));
Ok(BossFightOutcome::Victory { loot })
}#[workflow]
fn day_night_cycle(init: WorldInit) -> WorkflowResult<()> {
let day_length = 20.minutes();
let night_length = 10.minutes();
loop {
reducer!(set_world_time(WorldTime::Dawn));
reducer!(broadcast_event(WorldEvent::Dawn));
timer!(CycleTimer::Phase, 2.minutes()).await;
reducer!(set_world_time(WorldTime::Day));
reducer!(broadcast_event(WorldEvent::Day));
timer!(CycleTimer::Phase, day_length).await;
reducer!(set_world_time(WorldTime::Dusk));
reducer!(broadcast_event(WorldEvent::Dusk));
timer!(CycleTimer::Phase, 2.minutes()).await;
reducer!(set_world_time(WorldTime::Night));
reducer!(broadcast_event(WorldEvent::Night));
timer!(CycleTimer::Phase, night_length).await;
}
}The #[workflow] macro transforms sequential code into a state machine:
- Identify await points -
timer!().await,signal!().await,procedure!().await,spawn!().await,select!{}.await - Capture live variables - Variables used across await points become state fields
- Generate state enum - One variant per suspension point
- Generate WorkflowHandler impl - Dispatch based on current state
1. Workflow hits `procedure!(calculate_damage(a, b)).await`
2. Macro generates: save state + return WorkflowResult::CallProcedure
3. Reducer transaction commits, workflow status = "Suspended"
4. Runtime sees pending procedure, executes it in own transaction
5. Procedure completes, runtime calls `workflow_procedure_complete` reducer
6. Workflow state restored, execution resumes with result
reducer!(grant_xp(player_id, 100));
// Expands to direct function call, same transaction
// Workflow continues immediatelyWorkflowSubscriptiontable:workflow_id,signal_type,filter_valuesubscribe!adds row, signals dispatch only to matching workflows- Subscriptions auto-cleared when workflow completes
// Generated by install! macro
#[spacetimedb::reducer]
fn debug_fire_timer(workflow_id: u64, timer_name: String) {
// Immediately triggers on_timer as if timer fired
}
#[spacetimedb::reducer]
fn debug_send_signal(workflow_id: u64, signal_name: String, payload: Vec<u8>) {
// Bypasses subscription filters, delivers directly
}// Start workflow in manual mode - timers don't auto-fire
workflow_start("auction", init_data, TestMode::Manual)const wf = await client.workflow_start("auction", { item, price }, { testMode: "manual" });
await client.debug_send_signal(wf.id, "bid", { bidder: alice, amount: 100 });
await client.debug_send_signal(wf.id, "bid", { bidder: bob, amount: 150 });
await client.debug_fire_timer(wf.id, "end");
expect(wf.outcome).toBe({ winner: bob, price: 150 });- Add
WorkflowEvent::ProcedureComplete { name, result }variant - Add
WorkflowEvent::ChildComplete { child_id, result }variant - Add
WorkflowResult::Suspendwith timers, reducer_calls, subscriptions - Add
WorkflowResult::CallProcedurewith procedure and reducer_calls - Add
WorkflowResult::SpawnChildwith workflow_type, initial_data, reducer_calls - Add
WorkflowResult::Completewith result and reducer_calls - Add
WorkflowResult::Failwith reason and reducer_calls - Add
SignalSubscriptiontype for filtered event routing - Add
ProcedureCallandReducerCalltypes - Simplify
WorkflowHandlertrait (start, handle, workflow_type)
- Add
workflow_procedure_completereducer - Add
workflow_broadcast_signalreducer (subscription-based routing) - Add
workflow_start_testreducer (test mode - timers don't auto-fire) - Create
PendingProceduretable (generated by install! macro) - Create
WorkflowSubscriptiontable (generated by install! macro) - Add procedure scheduling/execution logic
- Add signal subscription matching on dispatch
- Add subscription cleanup on workflow complete
- Add debug reducers (
debug_fire_timer,debug_send_signal) - Add
test_modesupport in Workflow table and handlers
- Parse
#[workflow]function signature - Identify await points in function body
- Generate state struct (phase-based, with init data preservation)
- Generate
WorkflowHandlerimplementation - Support
timer!().await(basic linear case) - Handle implicit return expressions properly
- Handle code before/after await points
- Generate event type checking (timer vs signal vs procedure)
- Support
signal!().await(with result binding) - Live variable tracking for complex cases
- Support
reducer!()- fire-and-forget calls - Support
subscribe!()- signal subscription registration - Multiple consecutive await points
- Support
procedure!().await- async procedure with callback - Support
spawn!().await- child workflow spawning (basic) - Support
select! { ... }.await- multi-arm event selection (basic) - Handle loops with
select!where all arms use break/continue - Handle loops with dynamic iteration (counter-based for loops)
- Handle conditionals with await points (if/else with .await inside)
- Handle early returns (return Ok/Err before end of workflow)
- Live variable tracking across await points
- Signal payload binding (extracting data from signals like
Stack(n)) - Procedure/spawn result binding (capturing return value from procedure! and spawn!)
- Select arm result binding (capturing which arm matched)
Implemented patterns:
- Counter-based for loops (
for i in 0..n { spawn!(...).await }) - Fully supported - Infinite loops with select! and break/continue - Fully supported
- Mutable variable tracking across await points - Fully supported
- Conditionals with await (
if cond { timer!().await } else { signal!().await }) - Fully supported
Not yet supported:
- Nested if/else with await in inner conditionals
matchexpressions with await- Multiple await points in a single conditional branch
- Update
install!macro to generate debug reducers - Example workflows using
#[workflow]macro (buff, countdown, parent, child_task, failing, simple_timer, multi_step, procedure_test, spawn_test, select_test, conditional_test) - All example workflows converted to use
#[workflow]macro - Integration test reducers for macro-generated workflows (including select!, loops)
- TypeScript integration tests updated for new workflows
- Regenerate TypeScript bindings with new reducers
- Full documentation (README updated with macro syntax and examples)
-
57 unit tests in
workflow-core:- Handler tests (start, handle, timer cycles, signals, events)
- Type tests (WorkflowResult variants, TimerRequest, SignalSubscription, etc.)
- Registry tests
- Trait tests (Timer, Signal)
- Property-based tests (serialization roundtrips)
- Duration extension tests
-
Integration test reducers in
workflow-example:test_run_all- Runs all synchronous teststest_buff_dispel- Manual workflow handler teststest_workflow_cancel- Cancellation teststest_query_by_entity- Entity query teststest_macro_workflow- Simple timer macro workflowtest_multi_step_workflow- Multiple consecutive awaitstest_procedure_workflow- Procedure calling workflowtest_spawn_workflow- Child spawning workflowtest_debug_fire_timer- Debug reducer teststest_select_workflow- Select macro (timer OR signal)test_select_signal_completes- Select completes via signal armtest_select_timer_completes- Select completes via timer arm
-
TypeScript integration tests (require running SpacetimeDB):
- All original workflow tests (buff, countdown, parent-child, failing)
- New macro-generated workflow tests (simple_timer, multi_step, procedure_test, spawn_test, select_test)
This is a clean-slate v3 design. No backward compatibility with v2 WorkflowHandler trait needed - there are no users yet.
The #[workflow] macro generates a fresh state machine implementation directly, not layered on top of the old trait.