Skip to main content

rust-server

SpacetimeDB Rust server module SDK reference. Use when writing tables, reducers, or module logic in Rust.

Source facts

Repository
clockworklabs/SpacetimeDB
Last source activity
November 4, 2026 at 18:46
Detected SKILL.md language
English
Stars
25,531
Forks
1,117

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
rust-server
description
SpacetimeDB Rust server module SDK reference. Use when writing tables, reducers, or module logic in Rust.
license
Apache-2.0
metadata
{"author":"clockworklabs","version":"2.0","role":"server","language":"rust","cursor_globs":"**/*.rs","cursor_always_apply":true}
# SpacetimeDB Rust SDK Reference ## Imports ```rust use spacetimedb::{ procedure, reducer, table, Filter, Identity, ProcedureContext, Query, ReducerContext, SpacetimeType, Table, ConnectionId, ScheduleAt, TimeDuration, Timestamp, Uuid, }; ``` **`Table` is required.** Without it, `ctx.db.*.insert()`, `.iter()`, `.find()` etc. won't compile (`no method named 'insert' found`). ## Tables `#[spacetimedb::table(...)]` on a `pub struct`. `accessor` must be snake_case: ```rust #[spacetimedb::table(accessor = entity, public)] pub struct Entity { #[primary_key] #[auto_inc] pub id: u64, pub owner: Identity, pub name: String, #[index(btree)] pub active: bool, } ``` Options: `accessor = snake_case` (required), `public`, `scheduled(reducer_fn)`, `index(...)` `ctx.db` accessors use the `accessor` name (snake_case). ## Column Types | Rust type | Notes | |-----------|-------| | `u8` / `u16` / `u32` / `u64` / `u128` | unsigned integers | | `i8` / `i16` / `i32` / `i64` / `i128` | signed integers | | `spacetimedb::sats::u256` / `spacetimedb::sats::i256` | 256-bit integers | | `f32` / `f64` | floats | | `bool` | boolean | | `String` | text | | `Vec<T>` | list/array | | `Identity` | user identity | | `ConnectionId` | connection handle | | `Timestamp` | server timestamp (microseconds since epoch) | | `TimeDuration` | duration in microseconds | | `Uuid` | UUID | | `Option<T>` | nullable column | ## Column Attributes ```rust #[primary_key] // primary key #[auto_inc] // auto-increment (use 0 as placeholder on insert) #[unique] // unique constraint #[index(btree)] // btree index (enables .filter() on this column) #[default(true)] // migration-safe default for a newly appended column ``` Defaults support compatible addition of a newly appended field. Do not place `#[default(...)]` on primary-key, unique, or auto-increment columns. ## Indexes Prefer `#[index(btree)]` inline for single-column. Multi-column uses table-level: ```rust // Inline (preferred for single-column): #[index(btree)] pub author_id: u64, // Access: ctx.db.post().author_id().filter(author_id) // Multi-column (table-level): #[spacetimedb::table(accessor = membership, public, index(accessor = by_group_user, btree(columns = [group_id, user_id])) )] pub struct Membership { pub group_id: u64, pub user_id: Identity, ... } // Access: ctx.db.membership().by_group_user().filter((group_id, &user_id)) ``` When you frequently look up rows by multiple columns, prefer a multi-column index over filtering by one column and looping over the results. ## Reducers ```rust #[spacetimedb::reducer] pub fn create_entity(ctx: &ReducerContext, name: String) { ctx.db.entity().insert(Entity { id: 0, owner: ctx.sender(), name, active: true }); } // Reducers can return Result<(), String> or Result<(), E> where E: Display #[spacetimedb::reducer] pub fn validate_entity(ctx: &ReducerContext, name: String) -> Result<(), String> { if name.is_empty() { return Err("Name cannot be empty".to_string()); } ctx.db.entity().try_insert(Entity { id: 0, owner: ctx.sender(), name, active: true })?; Ok(()) } ``` Note: `insert()` panics on constraint violations. Use `try_insert()` with `?` when returning `Result`. ## DB Operations ```rust ctx.db.entity().insert(Entity { id: 0, name: "Sample".into() }); // Insert (0 for autoInc) ctx.db.entity().id().find(entity_id); // Find by PK → Option<Entity> ctx.db.entity().identity().find(ctx.sender()); // Find by unique column → Option<Entity> ctx.db.item().author_id().filter(author_id); // Filter by index → iterator ctx.db.entity().iter(); // All rows → iterator ctx.db.entity().count(); // Count rows ctx.db.entity().id().update(Entity { name: new_name, ..existing }); // Update (override + spread) ctx.db.entity().id().delete(entity_id); // Delete by PK ctx.db.entity().name().delete("Alice".to_string()); // Delete by indexed String column ``` Note: `iter()` and `filter()` return iterators. Collect to Vec if you need `.sort()`, `.filter()`, `.map()`. Range queries on btree indexes: `filter(18..=65)`, `filter(18..)`, `filter(..18)`. String column accessors operate on the column's owned `String` type, not `&str`. Pass a `String` or `&String` to `find` and `delete`; index filters borrow the key, as in `ctx.db.product().category().filter(&"hardware".to_string())`. ## Lifecycle Hooks ```rust #[spacetimedb::reducer(init)] pub fn init(ctx: &ReducerContext) { ... } #[spacetimedb::reducer(client_connected)] pub fn on_connect(ctx: &ReducerContext) { ... } #[spacetimedb::reducer(client_disconnected)] pub fn on_disconnect(ctx: &ReducerContext) { ... } ``` The current connection ID is available through `ctx.connection_id()` (not a public field) and may be absent outside connection-scoped calls. ## Views ```rust // Anonymous view (same result for all clients): use spacetimedb::{view, AnonymousViewContext}; #[view(accessor = active_users, public)] fn active_users(ctx: &AnonymousViewContext) -> Vec<Entity> { ctx.db.entity().active().filter(true).collect() } // Per-user view (result varies by sender): use spacetimedb::{view, ViewContext}; #[view(accessor = my_profile, public)] fn my_profile(ctx: &ViewContext) -> Option<Entity> { ctx.db.entity().identity().find(ctx.sender()) } ``` Procedural-view table handles support indexed `find` and `filter` access, but not full-table `iter()`. Start a procedural view from an appropriate table index. Declare a procedural view primary key in the view attribute: ```rust #[view(accessor = catalog_entry, public, primary_key = sku)] fn catalog_entry(ctx: &AnonymousViewContext) -> Vec<CatalogEntry> { ... } ``` Procedural-view primary keys are explicit schema metadata. Add one only when the view itself is required to expose a primary key; a source table's primary key is not inherited by the view. Query-builder views use `ViewContext`, `ctx.from`, and return `impl Query<Row>`. Use `filter` for predicates and `right_semijoin` when the result should contain right-side rows that have a matching left-side row: ```rust ctx.from.article().filter(|article| article.published.eq(true)) ctx.from.subscription().right_semijoin( ctx.from.account(), |subscription, account| subscription.account_id.eq(account.id), ) ``` Query-builder comparisons against a `String` column take an owned `String`, for example `row.label.eq("active".to_string())`. Views can also accept arguments (2.13+), after the context: ```rust #[spacetimedb::view(accessor = entity_by_id, public)] pub fn entity_by_id(ctx: &spacetimedb::AnonymousViewContext, id: u64) -> Option<Entity> { ctx.db.entity().id().find(id) } ``` Floating-point arguments and primary keys are rejected on argument-bearing views. See client skills for SQL subscription syntax and restrictions. ## Client Visibility Filters ```rust use spacetimedb::Filter; #[spacetimedb::client_visibility_filter] const PRIVATE_NOTE_FILTER: Filter = Filter::Sql("SELECT * FROM owned_row WHERE owner = :sender"); ``` ## Reducer Context API `ReducerContext` (`ctx`) is the only source of sender identity, time, and randomness; stdlib clocks and RNG are unavailable in modules. ```rust // Auth: ctx.sender() is the caller's Identity if row.owner != ctx.sender() { panic!("unauthorized"); // or: return Err(anyhow::anyhow!("unauthorized")); } // Server timestamp (deterministic per reducer call) ctx.db.item().insert(Item { id: 0, owner: ctx.sender(), created_at: ctx.timestamp, .. }); // Timestamp arithmetic let expiry = ctx.timestamp + TimeDuration::from_micros(delay_micros); // Deterministic RNG: ctx.random() for a single value, ctx.rng() for the rand::Rng trait use spacetimedb::rand::Rng; let n: u32 = ctx.random(); // random u32 let roll: u32 = ctx.rng().gen_range(1..=6); // any rand::Rng method // Client: Timestamp → milliseconds since epoch timestamp.to_micros_since_unix_epoch() / 1000 ``` ## Environment Variables ```rust #[spacetimedb::env] pub struct Env { pub API_KEY: String, pub LOG_LEVEL: Option<String>, } // Inside a root entry point: // ctx.env.API_KEY() -> String // ctx.env.LOG_LEVEL() -> Option<String> // ctx.env.get("LOG_LEVEL") -> Option<String> ``` For constrained values, derive `spacetimedb::EnvironmentValue` on a payload-free enum and use it as a field type (or `Option<Enum>`). `#[env(value = "production")]` on a variant supplies its exact accepted string; without it, the Rust variant name is used. `#[env(values("info", "debug"))]` also constrains `String`/`Option<String>` fields. See the concepts skill for shared environment and secret-handling rules. ## Scheduled Tables ```rust #[spacetimedb::table(accessor = tick_timer, scheduled(tick), public)] pub struct TickTimer { #[primary_key] #[auto_inc] scheduled_id: u64, scheduled_at: spacetimedb::ScheduleAt, } #[spacetimedb::reducer] pub fn tick(ctx: &ReducerContext, timer: TickTimer) { // timer row is auto-deleted after this reducer runs } // One-time: fires once at a specific time let at = ScheduleAt::Time(ctx.timestamp + std::time::Duration::from_secs(10)); // Repeating: fires on an interval let at = ScheduleAt::Interval(std::time::Duration::from_secs(5).into()); ctx.db.tick_timer().insert(TickTimer { scheduled_id: 0, scheduled_at: at }); ``` Construct a connection ID from a numeric representation with `ConnectionId::from_u128(value)`. ## Inter-Database Calls (Async IDC) A reducer calls a reducer in another database by inserting a row into an outbox table. The call is sent only if the transaction commits, runs exactly once, and runs in order with other calls through the same outbox to the same database. The sending reducer doesn't wait for it. Both databases must be on the same server. Generate receiver bindings by listing the receiving module under `dependencies` in the sending database's `spacetime.json`, then running `spacetime generate <sending-database>`. Rust bindings are written to `src/remote_bindings/`. ```json { "database": "lobby", "module-path": "./lobby", "dependencies": [{ "name": "game_world", "database": "game-world" }], "children": [{ "database": "game-world", "module-path": "./game_world" }] } ``` ```rust
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub