skills/rust-server/SKILL.md
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).
#[spacetimedb::table(...)] on a pub struct. accessor must be snake_case:
#[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).
| 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 |
#[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.
Prefer #[index(btree)] inline for single-column. Multi-column uses table-level:
// 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.
#[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.
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()).
#[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.
// 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:
#[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:
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()).
use spacetimedb::Filter;
#[spacetimedb::client_visibility_filter]
const PRIVATE_NOTE_FILTER: Filter =
Filter::Sql("SELECT * FROM owned_row WHERE owner = :sender");
ReducerContext (ctx) is the only source of sender identity, time, and randomness; stdlib clocks and RNG are unavailable in modules.
// 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
#[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).
Procedures use &mut ProcedureContext and may return typed values:
use spacetimedb::{procedure, ProcedureContext, SpacetimeType};
#[derive(SpacetimeType)]
pub struct ResultValue { pub value: String }
#[procedure]
pub fn inspect(_ctx: &mut ProcedureContext, input: String) -> ResultValue {
ResultValue { value: input }
}
Outbound HTTP is available through ctx.http. Convenience methods such as get return a response, and other methods use Request::builder() with ctx.http.send(request). response.status() returns a StatusCode; use is_success() to test it or as_u16() when a numeric status is needed. Responses are not cloneable, so inspect status and headers before consuming the body with response.into_body().into_string_lossy(). Header values use the fallible to_str() conversion; they do not provide as_str().
Open short database transactions with ctx.with_tx(|tx| ...). Access tables inside the callback through tx.db, not directly on tx. It returns the callback's value directly, so do not call unwrap or expect on the result unless the callback itself returns a Result. The callback implements Fn, so clone captured owned values when storing them rather than moving them out of the closure. Perform network I/O before opening the transaction.
For an outbound request without a convenience method:
let request = Request::builder()
.method("POST")
.uri(url)
.body(Body::from_bytes(data))
.unwrap();
let response = ctx.http.send(request).expect("request failed");
Scheduled procedures use the ordinary scheduled-table shape, but the scheduled function is marked #[procedure] and receives &mut ProcedureContext plus the scheduled row.
Inbound HTTP uses handler functions and one router:
use spacetimedb::http::{handler, router, Body, HandlerContext, Request, Response, Router};
#[handler]
fn health(_ctx: &mut HandlerContext, _request: Request) -> Response {
Response::builder().status(200).body(Body::from_bytes("ok")).unwrap()
}
#[router]
fn routes() -> Router { Router::new().get("/health", health) }
HandlerContext does not expose db; open database access with ctx.with_tx(|tx| ...). HTTP response bodies must own their data or borrow 'static data, so convert dynamic borrowed text to an owned String before passing it to Body::from_bytes.
log::info!("Player connected: {:?}", ctx.sender());
log::warn!("Low health: {}", hp);
log::error!("Failed to find entity");
#[derive(SpacetimeType)]
pub enum Status { Online, Away, Offline }
#[derive(SpacetimeType)]
pub struct Point { x: f32, y: f32 }
// src/lib.rs
use spacetimedb::{Identity, ReducerContext, SpacetimeType, Table, Timestamp};
#[spacetimedb::table(accessor = entity, public)]
pub struct Entity {
#[primary_key]
pub identity: Identity,
pub name: String,
pub active: bool,
}
#[spacetimedb::table(accessor = record, public)]
pub struct Record {
#[primary_key]
#[auto_inc]
pub id: u64,
pub owner: Identity,
pub value: u32,
pub created_at: Timestamp,
}
#[spacetimedb::reducer(client_connected)]
pub fn on_connect(ctx: &ReducerContext) {
if let Some(existing) = ctx.db.entity().identity().find(ctx.sender()) {
ctx.db.entity().identity().update(Entity { active: true, ..existing });
}
}
#[spacetimedb::reducer(client_disconnected)]
pub fn on_disconnect(ctx: &ReducerContext) {
if let Some(existing) = ctx.db.entity().identity().find(ctx.sender()) {
ctx.db.entity().identity().update(Entity { active: false, ..existing });
}
}
#[spacetimedb::reducer]
pub fn create_entity(ctx: &ReducerContext, name: String) {
if ctx.db.entity().identity().find(ctx.sender()).is_some() {
panic!("already exists");
}
ctx.db.entity().insert(Entity { identity: ctx.sender(), name, active: true });
}
#[spacetimedb::reducer]
pub fn add_record(ctx: &ReducerContext, value: u32) {
if ctx.db.entity().identity().find(ctx.sender()).is_none() {
panic!("not found");
}
ctx.db.record().insert(Record {
id: 0,
owner: ctx.sender(),
value,
created_at: ctx.timestamp,
});
}