rust-server

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SpacetimeDB Rust SDK Reference

SpacetimeDB Rust SDK 参考

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
).
rust
use spacetimedb::{
    procedure, reducer, table, Filter, Identity, ProcedureContext, Query,
    ReducerContext, SpacetimeType, Table, ConnectionId, ScheduleAt,
    TimeDuration, Timestamp, Uuid,
};
Table
是必需的。
如果缺少它,
ctx.db.*.insert()
、
.iter()
、
.find()
等将无法编译(
no method named 'insert' found
)。

Tables

表(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).
在
pub struct
上使用
#[spacetimedb::table(...)]
。
accessor
必须采用 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,
}
选项:
accessor = snake_case
(必填)、
public
、
scheduled(reducer_fn)
、
index(...)
ctx.db
访问器使用
accessor
名称(snake_case)。

Column Types

列类型(Column Types)

Rust typeNotes
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
Rust 类型说明
u8
/
u16
/
u32
/
u64
/
u128
无符号整数
i8
/
i16
/
i32
/
i64
/
i128
有符号整数
spacetimedb::sats::u256
/
spacetimedb::sats::i256
256 位整数
f32
/
f64
浮点数
bool
布尔值
String
文本
Vec<T>
列表/数组
Identity
用户身份
ConnectionId
连接句柄
Timestamp
服务端时间戳(自 epoch 起的微秒数)
TimeDuration
以微秒为单位的时长
Uuid
UUID
Option<T>
可空列

Column Attributes

列属性(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.
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
默认值支持以兼容方式新增追加字段。不要在 primary-key、unique 或 auto-increment 列上使用
#[default(...)]
。

Indexes

索引(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.
单列优先内联使用
#[index(btree)]
。多列使用表级索引:
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))
如果经常按多个列查找行,应优先使用多列索引,而不是按单个列过滤后遍历结果。

Reducers

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
.
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(())
}
注意:
insert()
在违反约束时会 panic。返回
Result
时,请使用
try_insert()
配合
?
。

DB Operations

数据库操作(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())
.
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
注意:
iter()
和
filter()
返回迭代器。如需使用
.sort()
、
.filter()
、
.map()
,请先收集到
Vec
。
btree 索引支持范围查询:
filter(18..=65)
、
filter(18..)
、
filter(..18)
。
String 列访问器操作的是该列拥有的
String
类型,而不是
&str
。向
find
和
delete
传入
String
或
&String
;索引过滤器会借用键,例如
ctx.db.product().category().filter(&"hardware".to_string())
。

Lifecycle Hooks

生命周期钩子(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.
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) { ... }
当前连接 ID 可通过
ctx.connection_id()
获取(它不是公开字段),并且在连接作用域调用之外可能不存在。

Views

视图(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.
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())
}
过程式视图的表句柄支持通过索引进行
find
和
filter
访问,但不支持对整表执行
iter()
。过程式视图应从合适的表索引开始。
在视图属性中声明过程式视图的 primary key:
rust
#[view(accessor = catalog_entry, public, primary_key = sku)]
fn catalog_entry(ctx: &AnonymousViewContext) -> Vec<CatalogEntry> { ... }
过程式视图的 primary key 是显式 schema 元数据。仅当视图本身需要公开 primary key 时才添加;源表的 primary key 不会被视图继承。
Query-builder 视图使用
ViewContext
、
ctx.from
,并返回
impl Query<Row>
。使用
filter
表示谓词;当结果应包含存在匹配左侧行的右侧行时,使用
right_semijoin
:
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 对
String
列进行比较时需传入拥有的
String
,例如
row.label.eq("active".to_string())
。
视图也可以接受参数(2.13+),参数位于 context 之后:
rust
#[spacetimedb::view(accessor = entity_by_id, public)]
pub fn entity_by_id(ctx: &spacetimeb::AnonymousViewContext, id: u64) -> Option<Entity> {
    ctx.db.entity().id().find(id)
}
带参数的视图会拒绝浮点参数和 primary key。有关 SQL 订阅语法和限制,请参阅 client skills。

Client Visibility Filters

