关于 spacetimedb-rust
介绍使用 Rust 编写 SpacetimeDB 模块的开发指南,模块以 WebAssembly 应用形式运行在数据库内。内容涵盖表、reducer、执行约束、procedure 和常见实现错误。
主要功能
- 01定义表和自定义类型
- 02实现事务型 reducer
- 03使用索引和定时 reducer
- 04处理身份与错误
- 05了解 procedure 的限制
适用场景
- 01创建 Rust SpacetimeDB 模块
- 02实现数据库表和 reducer
- 03添加定时或事件驱动逻辑
- 04排查模块实现错误
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ clockworklabs/ spacetimedb --skill spacetimedb-rust安装后在 Claude Code 中输入/spacetimedb-rust确认已加载。
详细介绍
SpacetimeDB Rust 模块开发
SpacetimeDB 模块是在数据库内部运行的 WebAssembly 应用程序。它们定义用于存储数据的表和用于修改数据的归约器。客户端直接连接到数据库,并在其中执行应用程序逻辑。
测试版本: SpacetimeDB 2.0+ API
幻觉 API — 请勿使用
这些 API/模式不正确。LLM 经常会臆造出它们。
在 2.0 中,以下两种宏形式都有效:#[spacetimedb::table(...)] / #[table(...)] 和 #[spacetimedb::reducer] / #[reducer]。
#[derive(Table)] // Tables use #[table] attribute, not derive
#[derive(Reducer)] // Reducers use #[reducer] attribute
// WRONG — SpacetimeType on tables
#[derive(SpacetimeType)] // DO NOT use on #[table] structs!
#[table(accessor = my_table)]
pub struct MyTable { ... }
// WRONG — mutable context
pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext
// WRONG — table access without parentheses
ctx.db.player // Should be ctx.db.player()
ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id)
// WRONG — old 1.0 patterns
ctx.sender // Use ctx.sender() — method, not field (2.0)
.with_module_name("db") // Use .with_database_name() (2.0)
ctx.db.user().name().update(..) // Update only via primary key (2.0)
正确模式:
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
use spacetimedb::SpacetimeType; // Only for custom types, NOT tables
// CORRECT TABLE — accessor, not name; no SpacetimeType derive!
#[table(accessor = player, public)]
pub struct Player {
#[primary_key]
pub id: u64,
pub name: String,
}
// CORRECT REDUCER — immutable context, sender() is a method
#[reducer]
pub fn create_player(ctx: &ReducerContext, name: String) {
ctx.db.player().insert(Player { id: 0, name });
}
// CORRECT TABLE ACCESS — methods with parentheses, sender() method
let player = ctx.db.player().id().find(&player_id);
let caller = ctx.sender();
请勿:
- 在
#[table]结构体上派生SpacetimeType— 宏会处理此事 - 使用可变上下文 — 使用
&ReducerContext,而非&mut ReducerContext - 忘记导入
Tabletrait — 表操作必需 - 通过字段访问表 — 使用
ctx.db.player(),而非ctx.db.player - 使用
ctx.sender— 在 2.0 中应使用ctx.sender()(方法)
常见错误表
| 错误 | 正确 | 错误原因 |
|---|---|---|
#[table(accessor = "my_table")] | #[table(accessor = my_table)] | 不允许使用字符串字面量 |
表上缺少 public | 添加 public 标记 | 客户端无法订阅 |
| 在归约器中使用网络/文件系统 | 改用过程 | 违反沙箱限制 |
| 对预期错误触发 panic | 返回 Result<(), String> | WASM 实例会被销毁 |
硬性要求
- 不要在
#[table]结构体上派生SpacetimeType— 宏会处理此事 - 导入
Tabletrait — 所有表操作都必需 - 使用
&ReducerContext— 而非&mut ReducerContext - 表是方法 — 使用
ctx.db.table(),而非ctx.db.table - 使用
ctx.sender()— 调用方法,而非访问字段(2.0) - 使用
accessor =设置 API 句柄 —name = "..."是表/索引属性中的可选规范名称 - 归约器必须是确定性的 — 不得使用文件系统、网络、计时器或外部随机数生成器
- 使用
ctx.rng()— 不要使用randcrate 生成随机数 - 添加
public标记 — 如果客户端需要订阅某个表 - 仅通过主键更新 — 对非主键字段的更改使用删除后插入(2.0)
项目设置
[package]
name = "my-module"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
spacetimedb = { workspace = true }
log = "0.4"
必要导入
use spacetimedb::{ReducerContext, Table};
use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt};
表定义
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
#[auto_inc]
id: u64,
name: String,
score: u32,
}
表属性
| 属性 | 描述 |
|---|---|
accessor = identifier | 必需。ctx.db.{accessor}() 中使用的 API 名称 |
public | 通过订阅使客户端可见该表 |
scheduled(function_name) | 创建一个计划表,用于触发指定的归约器或过程 |
index(accessor = idx, btree(columns = [a, b])) | 多列索引 |
列属性
| 属性 | 描述 |
|---|---|
#[primary_key] | 行的唯一标识符(每张表最多一个) |
#[unique] | 强制唯一性,并启用 find() 方法 |
#[auto_inc] | 插入 0 时自动生成唯一整数值 |
#[index(btree)] | 创建 B-tree 索引以提高查找效率 |
支持的列类型
基本类型:u8-u256、i8-i256、f32、f64、bool、String
SpacetimeDB 类型:Identity、ConnectionId、Timestamp、Uuid、ScheduleAt
集合类型:Vec<T>、Option<T>、Result<T, E>
自定义类型:任何使用 #[derive(SpacetimeType)] 的结构体/枚举
归约器
#[spacetimedb::reducer]
pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
ctx.db.player().insert(Player { id: 0, name, score: 0 });
Ok(())
}
归约器规则
- 第一个参数必须是
&ReducerContext - 返回
()、Result<(), String>,或满足E: Display的Result<(), E> - 发生 panic 或返回
Err时,所有更改都会回滚 - 必须导入
Tabletrait:use spacetimedb::Table;
ReducerContext
ctx.db // Database access
ctx.sender() // Identity of the caller (method, not field!)
ctx.connection_id() // Option<ConnectionId> (None for scheduled/system reducers)
ctx.timestamp // Invocation timestamp
ctx.identity() // Module's own identity
ctx.rng() // Deterministic RNG (method, not field!)
表操作
插入
// Insert returns the row with auto_inc values populated
let player = ctx.db.player().insert(Player { id: 0, name: "Alice".into(), score: 100 });
log::info!("Created player with id: {}", player.id);
查找和筛选
// Find by unique/primary key — returns Option
if let Some(player) = ctx.db.player().id().find(&123) {
log::info!("Found: {}", player.name);
}
// Optional clarity: typed literals can avoid inference ambiguity
if let Some(player) = ctx.db.player().id().find(&123u64) {
log::info!("Found: {}", player.name);
}
// Filter by indexed column — returns iterator
for player in ctx.db.player().name().filter(&"Alice".to_string()) {
log::info!("Player: {}", player.name);
}
// Full table scan
for player in ctx.db.player().iter() { }
let total = ctx.db.player().count();
更新
// Update via primary key (2.0: only primary key has update)
if let Some(player) = ctx.db.player().id().find(&123) {
ctx.db.player().id().update(Player { score: player.score + 10, ..player });
}
// For non-PK changes: delete + insert
if let Some(old) = ctx.db.player().id().find(&id) {
ctx.db.player().id().delete(&id);
ctx.db.player().insert(Player { name: new_name, ..old });
}
删除
// Delete by primary key
ctx.db.player().id().delete(&123);
// Delete by indexed column (collect first to avoid iterator invalidation)
let to_remove: Vec<u64> = ctx.db.player().name().filter(&"Alice".to_string())
.map(|p| p.id)
.collect();
for id in to_remove {
ctx.db.player().id().delete(&id);
}
索引
// Single-column index
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
id: u64,
#[index(btree)]
level: u32,
name: String,
}
// Multi-column index
#[spacetimedb::table(
accessor = score, public,
index(accessor = by_player_level, btree(columns = [player_id, level]))
)]
pub struct Score {
player_id: u32,
level: u32,
points: i64,
}
// Multi-column index querying: prefix match (first column only)
for s in ctx.db.score().by_player_level().filter(&(42,)) {
log::info!("Player 42, any level: {} pts", s.points);
}
// Full match (both columns)
for s in ctx.db.score().by_player_level().filter(&(42, 5)) {
log::info!("Player 42, level 5: {} pts", s.points);
}
事件表(2.0)
2.0 中已移除 Reducer 回调。请改用事件表和 on_insert。
#[table(accessor = damage_event, public, event)]
pub struct DamageEvent {
pub target: Identity,
pub amount: u32,
}
#[reducer]
fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
ctx.db.damage_event().insert(DamageEvent { target, amount });
}
客户端订阅并使用 on_insert:
conn.db.damage_event().on_insert(|ctx, event| {
play_damage_animation(event.target, event.amount);
});
必须显式订阅事件表——它们不会包含在 subscribe_to_all_tables() 中。
生命周期 Reducer
#[spacetimedb::reducer(init)]
pub fn init(ctx: &ReducerContext) -> Result<(), String> {
log::info!("Database initializing...");
ctx.db.config().insert(Config {
id: 0,
max_players: 100,
game_mode: "default".to_string(),
});
Ok(())
}
#[spacetimedb::reducer(client_connected)]
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> {
let caller = ctx.sender();
log::info!("Client connected: {}", caller);
if let Some(user) = ctx.db.user().identity().find(&caller) {
ctx.db.user().identity().update(User { online: true, ..user });
} else {
ctx.db.user().insert(User {
identity: caller,
name: format!("User-{}", &caller.to_hex()[..8]),
online: true,
});
}
Ok(())
}
#[spacetimedb::reducer(client_disconnected)]
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> {
let caller = ctx.sender();
if let Some(user) = ctx.db.user().identity().find(&caller) {
ctx.db.user().identity().update(User { online: false, ..user });
}
Ok(())
}
定时 Reducer
use spacetimedb::ScheduleAt;
use std::time::Duration;
#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))]
pub struct GameTickSchedule {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
scheduled_at: ScheduleAt,
}
#[spacetimedb::reducer]
fn game_tick(ctx: &ReducerContext, schedule: GameTickSchedule) {
if !ctx.sender_auth().is_internal() { return; }
log::info!("Game tick at {:?}", ctx.timestamp);
}
// Schedule at interval (e.g., in init reducer)
ctx.db.game_tick_schedule().insert(GameTickSchedule {
scheduled_id: 0,
scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()),
});
// Schedule at specific time
let run_at = ctx.timestamp + Duration::from_secs(delay_secs);
ctx.db.reminder_schedule().insert(ReminderSchedule {
scheduled_id: 0,
scheduled_at: ScheduleAt::Time(run_at),
});
身份与身份验证
#[spacetimedb::table(accessor = user, public)]
pub struct User {
#[primary_key]
identity: Identity,
name: String,
online: bool,
}
#[spacetimedb::reducer]
pub fn set_name(ctx: &ReducerContext, new_name: String) -> Result<(), String> {
let caller = ctx.sender();
let user = ctx.db.user().identity().find(&caller)
.ok_or("User not found — connect first")?;
ctx.db.user().identity().update(User { name: new_name, ..user });
Ok(())
}
仅限所有者的 Reducer 模式
fn require_owner(ctx: &ReducerContext, entity_owner: &Identity) -> Result<(), String> {
if ctx.sender() != *entity_owner {
Err("Not authorized: you don't own this entity".to_string())
} else {
Ok(())
}
}
#[spacetimedb::reducer]
pub fn rename_character(ctx: &ReducerContext, char_id: u64, new_name: String) -> Result<(), String> {
let character = ctx.db.character().id().find(&char_id)
.ok_or("Character not found")?;
require_owner(ctx, &character.owner)?;
ctx.db.character().id().update(Character { name: new_name, ..character });
Ok(())
}
错误处理
// Sender error — return Err (user sees message, transaction rolls back cleanly)
#[spacetimedb::reducer]
pub fn transfer(ctx: &ReducerContext, to: Identity, amount: u64) -> Result<(), String> {
let sender = ctx.db.wallet().identity().find(&ctx.sender())
.ok_or("Wallet not found")?;
if sender.balance < amount {
return Err("Insufficient balance".to_string());
}
// ... proceed with transfer
Ok(())
}
// Programmer error — panic (destroys the WASM instance, expensive!)
// Only use for truly impossible states
#[spacetimedb::reducer]
pub fn process(ctx: &ReducerContext, id: u64) {
let item = ctx.db.item().id().find(&id)
.expect("BUG: item should exist at this point");
// ...
}
对于所有预期的失败情况,优先使用 Result<(), String>。发生 panic 会销毁并重新创建 WASM 实例。
Procedures(Beta)
Procedures 在
spacetimedb中属于unstable功能。 在Cargo.toml中:spacetimedb = { version = "...", features = ["unstable"] }
use spacetimedb::{procedure, ProcedureContext};
#[procedure]
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
let data = fetch_from_url(&url)?;
ctx.try_with_tx(|tx| {
tx.db.external_data().insert(ExternalData { id: 0, content: data });
Ok(())
})?;
Ok(())
}
| Reducer | Procedure |
|---|---|
&ReducerContext(不可变) | &mut ProcedureContext(可变) |
直接访问 ctx.db | 必须使用 ctx.with_tx() |
| 不支持 HTTP/网络 | 支持 HTTP |
| 无返回值 | 可以返回数据 |
自定义类型
use spacetimedb::SpacetimeType;
#[derive(SpacetimeType)]
pub enum PlayerStatus { Active, Idle, Away }
#[derive(SpacetimeType)]
pub struct Position { x: f32, y: f32, z: f32 }
// Use in table (DO NOT derive SpacetimeType on the table!)
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
id: u64,
status: PlayerStatus,
position: Position,
}
命令
spacetime build
spacetime publish my_database --module-path .
spacetime publish my_database --clear-database --module-path .
spacetime logs my_database
spacetime call my_database create_player "Alice"
spacetime sql my_database "SELECT * FROM player"
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
重要约束
- 无全局状态:在 Reducer 调用之间使用静态/全局变量属于未定义行为
- 无副作用:Reducer 不能发起网络请求或执行文件 I/O
- 确定性执行:使用
ctx.rng()和ctx.new_uuid_*()生成随机值 - 事务性:失败时,所有 Reducer 更改都会回滚
- 隔离性:Reducer 在提交之前看不到并发更改