spacetimedb-rust について
Rust で SpacetimeDB モジュールを実装するための開発ガイドです。モジュールはデータベース内で WebAssembly アプリケーションとして動作し、テーブル、reducer、実行制約、procedure、よくある実装ミスを説明します。
主な機能
- 01テーブルと独自型の定義
- 02トランザクション型 reducer の実装
- 03インデックスとスケジュール reducer の利用
- 04ID とエラーの処理
- 05procedure の制約の把握
活用シーン
- 01Rust で 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を導出する — マクロが処理します- 可変コンテキストを使用する —
&mut ReducerContextではなく、&ReducerContextを使用します Tableトレイトのインポートを忘れる — テーブル操作に必要です- テーブルにフィールドアクセスを使用する —
ctx.db.playerではなく、ctx.db.player()を使用します ctx.senderを使用する — 2.0 ではctx.sender()(メソッド)です
よくある間違いの一覧
| 誤り | 正しい形 | エラー |
|---|---|---|
#[table(accessor = "my_table")] | #[table(accessor = my_table)] | 文字列リテラルは使用できません |
テーブルに public がない | public フラグを追加する | クライアントがサブスクライブできません |
| リデューサー内のネットワーク/ファイルシステム | 代わりにプロシージャを使用する | サンドボックス違反 |
| 想定されるエラーに対してパニックを発生させる | Result<(), String> を返す | WASM インスタンスが破棄されます |
厳守事項
#[table]構造体にSpacetimeTypeを導出しないでください — マクロが処理しますTableトレイトをインポートしてください — すべてのテーブル操作に必要です&ReducerContextを使用してください —&mut ReducerContextではありません- テーブルはメソッドです —
ctx.db.tableではなく、ctx.db.table()を使用します ctx.sender()を使用してください — フィールドアクセスではなくメソッド呼び出しです(2.0)- API ハンドルには
accessor =を使用してください —name = "..."はテーブル/インデックス属性で指定する省略可能な正規名です - リデューサーは決定的でなければなりません — ファイルシステム、ネットワーク、タイマー、外部 RNG は使用できません
- 乱数には
randクレートではなくctx.rng()を使用してください - クライアントがテーブルをサブスクライブする必要がある場合は、
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] | 行の一意な識別子です(テーブルごとに最大 1 つ) |
#[unique] | 一意性を強制し、find() メソッドを有効にします |
#[auto_inc] | 0 を挿入すると、一意な整数値を自動生成します |
#[index(btree)] | 効率的な検索のために B ツリーインデックスを作成します |
対応する列の型
プリミティブ型: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>を返します- パニックまたは
Errの返却時には、すべての変更がロールバックされます Tableトレイトをインポートする必要があります: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 ではリデューサーコールバックが削除されます。代わりにイベントテーブルと 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() の対象には含まれません。
ライフサイクルリデューサー
#[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(())
}
スケジュール済みリデューサー
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),
});
ID と認証
#[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(())
}
オーナー限定リデューサーパターン
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> を使用してください。パニックが発生すると WASM インスタンスが破棄され、再作成されます。
プロシージャ(ベータ版)
プロシージャは
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(())
}
| リデューサー | プロシージャ |
|---|---|
&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>
重要な制約
- グローバル状態なし:静的/グローバル変数は、リデューサー呼び出し間で未定義の動作を引き起こします
- 副作用なし:リデューサーはネットワークリクエストやファイル I/O を実行できません
- 決定的な実行:乱数の生成には
ctx.rng()とctx.new_uuid_*()を使用してください - トランザクション性:失敗すると、リデューサーによる変更はすべてロールバックされます
- 分離性:リデューサーはコミットされるまで、並行して行われた変更を認識しません