Skip to content

Latest commit

 

History

History
432 lines (325 loc) · 9.85 KB

File metadata and controls

432 lines (325 loc) · 9.85 KB

Tutorial: Build Your First Workflow

A hands-on guide from zero to working workflow.

What We'll Build

A buff workflow that:

  1. Starts with 1 stack
  2. Expires after a duration
  3. Can be dispelled via signal
  4. Can have stacks added via signal

Simple, but demonstrates all core concepts.

Prerequisites

  • Rust installed
  • SpacetimeDB CLI installed (install guide)
  • This project cloned

Part 1: The Simplest Workflow

Let's start with the absolute minimum: a workflow that waits and then completes.

Step 1: Define Types

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,
}

Step 2: Write the Workflow

#[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

Step 3: Register the Workflow

Add it to your install! macro:

// src/lib.rs

use workflow_macros::install;

install! {
    "buff" => BuffWorkflow,
}

Step 4: Test It

# 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!


Part 2: Adding Signals

Now let's add the ability to dispel the buff early.

Add Signal Type

use workflow_macros::{workflow, Timer, Signal};

#[derive(Timer)]
pub enum BuffTimer {
    Expire,
}

#[derive(Signal)]
pub enum BuffSignal {
    Dispel,
}

Update the Workflow with select!

#[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.

Test It

# 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!


Part 3: Mutable Variables and Loops

Let's add stacking - the buff can have multiple stacks added.

Add Stack Signal

#[derive(Signal)]
pub enum BuffSignal {
    Dispel,
    Stack(u32),  // Payload: number of stacks to add
}

Update the Workflow with Loop

#[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 annotations
  • stacks is persisted across await points — It survives server restarts!
  • continue — Re-enters the loop, waiting for the next event
  • break — Exits the loop, continues to the code after

Test It

# 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!


Part 4: Conditionals

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.


Part 5: Querying from Clients

Workflows are just table rows. Clients can subscribe:

SQL Queries

-- 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 = 1

TypeScript Client

import { 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', '[]');

Complete Code

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,
    })
}

What You Learned

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

Next Steps

Now that you've built a workflow:

Examples — See real-world patterns (patrol, combat, production) → API Reference — Complete API documentation