spacetimedb-csharp について
SpacetimeDB 2.0のサーバーモジュールとC#クライアントを開発するためのリファレンススキルです。正しいAPIパターン、セットアップ要件、よくあるミスを解説します。
主な機能
- 01サーバーモジュール開発ガイド
- 02C#クライアントの接続と購読
- 03テーブル、Reducer、インデックスのパターン
- 04セットアップ要件とよくあるミス
活用シーン
- 01SpacetimeDBのテーブルとReducerの実装
- 02C#クライアントの接続とデータ購読
- 03ビルドやコールバック処理の問題調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ clockworklabs/ spacetimedb --skill spacetimedb-csharpインストール後、Claude Code で/spacetimedb-csharpを入力して読み込まれたか確認します。
詳細
SpacetimeDB C# SDK
SpacetimeDB 2.0 に接続する C# サーバーサイドモジュールと C# クライアントを構築するためのガイダンスを提供します。
幻覚された API — 使用禁止
これらの API は存在しません。LLM が頻繁に幻覚します。
// WRONG — these table access patterns do not exist
ctx.db.tableName // Wrong casing — use ctx.Db
ctx.Db.tableName // Wrong casing — accessor must match exactly
ctx.Db.TableName.Get(id) // Use Find, not Get
ctx.Db.TableName.FindById(id) // Use index accessor: ctx.Db.TableName.Id.Find(id)
ctx.Db.table.field_name.Find(x) // Wrong! Use PascalCase: ctx.Db.Table.FieldName.Find(x)
Optional<string> field; // Use C# nullable: string? field
// WRONG — missing partial keyword
public struct MyTable { } // Must be "partial struct"
public class Module { } // Must be "static partial class"
// WRONG — non-partial types
[SpacetimeDB.Table(Accessor = "Player")]
public struct Player { } // WRONG — missing partial!
// WRONG — sum type syntax (VERY COMMON MISTAKE)
public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: struct, missing names
public partial record Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: missing variant names
public partial class Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // WRONG: class
// WRONG — Index attribute without full qualification
[Index.BTree(Accessor = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index!
[SpacetimeDB.Index.BTree(Accessor = "idx", Columns = ["Col"])] // Valid with modern C# collection expressions
// WRONG — old 1.0 patterns
[SpacetimeDB.Table(Name = "Player")] // Use Accessor, not Name (2.0)
<PackageReference Include="SpacetimeDB.ServerSdk" /> // Use SpacetimeDB.Runtime
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
// WRONG — lifecycle hooks starting with "On"
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
public static void OnClientConnected(ReducerContext ctx) { } // STDB0010 error!
// WRONG — non-deterministic code in reducers
var random = new Random(); // Use ctx.Rng
var guid = Guid.NewGuid(); // Not allowed
var now = DateTime.Now; // Use ctx.Timestamp
// WRONG — collection parameters
int[] itemIds = { 1, 2, 3 };
_conn.Reducers.ProcessItems(itemIds); // Generated code expects List<T>!
正しいパターン
using SpacetimeDB;
// CORRECT TABLE — must be partial struct, use Accessor
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity OwnerId;
public string Name;
}
// CORRECT MODULE — must be static partial class
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreatePlayer(ReducerContext ctx, string name)
{
ctx.Db.Player.Insert(new Player { Id = 0, OwnerId = ctx.Sender, Name = name });
}
}
// CORRECT DATABASE ACCESS — PascalCase, index-based lookups
var player = ctx.Db.Player.Id.Find(playerId); // Unique/PK: returns nullable
foreach (var p in ctx.Db.Player.OwnerId.Filter(ctx.Sender)) { } // BTree: returns IEnumerable
// CORRECT SUM TYPE — partial record with named tuple elements
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
// CORRECT — collection parameters use List<T>
_conn.Reducers.ProcessItems(new List<int> { 1, 2, 3 });
よくある間違いの表
| 誤り | 正しい方法 | エラー |
|---|---|---|
| 誤った .csproj 名 | StdbModule.csproj | 公開に失敗しても通知されない |
| .NET 9 SDK | .NET 8 SDK のみ | WASI コンパイルに失敗 |
| WASI ワークロードがない | dotnet workload install wasi-experimental | ビルドに失敗 |
| リデューサー内の async/await | 同期処理のみ | サポートされていません |
table.Name.Update(...) | table.Id.Update(...) | 更新は主キー経由のみ(2.0) |
FrameTick() を呼び出していない | Update ループ内で conn.FrameTick() | コールバックが実行されない |
バックグラウンドスレッドから conn.Db にアクセス | コールバック内でデータをコピー | データ競合 |
必須要件
- テーブルと Module は
partialにする必要があります — コード生成に必要です - テーブル属性では
Accessor =を使用します —Name =は SQL 互換性のためだけのものです(2.0) - プロジェクトファイル名は
StdbModule.csprojにする必要があります — CLI の要件です - .NET 8 SDK が必要です — .NET 9 以降はまだサポートされていません
- WASI ワークロードをインストールします —
dotnet workload install wasi-experimental - プロシージャはサポートされています — 必要に応じて
[SpacetimeDB.Procedure]とProcedureContextを使用します - リデューサーは決定的である必要があります — ファイルシステム、ネットワーク、タイマー、または
Randomは使用しないでください - クライアントがテーブルをサブスクライブする必要がある場合は、
Public = trueを追加します - null 許容フィールドには
T?を使用します —Optional<T>ではありません - 自動インクリメントには
0を渡します — 挿入時に ID 生成を実行します - Sum 型は
partial recordにする必要があります — struct や class ではありません - Index 属性は完全修飾します —
System.Indexとの曖昧さを避けるため、[SpacetimeDB.Index.BTree]を使用します - 更新は主キー経由のみです — 主キー以外の変更には削除してから挿入します(2.0)
SpacetimeDB.Runtimeパッケージを使用します —ServerSdkではありません(2.0)- コレクションパラメーターには
List<T>を使用します — 配列ではありません IdentityはSpacetimeDB名前空間にあります —SpacetimeDB.Typesではありません
サーバーサイドモジュールの開発
テーブルの定義
using SpacetimeDB;
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity OwnerId;
public string Name;
public Timestamp CreatedAt;
}
// Multi-column index (use fully-qualified attribute!)
[SpacetimeDB.Table(Accessor = "Score", Public = true)]
[SpacetimeDB.Index.BTree(Accessor = "by_player_game", Columns = new[] { "PlayerId", "GameId" })]
public partial struct Score
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public Identity PlayerId;
public string GameId;
public int Points;
}
フィールド属性
[SpacetimeDB.PrimaryKey] // Exactly one per table (required)
[SpacetimeDB.AutoInc] // Auto-increment (integer fields only)
[SpacetimeDB.Unique] // Unique constraint
[SpacetimeDB.Index.BTree] // Single-column B-tree index
[SpacetimeDB.Default(value)] // Default value for new columns
SpacetimeDB の列型
Identity // User identity (SpacetimeDB namespace, not SpacetimeDB.Types)
Timestamp // Timestamp (use ctx.Timestamp server-side, never DateTime.Now)
ScheduleAt // For scheduled tables
T? // Nullable (e.g., string?)
List<T> // Collections (use List, not arrays)
標準的な C# プリミティブ型(bool、byte~ulong、float、double、string)はすべてサポートされています。
自動インクリメントを使用した挿入
var player = ctx.Db.Player.Insert(new Player
{
Id = 0, // Pass 0 to trigger auto-increment
OwnerId = ctx.Sender,
Name = name,
CreatedAt = ctx.Timestamp
});
ulong newId = player.Id; // Insert returns the row with generated ID
モジュールとリデューサー
using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreateTask(ReducerContext ctx, string title)
{
if (string.IsNullOrEmpty(title))
throw new Exception("Title cannot be empty");
ctx.Db.Task.Insert(new Task
{
Id = 0,
OwnerId = ctx.Sender,
Title = title,
Completed = false
});
}
[SpacetimeDB.Reducer]
public static void CompleteTask(ReducerContext ctx, ulong taskId)
{
if (ctx.Db.Task.Id.Find(taskId) is not Task task)
throw new Exception("Task not found");
if (task.OwnerId != ctx.Sender)
throw new Exception("Not authorized");
ctx.Db.Task.Id.Update(task with { Completed = true });
}
[SpacetimeDB.Reducer]
public static void DeleteTask(ReducerContext ctx, ulong taskId)
{
ctx.Db.Task.Id.Delete(taskId);
}
}
ライフサイクルリデューサー
public static partial class Module
{
[SpacetimeDB.Reducer(ReducerKind.Init)]
public static void Init(ReducerContext ctx)
{
Log.Info("Module initialized");
}
// CRITICAL: no "On" prefix!
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
public static void ClientConnected(ReducerContext ctx)
{
Log.Info($"Client connected: {ctx.Sender}");
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
{
ctx.Db.User.Identity.Update(user with { Online = true });
}
else
{
ctx.Db.User.Insert(new User { Identity = ctx.Sender, Online = true });
}
}
[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]
public static void ClientDisconnected(ReducerContext ctx)
{
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
{
ctx.Db.User.Identity.Update(user with { Online = false });
}
}
}
イベントテーブル(2.0)
リデューサーのコールバックは 2.0 で削除されました。代わりにイベントテーブルと OnInsert を使用します。
[SpacetimeDB.Table(Accessor = "DamageEvent", Public = true, Event = true)]
public partial struct DamageEvent
{
public Identity Target;
public uint Amount;
}
[SpacetimeDB.Reducer]
public static void DealDamage(ReducerContext ctx, Identity target, uint amount)
{
ctx.Db.DamageEvent.Insert(new DamageEvent { Target = target, Amount = amount });
}
クライアントはサブスクライブして OnInsert を使用します:
conn.Db.DamageEvent.OnInsert += (ctx, evt) => {
PlayDamageAnimation(evt.Target, evt.Amount);
};
イベントテーブルは明示的にサブスクライブする必要があります。SubscribeToAllTables() の対象からは除外されています。
データベースへのアクセス
// Find by primary key — returns nullable, use pattern matching
if (ctx.Db.Task.Id.Find(taskId) is Task task) { /* use task */ }
// Update by primary key (2.0: only primary key has .Update)
ctx.Db.Task.Id.Update(task with { Title = newTitle });
// Delete by primary key
ctx.Db.Task.Id.Delete(taskId);
// Find by unique index — returns nullable
if (ctx.Db.Player.Username.Find("alice") is Player player) { }
// Filter by B-tree index — returns iterator
foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { }
// Full table scan — avoid for large tables
foreach (var task in ctx.Db.Task.Iter()) { }
var count = ctx.Db.Task.Count;
カスタム型と Sum 型
[SpacetimeDB.Type]
public partial struct Position { public int X; public int Y; }
// Sum types MUST be partial record with named tuple
[SpacetimeDB.Type]
public partial struct Circle { public int Radius; }
[SpacetimeDB.Type]
public partial struct Rectangle { public int Width; public int Height; }
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
// Creating sum type values
var circle = new Shape.Circle(new Circle { Radius = 10 });
スケジュールテーブル
[SpacetimeDB.Table(Accessor = "Reminder", Scheduled = nameof(Module.SendReminder))]
public partial struct Reminder
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public string Message;
public ScheduleAt ScheduledAt;
}
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void SendReminder(ReducerContext ctx, Reminder reminder)
{
Log.Info($"Reminder: {reminder.Message}");
}
[SpacetimeDB.Reducer]
public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs)
{
ctx.Db.Reminder.Insert(new Reminder
{
Id = 0,
Message = message,
ScheduledAt = new ScheduleAt.Time(ctx.Timestamp + TimeSpan.FromSeconds(delaySecs))
});
}
}
ロギング
Log.Debug("Debug message");
Log.Info("Information");
Log.Warn("Warning");
Log.Error("Error occurred");
Log.Exception("Critical failure"); // Logs at error level
ReducerContext API
ctx.Sender // Identity of the caller
ctx.Timestamp // Current timestamp
ctx.Db // Database access
ctx.Identity // Module's own identity
ctx.ConnectionId // Connection ID (nullable)
ctx.SenderAuth // Authorization context (JWT claims, internal call detection)
ctx.Rng // Deterministic random number generator
エラー処理
リデューサー内で例外をスローすると、トランザクション全体がロールバックされます:
[SpacetimeDB.Reducer]
public static void TransferCredits(ReducerContext ctx, Identity toUser, uint amount)
{
if (ctx.Db.User.Identity.Find(ctx.Sender) is not User sender)
throw new Exception("Sender not found");
if (sender.Credits < amount)
throw new Exception("Insufficient credits");
ctx.Db.User.Identity.Update(sender with { Credits = sender.Credits - amount });
if (ctx.Db.User.Identity.Find(toUser) is User receiver)
ctx.Db.User.Identity.Update(receiver with { Credits = receiver.Credits + amount });
}
プロジェクトのセットアップ
必須の .csproj(ファイル名は StdbModule.csproj にする必要があります)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SpacetimeDB.Runtime" Version="1.*" />
</ItemGroup>
</Project>
前提条件
# Install .NET 8 SDK (required, not .NET 9)
# Install WASI workload
dotnet workload install wasi-experimental
クライアント SDK
インストール
dotnet add package SpacetimeDB.ClientSDK
モジュールバインディングの生成
spacetime generate --lang csharp --out-dir module_bindings --module-path PATH_TO_MODULE
これにより、SpacetimeDBClient.g.cs、Tables/*.g.cs、Reducers/*.g.cs、Types/*.g.cs が作成されます。
接続のセットアップ
using SpacetimeDB;
using SpacetimeDB.Types;
var conn = DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithDatabaseName("my-database")
.WithToken(savedToken)
.OnConnect(OnConnected)
.OnConnectError(err => Console.Error.WriteLine($"Failed: {err}"))
.OnDisconnect((conn, err) => { if (err != null) Console.Error.WriteLine(err); })
.Build();
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// Save authToken to persistent storage for reconnection
Console.WriteLine($"Connected: {identity}");
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
}
重要:FrameTick
SDK はメッセージを自動的に処理しません。 定期的に FrameTick() を呼び出す必要があります。
// Console application
while (running) { conn.FrameTick(); Thread.Sleep(16); }
// Unity: call conn?.FrameTick() in Update()
警告:バックグラウンドスレッドから FrameTick() を呼び出さないでください。conn.Db を変更するため、データ競合が発生する可能性があります。
テーブルのサブスクライブ
// SQL queries
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.OnError((ctx, err) => Console.Error.WriteLine($"Subscription failed: {err}"))
.Subscribe(new[] {
"SELECT * FROM player",
"SELECT * FROM message WHERE sender = :sender"
});
// Subscribe to all tables (development only)
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
// Subscription handle for later unsubscribe
SubscriptionHandle handle = conn.SubscriptionBuilder()
.OnApplied(ctx => Console.WriteLine("Applied"))
.Subscribe(new[] { "SELECT * FROM player" });
handle.UnsubscribeThen(ctx => Console.WriteLine("Unsubscribed"));
警告:同じ接続で SubscribeToAllTables() と Subscribe() を併用することはできません。
クライアントキャッシュへのアクセス
// Iterate all rows
foreach (var player in ctx.Db.Player.Iter()) { Console.WriteLine(player.Name); }
// Count rows
int playerCount = ctx.Db.Player.Count;
// Find by unique/primary key — returns nullable
Player? player = ctx.Db.Player.Identity.Find(someIdentity);
if (player != null) { Console.WriteLine(player.Name); }
// Filter by BTree index — returns IEnumerable
foreach (var p in ctx.Db.Player.Level.Filter(1)) { }
行イベントコールバック
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
Console.WriteLine($"Player joined: {player.Name}");
};
ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => {
Console.WriteLine($"Player left: {player.Name}");
};
ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => {
Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}");
};
// Checking event source
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
switch (ctx.Event)
{
case Event<Reducer>.SubscribeApplied:
break; // Initial subscription data
case Event<Reducer>.Reducer(var reducerEvent):
Console.WriteLine($"Reducer: {reducerEvent.Reducer}");
break;
}
};
リデューサーの呼び出し
ctx.Reducers.SendMessage("Hello, world!");
ctx.Reducers.CreatePlayer("NewPlayer");
// Reducer completion callbacks
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
if (ctx.Event.Status is Status.Committed)
Console.WriteLine($"Message sent: {text}");
else if (ctx.Event.Status is Status.Failed(var reason))
Console.Error.WriteLine($"Send failed: {reason}");
};
// Unhandled reducer errors
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => {
Console.Error.WriteLine($"Reducer error: {ex.Message}");
};
ID と認証
// In OnConnect callback — save token for reconnection
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// Save authToken to persistent storage (file, config, PlayerPrefs, etc.)
SaveToken(authToken);
}
// Reconnect with saved token
string savedToken = LoadToken();
DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithDatabaseName("my-database")
.WithToken(savedToken)
.OnConnect(OnConnected)
.Build();
// Pass null or omit WithToken for anonymous connection
コマンド
spacetime start
spacetime publish <module-name> --module-path <backend-dir>
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
spacetime generate --lang csharp --out-dir <client>/SpacetimeDB --module-path <backend-dir>
spacetime logs <module-name>