mcpField
S
spacetimedb-rust

spacetimedb-rust

github.com/clockworklabs开发25.4k85 次安装今天更新
代码仓库

关于 spacetimedb-rust

介绍使用 Rust 编写 SpacetimeDB 模块的开发指南,模块以 WebAssembly 应用形式运行在数据库内。内容涵盖表、reducer、执行约束、procedure 和常见实现错误。

主要功能

  1. 01定义表和自定义类型
  2. 02实现事务型 reducer
  3. 03使用索引和定时 reducer
  4. 04处理身份与错误
  5. 05了解 procedure 的限制

适用场景

  1. 01创建 Rust SpacetimeDB 模块
  2. 02实现数据库表和 reducer
  3. 03添加定时或事件驱动逻辑
  4. 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
  • 忘记导入 Table trait — 表操作必需
  • 通过字段访问表 — 使用 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 实例会被销毁

硬性要求

  1. 不要在 #[table] 结构体上派生 SpacetimeType — 宏会处理此事
  2. 导入 Table trait — 所有表操作都必需
  3. 使用 &ReducerContext — 而非 &mut ReducerContext
  4. 表是方法 — 使用 ctx.db.table(),而非 ctx.db.table
  5. 使用 ctx.sender() — 调用方法,而非访问字段(2.0)
  6. 使用 accessor = 设置 API 句柄 — name = "..." 是表/索引属性中的可选规范名称
  7. 归约器必须是确定性的 — 不得使用文件系统、网络、计时器或外部随机数生成器
  8. 使用 ctx.rng() — 不要使用 rand crate 生成随机数
  9. 添加 public 标记 — 如果客户端需要订阅某个表
  10. 仅通过主键更新 — 对非主键字段的更改使用删除后插入(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(())
}
归约器规则
  1. 第一个参数必须是 &ReducerContext
  2. 返回 ()、Result<(), String>,或满足 E: Display 的 Result<(), E>
  3. 发生 panic 或返回 Err 时,所有更改都会回滚
  4. 必须导入 Table trait: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(())
}
ReducerProcedure
&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>

重要约束

  1. 无全局状态:在 Reducer 调用之间使用静态/全局变量属于未定义行为
  2. 无副作用:Reducer 不能发起网络请求或执行文件 I/O
  3. 确定性执行:使用 ctx.rng() 和 ctx.new_uuid_*() 生成随机值
  4. 事务性:失败时,所有 Reducer 更改都会回滚
  5. 隔离性:Reducer 在提交之前看不到并发更改

评论

支持 Markdown