客户端可见性过滤器(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");
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

Reducer Context API(Reducer 上下文 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
ReducerContext
(
ctx
)是 sender 身份、时间和随机数的唯一来源;模块中无法使用标准库时钟和 RNG。
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

环境变量(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.
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>
对于受约束的值,在无 payload 的 enum 上派生
spacetimedb::EnvironmentValue
,并将其用作字段类型(或
Option<Enum>
)。enum 变体上的
#[env(value = "production")]
指定其唯一可接受的字符串;未指定时使用 Rust 变体名。
#[env(values("info", "debug"))]
还会约束
String
/
Option<String>
字段。
有关共享环境和 secret 处理规则,请参阅 concepts skill。

Scheduled Tables

定时表(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)
.
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 });
使用
ConnectionId::from_u128(value)
可从数值表示构造连接 ID。

Inter-Database Calls (Async IDC)

数据库间调用(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
mod remote_bindings;
use remote_bindings::game_world;

// Each row is one call to game_world's `admit_player(name: String)` reducer.
#[spacetimedb::table(accessor = admit_player_outbox, outbox(game_world::admit_player))]
pub struct AdmitPlayerOutbox {
    #[primary_key]
    #[auto_inc]
    msg_id: u64,                 // exactly one u64 auto_inc primary key
    #[target]
    world: game_world::Identity, // exactly one target, typed by the bindings
    name: String,                // one column per reducer parameter, same name and type
    // #[param(name = name)] player_name: String,  // alias a column to a parameter
    // Extra columns are allowed. They aren't sent, but on_result can read them.
}

#[spacetimedb::reducer]
pub fn send_to_world(ctx: &ReducerContext, name: String, world: game_world::Identity) {
    // Sent if and only if this transaction commits.
    ctx.db.admit_player_outbox().insert(AdmitPlayerOutbox { msg_id: 0, world, name });
}

// Optional, at most one per outbox. Private. Runs once per row, then the row is deleted.
#[spacetimedb::reducer(on_result(admit_player_outbox))]
pub fn on_admit_player_result(ctx: &ReducerContext, row: AdmitPlayerOutbox, result: Result<(), String>) {
    if let Err(error) = result {
        log::warn!("{} was not admitted: {error}", row.name);
    }
}
  • The receiving reducer is an ordinary reducer and needs no changes. In it,
    ctx.sender()
    is the sending database's identity, so authorize calls by checking it against an allow-list table.
  • The receiving database runs
    client_connected
    and
    client_disconnected
    around every delivered call, with no JWT. If
    client_connected
    rejects the sending database, the call is retried forever.
  • Outbox rows are insert-only, so a pending call can't be cancelled. A call is never retried once it has a result. To try again, insert a new row, for example from
    on_result
    .
  • Wrap a plain
    Identity
    to get a target:
    game_world::Identity(identity)
    .
  • Index lookups (
    find
    ,
    filter
    ) on a
    game_world::Identity
    column don't compile. Iterate and compare instead.
  • An outbox table can't also be a scheduled table or an event table.
  • After the receiving reducer's parameters change, regenerate the bindings and republish the sending database. Calls built from old bindings fail with a signature mismatch error.
reducer 通过向 outbox table 插入一行来调用另一数据库中的 reducer。仅当事务提交时才会发送该调用;调用恰好执行一次,并与通过同一 outbox 发往同一数据库的其他调用按顺序执行。发送方 reducer 不会等待它。两个数据库必须位于同一服务器上。
要生成接收端 bindings,请在发送数据库的
spacetime.json
中将接收模块列在
dependencies
下,然后运行
spacetime generate <sending-database>
。Rust bindings 会写入
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
mod remote_bindings;
use remote_bindings::game_world;

// Each row is one call to game_world's `admit_player(name: String)` reducer.
#[spacetimedb::table(accessor = admit_player_outbox, outbox(game_world::admit_player))]
pub struct AdmitPlayerOutbox {
    #[primary_key]
    #[auto_inc]
    msg_id: u64,                 // exactly one u64 auto_inc primary key
    #[target]
    world: game_world::Identity, // exactly one target, typed by the bindings
    name: String,                // one column per reducer parameter, same name and type
    // #[param(name = name)] player_name: String,  // alias a column to a parameter
    // Extra columns are allowed. They aren't sent, but on_result can read them.
}

#[spacetimedb::reducer]
pub fn send_to_world(ctx: &ReducerContext, name: String, world: game_world::Identity) {
    // Sent if and only if this transaction commits.
    ctx.db.admit_player_outbox().insert(AdmitPlayerOutbox { msg_id: 0, world, name });
}

// Optional, at most one per outbox. Private. Runs once per row, then the row is deleted.
#[spacetimedb::reducer(on_result(admit_player_outbox))]
pub fn on_admit_player_result(ctx: &ReducerContext, row: AdmitPlayerOutbox, result: Result<(), String>) {
    if let Err(error) = result {
        log::warn!("{} was not admitted: {error}", row.name);
    }
}
  • 接收端 reducer 是普通 reducer,无需修改。在其中,
    ctx.sender()
    是发送数据库的身份,因此应通过与 allow-list 表进行核对来授权调用。
  • 接收数据库会围绕每次送达的调用执行
    client_connected
    和
    client_disconnected
    ,且不携带 JWT。如果
    client_connected
    拒绝发送数据库,该调用将被无限重试。
  • Outbox 行仅支持插入,因此无法取消待处理调用。调用一旦产生结果就绝不会重试。如需重试,请插入新行,例如从
    on_result
    中插入。
  • 用普通
    Identity
    包装即可获得 target:
    game_world::Identity(identity)
    。
  • game_world::Identity
    列上的索引查找(
    find
    、
    filter
    )无法编译。请改为遍历并比较。
  • Outbox table 不能同时是 scheduled table 或 event table。
  • 接收端 reducer 的参数发生变化后,请重新生成 bindings 并重新发布发送数据库。使用旧 bindings 构造的调用会因 signature mismatch 而失败。

Procedures and HTTP

Procedures 与 HTTP

Procedures use
&mut ProcedureContext
and may return typed values:
rust
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:
rust
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:
rust
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
.
Procedures 使用
&mut ProcedureContext
,并且可以返回类型化值:
rust
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 }
}
可通过
ctx.http
发起出站 HTTP 请求。
get
等便捷方法会返回 response,其他方法使用
Request::builder()
配合
ctx.http.send(request)
。
response.status()
返回
StatusCode
;可使用
is_success()
测试,或在需要数值状态时使用
as_u16()
。Response 不可 clone,因此应在消费 body 之前检查 status 和 headers,可使用
response.into_body().into_string_lossy()
。Header 值使用可失败的
to_str()
转换;它们不提供
as_str()
。
使用
ctx.with_tx(|tx| ...)
开启短数据库事务。在 callback 内通过
tx.db
访问 table,而不是直接通过
tx
访问。它会直接返回 callback 的值,因此除非 callback 自身返回
Result
,否则不要对结果调用
unwrap
或
expect
。Callback 实现
Fn
,因此存储捕获的 owned 值时应 clone 该值,而不是将其移出 closure。应在开启事务前执行网络 I/O。
对于没有便捷方法的出站请求:
rust
let request = Request::builder()
    .method("POST")
    .uri(url)
    .body(Body::from_bytes(data))
    .unwrap();
