Introduction: The Blueprint for Your Real-time World

Welcome back, future SpaceTimeDB architects! In our previous chapters, we got acquainted with what SpaceTimeDB is and set up our development environment. Now, it’s time to lay the foundation for your real-time applications: designing your database schema.

Just as an architect draws up blueprints before construction begins, you’ll define your data’s structure and relationships within SpaceTimeDB. This chapter is crucial because a well-designed schema isn’t just about storing data; it’s about enabling efficient real-time synchronization, consistent state management, and robust server-side logic. We’ll explore how SpaceTimeDB combines the power of Rust with database table definitions to create a unified data model.

By the end of this chapter, you’ll understand how to define tables, specify primary keys and indexes, and logically relate different pieces of data. You’ll be ready to translate your application’s data requirements into a SpaceTimeDB module, setting the stage for building dynamic, collaborative experiences.

Core Concepts: Defining Your Data Universe

In SpaceTimeDB, your database schema is much more than just a list of tables and columns. It’s a Rust-based module that defines both your data’s structure and, as we’ll see in later chapters, your server-side logic (reducers). Let’s break down the core components.

What is a SpaceTimeDB Schema?

Think of your SpaceTimeDB schema as the master plan for your entire application’s backend. It’s written in Rust, a powerful and safe programming language, and then compiled into WebAssembly (WASM). This WASM module is what SpaceTimeDB runs internally to manage your database, execute your logic, and ensure deterministic, consistent state across all connected clients.

Why Rust and WebAssembly?

  • Performance: Rust compiles to highly optimized native code, offering incredible speed. WASM provides a near-native performance environment.
  • Safety & Determinism: Rust’s strong type system and ownership model prevent common programming errors, leading to more reliable and deterministic server-side logic. This determinism is key to SpaceTimeDB’s ability to maintain a consistent shared state.
  • Unified Logic: By defining both data and logic in the same Rust module, SpaceTimeDB creates a tightly integrated system where your database schema and business rules live side-by-side, simplifying development and deployment.

Tables: The Building Blocks of Your Data

At its heart, SpaceTimeDB organizes data into tables, much like traditional relational databases. Each table represents a collection of similar items, like “Users,” “TodoItems,” or “GameCharacters.”

To define a table in SpaceTimeDB, you’ll use a standard Rust struct and adorn it with special attributes (called “derive macros” in Rust) provided by the SpacetimeDB library.

