Introduction: Where the Magic Happens – Server-Side Logic
Welcome back, intrepid SpaceTimeDB explorer! In our previous chapters, we laid the groundwork by understanding SpaceTimeDB’s unique architecture, setting up our environment, and defining our database schema with tables. You now know how to structure your data, but what about changing it? How do you update a player’s score, add a new chat message, or move a character in a game?
This is where server-side logic comes into play, and in SpaceTimeDB, it’s handled by a powerful concept called Reducers. Reducers are the heart of your application’s state changes, ensuring that all modifications to your shared database are consistent, deterministic, and immediately propagated to all connected clients.
In this chapter, we’re going to dive deep into:
- What Reducers are and why they are fundamental to SpaceTimeDB.
- How Reducers enable event-driven updates and real-time synchronization.
- The practical steps to write your first Reducer using Rust.
- How clients interact with these server-side functions.
By the end of this chapter, you’ll not only understand how to bring your application’s logic to life but also appreciate the elegant simplicity SpaceTimeDB offers for building complex real-time systems. Get ready to write some Rust code and see your database react in real-time!
Core Concepts: The Power of Reducers
Imagine you have a shared whiteboard, and many people are trying to draw on it at the same time. Without rules, it would be chaos! Reducers are like the strict, fair rules for drawing on SpaceTimeDB’s global whiteboard – they dictate exactly how and when changes can be made, ensuring everyone sees the same, consistent picture.
What is a Reducer?
At its core, a Reducer in SpaceTimeDB is a deterministic function that lives and executes on the SpaceTimeDB server. Its sole purpose is to receive inputs from a client and, based on those inputs, perform operations that modify the shared database state.
Think of it this way:
- Client-Side: Your frontend (web app, game, mobile) doesn’t directly write to the database. Instead, it calls a Reducer function on the SpaceTimeDB server.
- Server-Side: The SpaceTimeDB server receives the call, executes the corresponding Reducer (which you write in Rust), and if the Reducer successfully modifies data, SpaceTimeDB automatically broadcasts those changes to all subscribed clients in real-time.
This architecture has profound implications for building real-time, collaborative, and multiplayer applications:
- Consistency: All state changes are processed centrally and atomically by the server. This eliminates race conditions and ensures that every client sees the exact same, correct state.
- Determinism: Reducers must be deterministic. This means that given the same inputs, a Reducer will always produce the same output state change. This is crucial for SpaceTimeDB’s internal mechanics, such as replication, fault tolerance, and even for enabling features like time-travel debugging.
- Real-time Synchronization: After a Reducer successfully modifies the database, SpaceTimeDB automatically pushes these updates to all relevant connected clients. You don’t need to write any explicit synchronization or pub/sub code!
Reducers vs. Traditional Backend Endpoints
In a traditional backend, you might have REST API endpoints (e.g., /api/items POST) or GraphQL mutations. These endpoints typically involve:
- Receiving an HTTP request.
- Authenticating and authorizing the user.
- Performing business logic.
- Interacting with a database (e.g., SQL, NoSQL).
- Optionally, broadcasting updates via WebSockets to other clients.
SpaceTimeDB’s Reducers encapsulate steps 2-5 into a single, unified, and highly optimized system. Your Rust Reducer code handles the business logic and database interaction directly, and SpaceTimeDB takes care of the real-time propagation automatically. This significantly streamlines development for real-time applications.
The Reducer Workflow
Let’s visualize the journey of a Reducer call:
- Client Initiates: A client application (e.g., a JavaScript frontend) calls a named reducer function, passing any necessary arguments.
- Server Receives & Validates: The SpaceTimeDB server receives the call. It verifies the client’s identity and checks if they have permission to call that reducer.
- Reducer Execution: The server executes the corresponding Rust function you’ve defined as a reducer. This function contains your business logic.
- State Modification: Inside the reducer, you can read from and write to your SpaceTimeDB tables using helper functions provided by the
spacetimedbcrate. - Commit & Propagation: If the reducer executes successfully, SpaceTimeDB commits the changes to the database. Crucially, it then intelligently identifies which clients are subscribed to the affected data and immediately pushes the updates to them over the persistent WebSocket connection.
- Client Updates: Clients automatically receive these updates, allowing their local state and UI to reflect the changes in real-time without polling or manual refresh.
Reducer Structure in Rust
Reducers are written in Rust and marked with the #[reducer] attribute macro. They typically take an Identity (representing the calling client) and custom arguments, and return a Result<(), String> to indicate success or failure.
Here’s a sneak peek at what a reducer might look like:
use spacetimedb::{spacetimedb, table, reducer, Identity, Effect, Hash};
// (Assuming a 'Counter' table is defined in module.stdb)
#[reducer]
pub fn increment_counter(identity: Identity, counter_id: u32, amount: u32) -> Result<(), String> {
// ... logic to find and update the counter ...
Ok(()) // Indicate success
}
The Identity argument is a powerful feature that provides information about the client making the call. This is essential for implementing authorization logic (e.g., “only the owner can modify this item”). We’ll explore authentication and authorization in more detail in a later chapter.
Step-by-Step Implementation: Building Our First Reducer
Let’s get practical! We’ll create a simple counter application where clients can increment a shared counter. This will demonstrate how to define a table, write a reducer, deploy it, and call it from a client.
Prerequisites:
- You have the
spacetimeCLI installed (latest stable version,v2.xas of 2026-03-14). - You have a SpaceTimeDB project initialized (e.g.,
spacetime new my_counter_app). - You are familiar with basic Rust syntax.
Step 1: Define Your Table Schema
First, we need a table to hold our counter’s value. We’ll define a Counter table in our module.stdb file.
Open your module.stdb file (typically located at the root of your SpaceTimeDB project) and add the following schema definition:
// module.stdb
#[table]
pub struct Counter {
#[primarykey]
pub id: u32,
pub value: u32,
}
Explanation:
#[table]: This attribute macro marks theCounterstruct as a SpaceTimeDB table.pub struct Counter: Defines a public struct namedCounter.#[primarykey]: Theidfield is marked as the primary key, meaning each counter will have a uniqueid.pub id: u32: A public fieldidof typeu32(unsigned 32-bit integer).pub value: u32: A public fieldvalueof typeu32to store our counter’s current value.
Save this file.
Step 2: Implement the increment_counter Reducer
Now, let’s write the Rust code for our reducer. Open your src/lib.rs file within your SpaceTimeDB module (e.g., my_counter_app/src/lib.rs).
Add the following Rust code:
// src/lib.rs
use spacetimedb::{spacetimedb, table, reducer, Identity, Effect, Hash};
// Import the Counter table definition from module.stdb
// (SpacetimeDB automatically makes tables defined in module.stdb available)
// You might need to add `use crate::module_stdb::Counter;` if not auto-imported,
// but often it's directly accessible depending on project structure.
// For simplicity, we'll assume direct access here.
// If you defined the table in module.stdb, SpaceTimeDB's build process
// generates a `module_stdb.rs` file. You might need to explicitly import it:
// use crate::module_stdb::{Counter, CounterTable}; // Adjust based on generated module
// For this example, we'll rely on the `spacetimedb` crate's helpers
// which often abstract away the direct import of the generated table struct.
// A reducer that initializes a new counter
#[reducer]
pub fn create_counter(identity: Identity, initial_value: u32) -> Result<(), String> {
// Check if a counter with id 1 already exists (for simplicity, we'll use id 1)
if Counter::filter_by_id(1).is_some() {
return Err("Counter with ID 1 already exists.".to_string());
}
// Insert a new counter
Counter::insert(Counter {
id: 1, // Using a fixed ID for this simple example
value: initial_value,
});
Effect::log("counter_created", &format!("New counter created by {:?} with initial value: {}", identity, initial_value));
Ok(())
}
// Our main reducer: incrementing the counter
#[reducer]
pub fn increment_counter(identity: Identity, counter_id: u32, amount: u32) -> Result<(), String> {
// 1. Find the counter in the database
let mut counter = Counter::filter_by_id(counter_id)
.ok_or_else(|| format!("Counter with ID {} not found", counter_id))?;
// 2. Perform the business logic: increment its value
counter.value += amount;
// 3. Update the table with the new counter value
Counter::update_by_id(counter_id, counter);
// Optional: Log an event for observability/auditing
Effect::log("counter_incremented", &format!("Counter ID {} incremented by {} by {:?}", counter_id, amount, identity));
Ok(()) // Indicate that the reducer executed successfully
}
// You can add more reducers here later...
Explanation of the increment_counter reducer:
use spacetimedb::...: Imports necessary components from thespacetimedbcrate.#[reducer]: This macro transforms our Rust function into a SpaceTimeDB reducer.pub fn increment_counter(identity: Identity, counter_id: u32, amount: u32) -> Result<(), String>:identity: Identity: This argument is automatically provided by SpaceTimeDB and represents the authenticated caller. It’s crucial for security and auditing.counter_id: u32: The ID of the counter we want to increment. This is an argument passed by the client.amount: u32: The value by which to increment the counter. Also passed by the client.-> Result<(), String>: Reducers typically returnResult<(), String>.Ok(())signifies success, whileErr("Some error message".to_string())indicates failure.
let mut counter = Counter::filter_by_id(counter_id)...: This line uses a generated helper function (filter_by_id) to retrieve aCounterinstance from the database based on its primary key.ok_or_elsehandles the case where the counter isn’t found, returning anErr.counter.value += amount;: This is our core business logic – incrementing the counter’s value.Counter::update_by_id(counter_id, counter);: This generated helper function updates the existing counter in the database with the modifiedcounterstruct.Effect::log(...): This is a helper for logging messages to the SpaceTimeDB server’s console/logs. It’s great for debugging and understanding reducer execution.Ok(()): If everything goes well, we returnOk(()).
Step 3: Deploy Your SpaceTimeDB Module
Now that we’ve defined our schema and written our reducer, it’s time to deploy it to our local SpaceTimeDB instance.
Start your SpaceTimeDB server (if not already running):
spacetime startThis will typically start a local SpaceTimeDB instance, often on
localhost:3000.Deploy your module: Navigate to your project’s root directory in your terminal and run:
spacetime deployThis command compiles your Rust code, bundles your schema, and pushes it to your running SpaceTimeDB instance. You should see output indicating a successful deployment, including the deployed module hash.
If you encounter compilation errors, carefully check your Rust code for typos or syntax issues.
Step 4: Interact with the Reducer from a Client
Finally, let’s call our reducer from a client application. For simplicity, we’ll use the spacetime CLI’s call command, which acts as a client. In a real application, you’d use a client SDK (e.g., TypeScript, Python, C#).
First, create the counter: We need to ensure a counter with
id: 1exists before we can increment it.spacetime call create_counter 1 0This calls the
create_counterreducer withinitial_value: 0. You should seeOk(())if successful.Now, call the
increment_counterreducer:spacetime call increment_counter 1 1This calls our
increment_counterreducer, targetingcounter_id: 1and incrementing it byamount: 1.You should see
Ok(())as output.Verify the change: To see if the counter actually updated, you can query the table:
spacetime get CounterYou should see output similar to:
[ { "id": 1, "value": 1 } ]Run
spacetime call increment_counter 1 1a few more times, and thenspacetime get Counteragain. You’ll see thevalueincreasing in real-time!In a real client application, you would subscribe to the
Countertable, and your UI would automatically update without needing to manuallygetthe data.
Mini-Challenge: Resetting the Counter
You’ve successfully built and called your first SpaceTimeDB reducer! Now, let’s solidify that knowledge with a small challenge.
Challenge:
Implement a new reducer named reset_counter that takes a counter_id as input and sets the value of that specific counter back to 0.
Steps:
- Open your
src/lib.rsfile. - Define a new public function marked with
#[reducer]. - It should accept
identity: Identityandcounter_id: u32. - Inside the reducer, find the counter by its
id. - If found, set its
valuefield to0. - Update the counter in the database.
- Return
Ok(())on success, or anErrwith a descriptive message if the counter is not found. - Deploy your updated module using
spacetime deploy. - Test your new reducer using
spacetime call reset_counter 1(assuming your counter ID is 1). - Verify the result with
spacetime get Counter.
Hint: The structure will be very similar to increment_counter. Remember to handle the case where the counter_id might not exist.
Common Pitfalls & Troubleshooting
Working with server-side logic can sometimes introduce new challenges. Here are a few common pitfalls and how to troubleshoot them:
Reducer Not Found / Mismatched Signature:
- Symptom: Your client call fails with an error like “Reducer
my_reducernot found” or “Incorrect number/type of arguments”. - Cause:
- You haven’t deployed the module after adding/changing the reducer.
- The reducer name you’re calling from the client doesn’t exactly match the Rust function name.
- The number or types of arguments passed from the client don’t match the reducer’s function signature in Rust.
- Fix:
- Always run
spacetime deployafter making changes tomodule.stdborsrc/lib.rs. - Double-check reducer names and argument types/counts. SpaceTimeDB’s client SDKs often provide type-safe bindings, which help prevent this.
- Always run
- Symptom: Your client call fails with an error like “Reducer
Deterministic Errors in Reducers:
- Symptom: Reducer execution fails unpredictably, or replicated instances of SpaceTimeDB diverge.
- Cause: You’ve introduced non-deterministic operations within your reducer. Examples include:
- Generating random numbers (
rand::random()). - Accessing the current wall-clock time (
std::time::Instant::now()). - Making external network requests directly (e.g., HTTP calls to another service).
- Generating random numbers (
- Fix: Reducers must be deterministic. For operations like generating IDs, use
Hash::random()(which is deterministically seeded by SpaceTimeDB’s internal state) or sequential IDs. For time, pass a timestamp from the client if it’s external, or use SpaceTimeDB’s internal logical clock if available (advanced). External operations should be handled by separate “effect” services that react to SpaceTimeDB events, not directly within reducers.
Permission Denied Errors:
- Symptom: Your reducer is found, but the client call is rejected with a “Permission Denied” error.
- Cause: You (or the default setup) have implemented authorization logic that prevents the calling
Identityfrom executing that specific reducer or modifying certain data. - Fix: This is often a feature, not a bug! In later chapters, we’ll learn about defining permissions. For now, if you’re experimenting, ensure your default permissions allow
Identity::nil()(the anonymous identity) to call your reducer, or use an authenticated client.
Debugging Reducer Logic:
- Symptom: Your reducer executes, but the database state isn’t what you expect, or it returns an
Err. - Fix:
- Use
Effect::log!: As shown in the example,Effect::log!is your best friend. You can print variable values, messages, and execution paths directly to the SpaceTimeDB server’s console/logs. - Rust Debugging: Since reducers are Rust code, you can use standard Rust debugging techniques (e.g.,
dbg!,println!, thoughEffect::log!is preferred for server-side output). spacetime get: Regularly usespacetime get <TableName>to inspect the current state of your tables after reducer calls.
- Use
- Symptom: Your reducer executes, but the database state isn’t what you expect, or it returns an
Summary
Phew! You’ve just taken a massive leap forward in understanding SpaceTimeDB. Here’s a quick recap of the key takeaways from this chapter:
- Reducers are SpaceTimeDB’s server-side logic: They are deterministic Rust functions that live on the SpaceTimeDB server and are the only way to modify the database state.
- Ensuring Consistency and Real-time: Reducers guarantee atomic, consistent state changes that are automatically propagated to all subscribed clients in real-time.
- Simplified Backend: They unify database operations, business logic, and real-time synchronization, eliminating the need for separate API layers for data mutations.
- Implementation: You define tables in
module.stdband write your reducer functions in Rust, marking them with#[reducer]. - Deployment: Use
spacetime deployto compile and push your Rust logic to the running SpaceTimeDB instance. - Client Interaction: Clients call reducers by name, passing arguments, and SpaceTimeDB handles the rest, including real-time updates.
- Determinism is Key: Avoid non-deterministic operations within reducers to maintain consistency and enable SpaceTimeDB’s advanced features.
You now have the fundamental building blocks to create interactive, real-time applications. You can define your data, and crucially, you can define how that data changes in a safe and consistent manner.
What’s Next?
In the next chapter, we’ll explore how to connect a frontend application (e.g., a web app) to SpaceTimeDB, subscribe to table changes, and truly see the “real-time” aspect come alive as your UI updates dynamically based on the reducer calls you’ve just learned to create. Get ready to build your first fully interactive SpaceTimeDB application!
References
- SpacetimeDB Official Website
- SpacetimeDB Documentation - Core Concepts
- SpacetimeDB Documentation - Reducers
- The Rust Programming Language Book
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.