let response = ctx.http.send(request).expect("request failed");
定时 procedures 使用普通 scheduled-table 结构,但定时函数标记为
#[procedure]
,并接收
&mut ProcedureContext
及 scheduled row。
入站 HTTP 使用 handler functions 和单个 router:
rust
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
不公开
db
;使用
ctx.with_tx(|tx| ...)
开启数据库访问。HTTP response body 必须拥有其数据或借用
'static
数据,因此将动态借用的文本转换为 owned
String
后再传给
Body::from_bytes
。

Logging

日志(Logging)

rust
log::info!("Player connected: {:?}", ctx.sender());
log::warn!("Low health: {}", hp);
log::error!("Failed to find entity");
rust
log::info!("Player connected: {:?}", ctx.sender());
log::warn!("Low health: {}", hp);
log::error!("Failed to find entity");

Custom Types

自定义类型(Custom Types)

rust
#[derive(SpacetimeType)]
pub enum Status { Online, Away, Offline }

#[derive(SpacetimeType)]
pub struct Point { x: f32, y: f32 }
rust
#[derive(SpacetimeType)]
pub enum Status { Online, Away, Offline }

#[derive(SpacetimeType)]
pub struct Point { x: f32, y: f32 }

Complete Example

完整示例(Complete Example)

rust
// 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,
    });
}
rust
// 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,
    });
}

