Introduction
Welcome back, future SpaceTimeDB master! In the previous chapter, you learned how to define your database schema and create tables to store your application’s shared state. You even got a taste of how to add data to these tables using reducers. But what good is storing data if you can’t get it back out?
This chapter is all about querying your data. We’ll dive into how clients can ask SpaceTimeDB for specific pieces of information and how that information is kept up-to-date in real-time. We’ll explore the unique subscription model that makes SpaceTimeDB so powerful for real-time applications, and also touch upon how server-side logic (like your reducers) can access and filter data. By the end of this chapter, you’ll be able to retrieve exactly the data you need, when you need it, and react to changes instantly.
Ready to make your data come alive? Let’s go!
Core Concepts: How SpaceTimeDB Queries Data
SpaceTimeDB takes a slightly different approach to data retrieval compared to traditional request-response databases. While you can certainly “query” data in a familiar sense, its strength lies in real-time subscriptions where clients declare their interest in data and receive continuous updates.
The Two Sides of Querying
In SpaceTimeDB, querying happens in two primary contexts:
Client-Side Subscriptions: This is the primary way your frontend applications (web, game clients, mobile apps) retrieve data. Clients “subscribe” to tables or specific views of tables. Once subscribed, SpaceTimeDB streams the initial data and then pushes any subsequent changes to that data in real-time. This is perfect for building reactive UIs and multiplayer experiences.
Server-Side Data Access (within Modules/Reducers): Your SpaceTimeDB modules, written in Rust, can also query the database. This is typically done within reducers or other server-side logic to read existing state before making modifications or performing complex calculations. This is similar to how a traditional backend service might query its database.
Let’s explore each of these in more detail.
Client-Side Subscriptions: Your Window to Real-time Data
Imagine you’re building a chat application. You don’t just want to fetch all messages once; you want to see new messages as they arrive, instantly. That’s where subscriptions shine!
A client-side subscription is a declaration of interest. Your client tells SpaceTimeDB, “Hey, I’d like to see all messages in this chat room,” or “Show me all players currently online.” SpaceTimeDB then does three things:
- Initial Snapshot: Sends the client all the data that matches the subscription’s criteria right now.
- Continuous Updates: Whenever that data changes (e.g., a new message is sent, a player logs in/out, or a player’s position updates), SpaceTimeDB automatically pushes those changes to your client.
- Filtered Views: You can specify criteria (filters) to only receive a subset of the data, keeping your client’s data footprint small and relevant.
This mechanism fundamentally changes how you build real-time applications, moving away from constant polling and towards an efficient, event-driven model.
Here’s a simplified flow:
Server-Side Data Access: Logic Meets State
While client subscriptions are for broadcasting data to clients, your server-side SpaceTimeDB modules (the Rust code) often need to read data to perform their logic. For instance, a reducer that handles a join_game event might first need to check how many players are already in the game before allowing a new one to join.
Within your Rust modules, you interact directly with the database tables you’ve defined. SpaceTimeDB provides a clear API for iterating through table rows, filtering them, and retrieving specific entries. This is where you’ll use more traditional-looking query patterns, but still within the deterministic, event-sourced context of SpaceTimeDB.
The spacetime CLI for Data Exploration
Before we dive into code, remember your trusty spacetime CLI tool! It’s not just for deploying modules; it’s also fantastic for inspecting the current state of your database.
You can connect to your local or remote SpaceTimeDB instance and browse tables, view rows, and even manually insert data. This is invaluable for debugging and understanding what’s actually stored.
Challenge for yourself: If you have your SpaceTimeDB instance running from Chapter 3, try connecting to it with spacetime client and then use commands like list tables or select * from {your_table_name} to see the data you inserted previously. This will give you a feel for interacting with the database directly.
Step-by-Step Implementation: Subscribing and Filtering
Let’s put these concepts into practice. We’ll continue with our simple Player table from Chapter 3 and learn how a client can subscribe to it and filter the results.
Prerequisites
Make sure you have:
A SpaceTimeDB project initialized (e.g.,
my_game_db).A
Playertable defined in yoursrc/lib.rsmodule, similar to this:// src/lib.rs use spacetimedb::{spacetimedb, ReducerContext, Identity, Timestamp}; #[spacetimedb(table)] pub struct Player { #[primarykey] pub identity: Identity, pub name: String, pub health: u32, pub last_login: Timestamp, } #[spacetimedb(reducer)] pub fn create_player(ctx: ReducerContext, name: String) { if Player::filter_by_identity(&ctx.sender).is_some() { log::info!("Player with identity {:?} already exists.", ctx.sender); return; } Player::insert(Player { identity: ctx.sender, name, health: 100, last_login: ctx.timestamp, }).expect("Failed to insert player"); log::info!("Player {:?} created with name: {}", ctx.sender, name); }Your SpaceTimeDB instance running locally:
spacetime db dev --disable-component-hot-reloadingYour module deployed:
spacetime client deploy .Some sample
Playerdata. If you haven’t inserted any, you can do so from thespacetime clientconsole:spacetime client // In the client console: create_player("Alice") create_player("Bob") create_player("Charlie") create_player("Alice_Alt") // A player with a similar nameEach
create_playercall will use a new ephemeral identity by default, creating unique players.
Step 1: Setting up a Basic Client
We’ll use a simple JavaScript/TypeScript client for this example, as it’s common for web and Node.js applications. Create a new file, client.js (or client.ts if you prefer TypeScript), in your project root.
First, install the SpaceTimeDB client library:
npm init -y
npm install @clockworklabs/spacetimedb-sdk
Now, open client.js and add the basic connection logic:
// client.js
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
// Define your SpaceTimeDB instance URL.
// For local development, this is typically ws://localhost:3000
const SPACETIMEDB_URI = "ws://localhost:3000";
// Initialize the client
const client = new SpacetimeDBClient(SPACETIMEDB_URI);
console.log("Connecting to SpaceTimeDB...");
client.onConnect(() => {
console.log("Successfully connected to SpaceTimeDB!");
// We'll add our subscription logic here
});
client.onDisconnect(() => {
console.log("Disconnected from SpaceTimeDB.");
});
client.onError((e) => {
console.error("SpaceTimeDB client error:", e);
});
// Connect to the database
client.connect();
Run this client:
node client.js
You should see “Connecting to SpaceTimeDB…” and then “Successfully connected to SpaceTimeDB!”. Great, your client can talk to the database!
Step 2: Subscribing to All Players
Let’s subscribe to the Player table to get all player data. We’ll also set up an event listener to react to data changes.
Modify your client.js file:
// client.js
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
// Import your table definitions from the generated client library
// Assuming your module ID is 'my_game_db' and you've run 'spacetime client generate'
// For now, we'll manually define the Player structure for logging clarity.
// In a real project, you'd import generated types.
const SPACETIMEDB_URI = "ws://localhost:3000";
const MODULE_NAME = "my_game_db"; // Replace with your actual module name
const client = new SpacetimeDBClient(SPACETIMEDB_URI);
// We'll simulate the generated Player class for this example's logging
class Player {
constructor(identity, name, health, last_login) {
this.identity = identity;
this.name = name;
this.health = health;
this.last_login = last_login;
}
}
// Map of table names to their data
const subscribedData = {};
client.onConnect(() => {
console.log("Successfully connected to SpaceTimeDB!");
// Subscribe to the 'Player' table
client.subscribe([`/${MODULE_NAME}/Player`]); // Subscribe to the full table
console.log("Subscribed to the Player table.");
});
// Listen for updates to subscribed data
client.onUpdate(() => {
console.log("\n--- Data Update Received ---");
// Get all rows for the 'Player' table
const players = client.getEntities(MODULE_NAME, "Player");
// Clear previous data for this example (in a real app, you'd manage state)
subscribedData["Player"] = [];
if (players && players.length > 0) {
console.log("Current Players:");
players.forEach(playerRow => {
// In a real app, 'playerRow' would be an instance of your generated Player class
// For this example, we'll construct it for consistent logging.
const player = new Player(
playerRow.identity.toHexString(), // Convert Identity to string for display
playerRow.name,
playerRow.health,
playerRow.last_login
);
subscribedData["Player"].push(player);
console.log(`- Name: ${player.name}, Health: ${player.health}, ID: ${player.identity.substring(0, 8)}...`);
});
} else {
console.log("No players found.");
}
console.log("----------------------------");
});
client.onDisconnect(() => {
console.log("Disconnected from SpaceTimeDB.");
});
client.onError((e) => {
console.error("SpaceTimeDB client error:", e);
});
client.connect();
Explanation of changes:
client.subscribe([/${MODULE_NAME}/Player]): This is the core of the subscription. We’re telling SpaceTimeDB we want to receive data for thePlayertable within ourmy_game_dbmodule. The array allows subscribing to multiple tables or views.client.onUpdate(() => { ... }): This callback fires whenever any subscribed data changes. Inside it, we useclient.getEntities(MODULE_NAME, "Player")to retrieve the current snapshot of all rows in thePlayertable that match our subscription.playerRow.identity.toHexString(): TheIdentitytype from SpaceTimeDB is a special object; we convert it to a hex string for easier display.
Run this updated client.js again. You should see an initial list of all players you created.
Now for the fun part: keep client.js running. Open a new terminal and connect to your SpaceTimeDB instance with the CLI:
spacetime client
In the CLI, call your create_player reducer:
create_player("Eve")
Observe your client.js terminal. You should immediately see a new “Data Update Received” log, and “Eve” will appear in the list of players! This demonstrates the real-time nature of subscriptions.
Step 3: Filtering Subscriptions
Subscribing to all data is often inefficient. What if we only want players with a certain name, or those with low health? SpaceTimeDB subscriptions allow you to add filters.
Let’s modify our client.js to only subscribe to players named “Alice”.
// client.js
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
const SPACETIMEDB_URI = "ws://localhost:3000";
const MODULE_NAME = "my_game_db";
const client = new SpacetimeDBClient(SPACETIMEDB_URI);
class Player { /* ... same as before ... */ }
const subscribedData = {};
client.onConnect(() => {
console.log("Successfully connected to SpaceTimeDB!");
// --- NEW: Filtered Subscription ---
// Subscribe to the 'Player' table, but only for rows where 'name' is 'Alice'
client.subscribe([`/${MODULE_NAME}/Player?name=Alice`]);
console.log("Subscribed to Player table, filtered by name='Alice'.");
});
// ... onUpdate, onDisconnect, onError, connect functions remain the same ...
// (The onUpdate logic will now only receive and log players named Alice)
Explanation of filter:
/${MODULE_NAME}/Player?name=Alice: Notice the?name=Aliceat the end of the path. This is how you apply a simple equality filter directly in the subscription path. SpaceTimeDB’s client SDK understands this query string syntax.
Run this modified client.js. You should now only see players named “Alice” (and “Alice_Alt” might not appear if the filter is strict equality, depending on how name=Alice is interpreted by the SDK against your schema - typically it’s strict equality).
Experiment with other filters (stop and restart client.js each time):
?health=100: To see players with full health.?name=Bob: To see only Bob.
SpaceTimeDB’s client SDKs support various filter types, including:
- Equality:
?field=value - Inequality:
?field!=value - Range:
?field>=value,?field<=value,?field>value,?field<value - Logical AND: Combine multiple filters with
&(e.g.,?name=Alice&health=100) - Logical OR: For more complex OR conditions, you might need to subscribe to multiple filtered views or filter on the client side after a broader subscription (depending on SDK capabilities and performance needs).
For the most up-to-date and comprehensive filtering options, always refer to the official SpaceTimeDB client SDK documentation.
Step 4: Querying from a Reducer (Server-Side)
Now, let’s look at how your Rust modules can access data. This is crucial for implementing game logic, validation, or complex state transitions.
Imagine we want a reducer that “heals” a player but only if their health is below a certain threshold. This requires reading the player’s current health.
Open your src/lib.rs file and add a new reducer:
// src/lib.rs
// ... existing code ...
#[spacetimedb(reducer)]
pub fn heal_player(ctx: ReducerContext, amount: u32) {
// 1. Retrieve the player using the sender's identity
let mut player = match Player::filter_by_identity(&ctx.sender) {
Some(p) => p,
None => {
log::warn!("Heal attempt by non-existent player: {:?}", ctx.sender);
return;
}
};
// 2. Access the player's current health
let current_health = player.health;
log::info!("Player {} (ID: {:?}) current health: {}", player.name, player.identity, current_health);
// 3. Apply game logic: Only heal if not already at max health (e.g., 100)
if current_health >= 100 {
log::info!("Player {} is already at max health.", player.name);
return;
}
// 4. Calculate new health, capping at 100
player.health = u32::min(current_health + amount, 100);
// 5. Update the player in the database
// The `update` method takes a closure that receives the current row
// and returns the modified row.
player.update().expect("Failed to update player health");
log::info!("Player {} (ID: {:?}) healed by {} to {} health.", player.name, player.identity, amount, player.health);
}
Explanation:
Player::filter_by_identity(&ctx.sender): This is how you query a table by its primary key (which isidentityin ourPlayertable). It returns anOption<Player>, so we use amatchstatement to handle bothSome(player found) andNone(player not found) cases.player.health: Once you have aPlayerinstance, you can directly access its fields.player.update(): After modifying theplayerstruct, call.update()to persist the changes back to the database. This will trigger real-time updates for any clients subscribed to this player’s data!
Deploy and Test:
Save
src/lib.rs.Redeploy your module:
spacetime client deploy .Keep your
client.jsrunning (subscribed to all players, or specifically to “Alice” if you want to observe her health).In the
spacetime clientconsole, assume the identity of “Alice” (or any other player you created) and then callheal_player:login_with_identity "0x..." // Replace with Alice's identity heal_player(10)You’ll need to know the identity of one of your players. You can find this by running
spacetime clientand usingselect * from my_game_db.Playerto see theidentitycolumn.You should see the
heal_playerreducer’s logs in yourspacetime db devterminal, and crucially, yourclient.jsterminal will show a “Data Update Received” with Alice’s new health!
This demonstrates how reducers can perform reads, apply logic, and then write updates, all while maintaining real-time synchronization with connected clients.
Mini-Challenge: Filtering Items by Type
Let’s solidify your understanding with a practical challenge.
Challenge:
- Define a new table called
Itemin yoursrc/lib.rs. It should have a#[primarykey]id: u64,name: String,item_type: String(e.g., “weapon”, “armor”, “potion”), andrarity: String(e.g., “common”, “rare”, “legendary”). - Create a reducer called
create_itemthat allows you to insert new items into this table. - Deploy your updated module.
- Insert at least 5-7 sample items with varying
item_typeandrarityusing thespacetime clientCLI. - Modify your
client.jsto:- Subscribe to the
Itemtable. - Filter this subscription to only receive items where
item_typeis “weapon” ANDrarityis “legendary”. - Log the details of the items received, just like you did for players.
- Subscribe to the
Hint:
- Remember the
?field=value&another_field=another_valuesyntax for combining filters in client subscriptions. - For the
idfield in yourcreate_itemreducer, you can usespacetimedb::random_id()to generate unique IDs.
What to Observe/Learn:
- How to define a new table and reducer independently.
- How to combine multiple filters in a single client-side subscription.
- The immediate real-time updates when you add new items matching your filter criteria.
Common Pitfalls & Troubleshooting
Incorrect Subscription Path:
- Mistake: Using
/Playerinstead of/{MODULE_NAME}/Player. For example, if your module ID ismy_game_db, it should be/my_game_db/Player. - Troubleshooting: Double-check your
MODULE_NAMEconstant and ensure it matches the ID specified in yourSpacetime.tomlor the ID you see when deploying. Look for client-side errors indicating an invalid subscription.
- Mistake: Using
Forgetting
client.onUpdate:- Mistake: You called
client.subscribe(), but youronUpdatecallback never fires, or you’re not correctly retrieving data inside it. - Troubleshooting: Ensure
client.onUpdate()is registered beforeclient.connect(). InsideonUpdate, verify thatclient.getEntities(MODULE_NAME, "TableName")is being called and that the table name is correct. Remember thatonUpdatefires for any change to any subscribed table, so you might need to check which table changed if you have multiple subscriptions.
- Mistake: You called
Misunderstanding Filter Syntax:
- Mistake: Using incorrect query parameters for filtering (e.g.,
?name==Aliceinstead of?name=Alice, or?health>50when the SDK expects a different range syntax). - Troubleshooting: Refer to the official SpaceTimeDB client SDK documentation for the exact filter syntax supported by your chosen client library. Different SDKs might have slightly different ways to express complex queries.
- Mistake: Using incorrect query parameters for filtering (e.g.,
No Data Appearing:
- Mistake: Your client connects, subscribes, but no data ever shows up.
- Troubleshooting:
- Is your SpaceTimeDB instance running? (
spacetime db dev) - Is your module deployed? (
spacetime client deploy .) - Have you inserted any data into the table you’re subscribing to? Use
spacetime clientandselect * from {your_module_name}.{your_table_name}to verify data exists on the server. - Is your client connected to the correct URI (
ws://localhost:3000for local dev)?
- Is your SpaceTimeDB instance running? (
Summary
Phew! You’ve just unlocked a crucial part of building real-time applications with SpaceTimeDB. Here’s a quick recap of what we covered:
- Client-Side Subscriptions are the backbone of real-time data flow in SpaceTimeDB, allowing clients to declare interest in data and receive continuous updates.
- You learned how to use
client.subscribe()with path-based filters (e.g.,?name=Alice) to retrieve specific subsets of data. - The
client.onUpdate()callback is your entry point for reacting to any changes in your subscribed data. - Server-Side Data Access within Rust reducers allows you to read table data (e.g.,
Player::filter_by_identity()) to inform your application logic before making state changes. - The
spacetime clientCLI is an invaluable tool for inspecting your database’s current state.
You now have the tools to retrieve and filter data effectively, both from your client applications and within your server-side logic. This is a massive step towards building dynamic, reactive systems.
In the next chapter, we’ll shift our focus from reading data to modifying data. You’ll learn how to use reducers to change existing records, delete entries, and keep your shared state perfectly synchronized across all clients.
References
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.