mcpField
R
rust-server

rust-server

github.com/clockworklabs開発25.4k81 回インストール今日更新
リポジトリ

rust-server について

SpacetimeDB のサーバーモジュール実装向け Rust SDK リファレンスです。テーブルスキーマ、Reducer、ビュー、Procedure、HTTP、トランザクションを扱います。

主な機能

  1. 01テーブルスキーマと列属性
  2. 02Reducer とライフサイクルフック
  3. 03インデックス検索とデータ更新
  4. 04手続き型ビューとクエリビルダービュー
  5. 05Procedure と HTTP ハンドラー
  6. 06トランザクションとスケジュールテーブル

活用シーン

  1. 01Rust モジュールのテーブルとインデックス定義
  2. 02Reducer とライフサイクルフックの実装
  3. 03クライアント向けデータビューの作成
  4. 04HTTP ハンドラーまたは Procedure の追加

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/clockworklabs/spacetimedb --skill rust-server

インストール後、Claude Code で/rust-serverを入力して読み込まれたか確認します。

詳細

SpacetimeDB Rust SDK リファレンス

インポート

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)。

テーブル

pub struct に #[spacetimedb::table(...)] を指定します。accessor はスネークケースである必要があります。

#[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 の名前(スネークケース)が使われます。

カラム型

Rust 型備考
u8 / u16 / u32 / u64 / u128符号なし整数
i8 / i16 / i32 / i64 / i128符号付き整数
spacetimedb::sats::u256 / spacetimedb::sats::i256256 ビット整数
f32 / f64浮動小数点数
bool真偽値
Stringテキスト
Vec<T>リスト/配列
Identityユーザー ID
ConnectionId接続ハンドル
Timestampサーバータイムスタンプ(エポックからのマイクロ秒)
TimeDurationマイクロ秒単位の期間
UuidUUID
Option<T>NULL 許容カラム

カラム属性

#[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

デフォルト値を設定すると、新しいフィールドを末尾に追加する際の互換性が保たれます。主キー、一意制約付き、または自動インクリメントのカラムには #[default(...)] を指定しないでください。

インデックス

単一カラムには、インラインで #[index(btree)] を指定する方法を推奨します。複数カラムの場合は、テーブルレベルで指定します。

// 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))

複数のカラムで行を頻繁に検索する場合は、1 つのカラムでフィルタリングして結果をループするよりも、複数カラムのインデックスを使用してください。

リデューサー

#[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() は制約違反時にパニックします。Result を返す場合は、? とともに try_insert() を使用してください。

DB 操作

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)。

文字列カラムのアクセサーが扱う型は &str ではなく、所有権を持つ String です。find と delete には String または &String を渡してください。インデックスフィルターではキーを借用します。例:ctx.db.product().category().filter(&"hardware".to_string())。

ライフサイクルフック

#[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() で取得できます(公開フィールドではありません)。接続スコープ外の呼び出しでは、値が存在しない場合があります。

ビュー

// 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() には対応していません。適切なテーブルインデックスから手続き型ビューを開始してください。

ビュー属性で手続き型ビューの主キーを宣言します。

#[view(accessor = catalog_entry, public, primary_key = sku)]
fn catalog_entry(ctx: &AnonymousViewContext) -> Vec<CatalogEntry> { ... }

手続き型ビューの主キーは、明示的なスキーマメタデータです。ビュー自体で主キーを公開する必要がある場合にのみ追加してください。ソーステーブルの主キーはビューに継承されません。

クエリビルダービューでは、ViewContext と ctx.from を使用し、impl Query<Row> を返します。述語には filter を使用し、結果に左側の一致する行を持つ右側の行を含める場合は right_semijoin を使用してください。

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),
)

クエリビルダーで String カラムと比較する場合は、所有権を持つ String を渡します。例:row.label.eq("active".to_string())。

クライアント可視性フィルター

use spacetimedb::Filter;

#[spacetimedb::client_visibility_filter]
const PRIVATE_NOTE_FILTER: Filter =
    Filter::Sql("SELECT * FROM owned_row WHERE owner = :sender");

リデューサーコンテキスト API

ReducerContext(ctx)だけが送信者の ID、時刻、乱数の取得元です。モジュールでは標準ライブラリのクロックと乱数生成器を使用できません。

// 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

スケジュールテーブル

#[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 });

数値表現から接続 ID を生成するには、ConnectionId::from_u128(value) を使用します。

プロシージャと HTTP

プロシージャは &mut ProcedureContext を使用し、型付きの値を返すことができます。

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 }
}

送信 HTTP は ctx.http から利用できます。get などの便利なメソッドはレスポンスを返し、その他のメソッドでは Request::builder() と ctx.http.send(request) を使用します。response.status() は StatusCode を返します。ステータスの判定には is_success() を、数値のステータスが必要な場合は as_u16() を使用します。レスポンスはクローンできないため、response.into_body().into_string_lossy() でボディを消費する前に、ステータスとヘッダーを確認してください。ヘッダー値では失敗する可能性のある to_str() 変換を使用します。as_str() は提供されていません。

ctx.with_tx(|tx| ...) で短いデータベーストランザクションを開始します。コールバック内では tx に直接アクセスせず、tx.db 経由でテーブルにアクセスしてください。コールバックの値をそのまま返すため、コールバック自体が Result を返す場合を除き、結果に unwrap や expect を呼び出さないでください。コールバックは Fn を実装しているため、キャプチャした所有値を保存するときは、クロージャからムーブするのではなくクローンしてください。トランザクションを開始する前にネットワーク I/O を実行してください。

便利なメソッドを使わずに送信リクエストを行う場合:

let request = Request::builder()
    .method("POST")
    .uri(url)
    .body(Body::from_bytes(data))
    .unwrap();
let response = ctx.http.send(request).expect("request failed");

スケジュール済みプロシージャでは通常のスケジュール済みテーブル形式を使用しますが、スケジュール関数には #[procedure] を付け、&mut ProcedureContext とスケジュール済みの行を受け取ります。

受信 HTTP ではハンドラー関数と単一のルーターを使用します。

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 レスポンスのボディは、そのデータを所有しているか、'static なデータを借用している必要があります。そのため、Body::from_bytes に渡す前に、動的な借用テキストを所有する String に変換してください。

ロギング

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

カスタム型

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

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

完全な例

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

コメント