Let’s look at the key elements:

  • #[derive(SpacetimeTable)]: This macro is essential. It tells the SpaceTimeDB compiler that your Rust struct should be treated as a database table. It automatically generates the necessary code to allow SpaceTimeDB to store, retrieve, and synchronize instances of this struct.
  • Fields and Data Types: Inside your struct, you define fields using standard Rust data types (u64, String, bool, etc.) or even other custom structs (which can also be tables themselves or embedded data).
  • Primary Keys (#[primarykey]): Every table must have a primary key. This field uniquely identifies each row in your table. It’s crucial for efficient data retrieval and ensuring data integrity. Primary keys in SpaceTimeDB are typically u64 (unsigned 64-bit integers).
  • Indexes (#[index]): While a primary key ensures unique identification, secondary indexes improve the performance of queries that filter or sort by other fields. If you frequently search for users by their name or items by their category, adding an #[index] to those fields will speed up those operations significantly.
  • Unique Constraints (#[unique]): You can also mark a field with #[unique] to ensure that no two rows in the table have the same value for that specific field. This is often used for things like usernames or email addresses.

Here’s a conceptual view of how your Rust code maps to SpaceTimeDB tables:

flowchart TD A["Your Project Folder"] --> B["src/spacetime.rs"] subgraph spacetime_rs_content["spacetime.rs Content"] C["Rust Struct: User"] D["#[derive]"] E["#[primarykey] id: u64"] F["name: String"] C --- D D --- E D --- F G["Rust Struct: TodoItem"] H["#[derive]"] I["#[primarykey] id: u64"] J["#[index] user_id: u64"] K["description: String"] L["completed: bool"] G --- H H --- I H --- J H --- K H --- L end B --> spacetime_rs_content spacetime_rs_content --> M["spacetime build"] M --> N["Compiled WASM Module "] N --> O["spacetime db deploy"] O --> P["SpaceTimeDB Core"] P --> Q["User Table "] P --> R["TodoItem Table "] Q -.->|Logical Link via user_id| R

Relations: Connecting Your Data

In SpaceTimeDB, you don’t define explicit FOREIGN KEY constraints in the same way you would in a traditional SQL database. Instead, relationships between tables are established logically by referencing the primary key of one table in another.

For example, if you have a User table and a TodoItem table, a TodoItem doesn’t “belong” to a User through a database constraint. Instead, the TodoItem struct would simply contain a field, say user_id, which stores the id of the User it’s associated with.

This approach offers flexibility and is very efficient for real-time synchronization. SpaceTimeDB’s client SDKs (which we’ll cover later) make it easy to “join” this data on the client side, allowing you to build rich UIs that display related information.

Why this approach?

  • Flexibility: Avoids rigid database-level constraints that can sometimes complicate schema evolution or distributed systems.
  • Performance: Lookup by primary key (ID) is extremely fast.
  • Client-side Joins: SpaceTimeDB’s reactive nature means clients can efficiently subscribe to and combine data from multiple tables based on these logical links.

Step-by-Step Implementation: Building Our First Schema

Let’s put these concepts into practice by creating a simple “Todo List” application schema. Our application will need to store User information and TodoItems, with each TodoItem belonging to a specific User.

Prerequisites

Make sure you’ve completed the setup from Chapter 2 and have the spacetime CLI installed. We’ll assume you’ve already created a new SpaceTimeDB project (e.g., spacetime new todo-app) and are working within its directory.

If you haven’t, open your terminal and run:

spacetime new todo-app
cd todo-app

1. Open Your Schema File

Navigate to the src directory within your todo-app project. You’ll find a file named spacetime.rs. This is where your schema and server-side logic will live.

Open src/spacetime.rs in your favorite code editor. It might contain some boilerplate code. For now, let’s clear it out so we can start fresh.

Your src/spacetime.rs file should initially look something like this (you might remove any example structs if present):

#![allow(warnings)] // Allow warnings for now, good practice to remove later

use spacetimedb::{
    spacetimedb,
    SpacetimeTable,
};

// Your schema definitions and reducers will go here

2. Define the User Table

First, let’s define our User table. Each user will have a unique id and a name.

Add the following Rust code to your src/spacetime.rs file, just below the use statements:

#[spacetimedb(table)]
pub struct User {
    #[primarykey]
    pub id: u64,
    pub name: String,
}

Let’s break down these lines:

  • #[spacetimedb(table)]: This is the attribute that marks our User struct as a SpaceTimeDB table. It’s a newer, more concise syntax that replaces #[derive(SpacetimeTable)] in recent v2.x versions of SpaceTimeDB, aligning with modern Rust attribute usage.
  • pub struct User { ... }: Defines a public Rust struct named User. pub means it’s publicly accessible.
  • #[primarykey]: This attribute designates the id field as the primary key for the User table. It must be unique for each User entry.
  • pub id: u64: The id field is a public unsigned 64-bit integer, a common type for primary keys.
  • pub name: String: The name field is a public Rust String, which will store the user’s name.

3. Define the TodoItem Table

Next, let’s define the TodoItem table. Each todo item will have its own unique id, a user_id to link it to a User, a description, and a completed status.

Add this code below your User struct definition in src/spacetime.rs:

#[spacetimedb(table)]
pub struct TodoItem {
    #[primarykey]
    pub id: u64,
    #[index]
    pub user_id: u64, // Logical link to a User's id
    pub description: String,
    pub completed: bool,
}

And here’s the explanation:

  • #[spacetimedb(table)]: Again, marking TodoItem as a SpaceTimeDB table.
  • pub id: u64: The primary key for the TodoItem.
  • #[index] pub user_id: u64: This field holds the id of the User who owns this todo item. We’ve added #[index] because we’ll likely want to quickly retrieve all todo items for a specific user. This index will make those lookups fast.
  • pub description: String: The text content of the todo item.
  • pub completed: bool: A boolean indicating whether the todo item has been completed.

Your complete src/spacetime.rs file should now look like this:

#![allow(warnings)] // Allow warnings for now, good practice to remove later

use spacetimedb::{
    spacetimedb,
    SpacetimeTable,
};

#[spacetimedb(table)]
pub struct User {
    #[primarykey]
    pub id: u64,
    pub name: String,
}

#[spacetimedb(table)]
pub struct TodoItem {
    #[primarykey]
    pub id: u64,
    #[index]
    pub user_id: u64, // Logical link to a User's id
    pub description: String,
    pub completed: bool,
}

4. Compiling and Deploying Your Schema

Now that we’ve defined our schema, it’s time to compile our Rust code into a WebAssembly module and then deploy it to a SpaceTimeDB instance.

  1. Build the Module: Open your terminal in the todo-app project directory and run:

    spacetime build
    

    This command invokes the Rust compiler to build your src/spacetime.rs file into a .wasm file, typically located at target/spacetime.wasm. You should see output indicating a successful build. If there are any Rust syntax errors, the compiler will tell you here!

  2. Deploy the Module: Once built, deploy it to your local SpaceTimeDB instance. We’ll use the spacetime db start command, which both starts the database and deploys your module.

    spacetime db start --module-path target/spacetime.wasm
    

    You should see output indicating that SpaceTimeDB is starting up and your module has been successfully deployed. This command effectively applies your schema to the running SpaceTimeDB instance.

    Note on spacetime db deploy vs spacetime db start --module-path:

    • spacetime db deploy is used to deploy a new module to an already running SpaceTimeDB instance.
    • spacetime db start --module-path is convenient for local development, as it starts the database and deploys your module in one go. We’ll use this primarily for now.

5. Verifying Your Schema

How do we know our schema was deployed correctly? The SpaceTimeDB CLI provides ways to inspect the database.

While SpaceTimeDB v2.x focuses heavily on client SDKs for interaction, you can get a basic confirmation of table existence.

  1. Connect to the CLI (if not already connected): If your spacetime db start command is running in one terminal, open a second terminal in the todo-app directory.

  2. List Tables (Conceptual): Currently, direct CLI commands for listing schema details are being continuously enhanced. The primary way to interact and observe your schema is through client SDKs (which we’ll cover in the next chapter) or by checking the logs of your spacetime db start command for successful module deployment.

    For now, trust that if spacetime build and spacetime db start --module-path completed without errors, your schema has been successfully applied! The SpaceTimeDB instance now understands the User and TodoItem tables and their defined fields.

    In future chapters, when we connect a client, we’ll see exactly how to query and interact with these tables.

Mini-Challenge: Enhancing Your TodoItem

Let’s expand our TodoItem table with a couple more practical fields. This will solidify your understanding of adding fields and applying indexes.

Challenge:

  1. Add a priority field to the TodoItem struct. This field should be an u8 (unsigned 8-bit integer), representing priority levels from 0 (low) to 255 (high).
  2. Make the priority field indexable, as you might want to quickly find all high-priority tasks.
  3. Add an created_at field to the TodoItem struct. This field should be a u64, storing a Unix timestamp (milliseconds since epoch) indicating when the todo item was created. This field does not need an index for now.

Hint:

  • Remember the #[index] attribute for indexable fields.
  • After making changes to src/spacetime.rs, you’ll need to run spacetime build again, and then restart your database with spacetime db start --module-path target/spacetime.wasm to apply the new schema.
Click for Solution (after you've tried it!)
#![allow(warnings)]

use spacetimedb::{
    spacetimedb,
    SpacetimeTable,
};

#[spacetimedb(table)]
pub struct User {
    #[primarykey]
    pub id: u64,
    pub name: String,
}

#[spacetimedb(table)]
pub struct TodoItem {
    #[primarykey]
    pub id: u64,
    #[index]
    pub user_id: u64, // Logical link to a User's id
    pub description: String,
    pub completed: bool,
    #[index] // Added index here
    pub priority: u8, // New field for priority
    pub created_at: u64, // New field for creation timestamp
}

After updating the file, run:

spacetime build
spacetime db start --module-path target/spacetime.wasm

What to observe/learn: You’ve now successfully modified your schema, added new data types, and applied another index. This demonstrates the iterative nature of schema design in SpaceTimeDB.

Common Pitfalls & Troubleshooting

Even with a strong type system like Rust’s, you might encounter a few common issues when designing your SpaceTimeDB schema.

  1. Rust Compilation Errors: The most frequent issue for beginners.
    • Problem: spacetime build fails with Rust compiler errors.
    • Solution: Read the error messages carefully. Rust’s compiler is famously helpful! It often tells you exactly where the error is, what’s expected, and sometimes even suggests fixes. Common issues include missing semicolons, incorrect types, or uninitialized fields.
  2. Missing #[spacetimedb(table)] or #[primarykey]:
    • Problem: Your module builds, but SpaceTimeDB doesn’t recognize your table, or deployment fails with a schema-related error.
    • Solution: Double-check that every struct intended to be a table has #[spacetimedb(table)] above it, and that exactly one field within each table struct is marked with #[primarykey].
  3. Incorrect Data Types:
    • Problem: You try to store a String in a u64 field, or vice-versa, which will be caught by the Rust compiler.
    • Solution: Ensure your Rust types match the kind of data you intend to store. SpaceTimeDB supports most primitive Rust types and certain complex types. Consult the official SpaceTimeDB documentation for a comprehensive list of supported types: https://spacetimedb.com/docs/
  4. Forgetting spacetime build or spacetime db start:
    • Problem: You make changes to spacetime.rs but your database doesn’t reflect them, or your client code (in later chapters) can’t find the new fields/tables.
    • Solution: Always remember the two-step process: spacetime build to compile your Rust code, then spacetime db start --module-path target/spacetime.wasm (or spacetime db deploy) to apply the compiled module to the running database instance.

Summary: Your Data Takes Shape

Congratulations! You’ve successfully designed and deployed your first SpaceTimeDB schema. Let’s recap the key takeaways from this chapter:

  • Schema as Blueprint: Your SpaceTimeDB schema, written in Rust, defines both your data’s structure and your server-side logic, compiled into a WebAssembly module.
  • Tables from Structs: Rust structs decorated with #[spacetimedb(table)] become your database tables.
  • Primary Keys: Every table must have a #[primarykey] field (typically u64) for unique identification.
  • Indexes for Speed: Use #[index] on fields you’ll frequently query or filter by to ensure performant lookups. #[unique] ensures field values are distinct across rows.
  • Logical Relations: Relationships between tables are established by referencing primary keys (e.g., user_id in TodoItem linking to User.id), rather than explicit foreign key constraints.
  • Build and Deploy: The workflow involves spacetime build to compile your Rust module and spacetime db start --module-path target/spacetime.wasm (or spacetime db deploy) to apply it to your SpaceTimeDB instance.

You now have a solid understanding of how to structure your application’s data within SpaceTimeDB. In the next chapter, we’ll learn how to interact with this schema by writing server-side logic using SpaceTimeDB’s “reducers” to create, update, and delete data, bringing your real-time application to life!

References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.