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:

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

  2. 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:

  1. Initial Snapshot: Sends the client all the data that matches the subscription’s criteria right now.
  2. 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.
  3. 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:

flowchart LR Client["Client Application "] SpacetimeDB_Server["SpaceTimeDB Server"] Client -->|\1| SpacetimeDB_Server SpacetimeDB_Server -->|\1| Client SpacetimeDB_Server -->|\1| Client SpacetimeDB_Server -->|\1| Client

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:

  1. A SpaceTimeDB project initialized (e.g., my_game_db).

  2. A Player table defined in your src/lib.rs module, 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);
    }
    
  3. Your SpaceTimeDB instance running locally:

    spacetime db dev --disable-component-hot-reloading
    
  4. Your module deployed:

    spacetime client deploy .
    
  5. Some sample Player data. If you haven’t inserted any, you can do so from the spacetime client console:

    spacetime client
    // In the client console:
    create_player("Alice")
    create_player("Bob")
    create_player("Charlie")
    create_player("Alice_Alt") // A player with a similar name
    

    Each create_player call 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 the Player table within our my_game_db module. The array allows subscribing to multiple tables or views.
  • client.onUpdate(() => { ... }): This callback fires whenever any subscribed data changes. Inside it, we use client.getEntities(MODULE_NAME, "Player") to retrieve the current snapshot of all rows in the Player table that match our subscription.
  • playerRow.identity.toHexString(): The Identity type 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=Alice at 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 is identity in our Player table). It returns an Option<Player>, so we use a match statement to handle both Some (player found) and None (player not found) cases.
  • player.health: Once you have a Player instance, you can directly access its fields.
  • player.update(): After modifying the player struct, 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:

  1. Save src/lib.rs.

  2. Redeploy your module:

    spacetime client deploy .
    
  3. Keep your client.js running (subscribed to all players, or specifically to “Alice” if you want to observe her health).

  4. In the spacetime client console, assume the identity of “Alice” (or any other player you created) and then call heal_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 client and using select * from my_game_db.Player to see the identity column.

    You should see the heal_player reducer’s logs in your spacetime db dev terminal, and crucially, your client.js terminal 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:

  1. Define a new table called Item in your src/lib.rs. It should have a #[primarykey] id: u64, name: String, item_type: String (e.g., “weapon”, “armor”, “potion”), and rarity: String (e.g., “common”, “rare”, “legendary”).
  2. Create a reducer called create_item that allows you to insert new items into this table.
  3. Deploy your updated module.
  4. Insert at least 5-7 sample items with varying item_type and rarity using the spacetime client CLI.
  5. Modify your client.js to:
    • Subscribe to the Item table.
    • Filter this subscription to only receive items where item_type is “weapon” AND rarity is “legendary”.
    • Log the details of the items received, just like you did for players.

Hint:

  • Remember the ?field=value&another_field=another_value syntax for combining filters in client subscriptions.
  • For the id field in your create_item reducer, you can use spacetimedb::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

  1. Incorrect Subscription Path:

    • Mistake: Using /Player instead of /{MODULE_NAME}/Player. For example, if your module ID is my_game_db, it should be /my_game_db/Player.
    • Troubleshooting: Double-check your MODULE_NAME constant and ensure it matches the ID specified in your Spacetime.toml or the ID you see when deploying. Look for client-side errors indicating an invalid subscription.
  2. Forgetting client.onUpdate:

    • Mistake: You called client.subscribe(), but your onUpdate callback never fires, or you’re not correctly retrieving data inside it.
    • Troubleshooting: Ensure client.onUpdate() is registered before client.connect(). Inside onUpdate, verify that client.getEntities(MODULE_NAME, "TableName") is being called and that the table name is correct. Remember that onUpdate fires for any change to any subscribed table, so you might need to check which table changed if you have multiple subscriptions.
  3. Misunderstanding Filter Syntax:

    • Mistake: Using incorrect query parameters for filtering (e.g., ?name==Alice instead of ?name=Alice, or ?health>50 when 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.
  4. 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 client and select * from {your_module_name}.{your_table_name} to verify data exists on the server.
      • Is your client connected to the correct URI (ws://localhost:3000 for local dev)?

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 client CLI 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.