Module Unit Tests

模块单元测试(Module Unit Tests)

The native in-memory harness is available in 2.12+. Keep the same
spacetimedb
version in normal and dev dependencies, enabling
test-utils
only for tests:
toml
[dependencies]
spacetimedb = { version = "2.13.0", features = ["unstable"] }

[dev-dependencies]
spacetimedb = { version = "2.13.0", features = ["test-utils"] }
icu_calendar = ">=2.1, <2.2" # temporal_rs requires icu_calendar 2.1.x
Run
cargo test
natively, not with a WebAssembly target. For the
create_entity
reducer above:
rust
#[cfg(test)]
mod tests {
    use super::*;
    use spacetimedb::test_utils::{TestAuth, TestContext};

    #[test]
    fn inserts_entity() {
        let test = TestContext::new().expect("test database");
        test.with_reducer_tx(TestAuth::internal(), |ctx| {
            create_entity(ctx, "Alice".to_owned());
            Ok::<(), String>(())
        }).expect("transaction");
        assert_eq!(test.db.entity().count(), 1);
    }
}
with_reducer_tx
commits on
Ok
and rolls back on
Err
or panic unwinding; propagate reducer errors with
?
when the reducer returns
Result
. Create a new
TestContext
for each test: cloning shares the database. Use
TestAuth::from_jwt_payload(payload, connection_id)?
for client calls and
TestAuth::internal()
for the database itself. The harness exposes
clock
,
rng
, procedure/view contexts, and query execution. It does not simulate subscriptions, RLS, lifecycle calls, or timers. Invoke lifecycle/scheduled reducers explicitly and retain integration tests for host behavior.
See https://spacetimedb.com/docs/how-to/unit-testing for test setup, HTTP mocking, and current limitations.
原生内存测试 harness 从 2.12+ 开始可用。在常规依赖和开发依赖中保持相同的
spacetimedb
版本,仅为测试启用
test-utils
:
toml
[dependencies]
spacetimedb = { version = "2.13.0", features = ["unstable"] }

[dev-dependencies]
spacetimedb = { version = "2.13.0", features = ["test-utils"] }
icu_calendar = ">=2.1, <2.2" # temporal_rs requires icu_calendar 2.1.x
原生运行
cargo test
,不要使用 WebAssembly target。对于上面的
create_entity
reducer:
rust
#[cfg(test)]
mod tests {
    use super::*;
    use spacetimedb::test_utils::{TestAuth, TestContext};

    #[test]
    fn inserts_entity() {
        let test = TestContext::new().expect("test database");
        test.with_reducer_tx(TestAuth::internal(), |ctx| {
            create_entity(ctx, "Alice".to_owned());
            Ok::<(), String>(())
        }).expect("transaction");
        assert_eq!(test.db.entity().count(), 1);
    }
}
with_reducer_tx
在返回
Ok
时提交,在返回
Err
或发生 panic unwind 时回滚;当 reducer 返回
Result
时,使用
?
传播 reducer 错误。每个测试都创建新的
TestContext
:clone 会共享数据库。客户端调用使用
TestAuth::from_jwt_payload(payload, connection_id)?
,数据库自身使用
TestAuth::internal()
。Harness 暴露
clock
、
rng
、procedure/view context 和 query execution,但不模拟 subscriptions、RLS、lifecycle calls 或 timers。请显式调用 lifecycle/scheduled reducers,并保留 integration tests 以测试宿主行为。
有关测试设置、HTTP mocking 和当前限制,请参阅 https://spacetimedb.com/docs/how-to/unit-testing。