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. 04ID とエラーの処理
  5. 05procedure の制約の把握

活用シーン

  1. 01Rust で 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 を導出する — マクロが処理します
  • 可変コンテキストを使用する — &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 インスタンスが破棄されます

厳守事項

  1. #[table] 構造体に SpacetimeType を導出しないでください — マクロが処理します
  2. Table トレイトをインポートしてください — すべてのテーブル操作に必要です
  3. &ReducerContext を使用してください — &mut ReducerContext ではありません
  4. テーブルはメソッドです — ctx.db.table ではなく、ctx.db.table() を使用します
  5. ctx.sender() を使用してください — フィールドアクセスではなくメソッド呼び出しです(2.0)
  6. API ハンドルには accessor = を使用してください — name = "..." はテーブル/インデックス属性で指定する省略可能な正規名です
  7. リデューサーは決定的でなければなりません — ファイルシステム、ネットワーク、タイマー、外部 RNG は使用できません
  8. 乱数には rand クレートではなく ctx.rng() を使用してください
  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]行の一意な識別子です(テーブルごとに最大 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(())
}
リデューサーのルール
  1. 最初のパラメーターは &ReducerContext でなければなりません
  2. ()、Result<(), String>、または E: Display を満たす Result<(), E> を返します
  3. パニックまたは Err の返却時には、すべての変更がロールバックされます
  4. 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>

重要な制約

  1. グローバル状態なし:静的/グローバル変数は、リデューサー呼び出し間で未定義の動作を引き起こします
  2. 副作用なし:リデューサーはネットワークリクエストやファイル I/O を実行できません
  3. 決定的な実行:乱数の生成には ctx.rng() と ctx.new_uuid_*() を使用してください
  4. トランザクション性:失敗すると、リデューサーによる変更はすべてロールバックされます
  5. 分離性:リデューサーはコミットされるまで、並行して行われた変更を認識しません

コメント