mcpField
S
spacetimedb-csharp

spacetimedb-csharp

github.com/clockworklabs开发25.4k40 次安装今天更新
代码仓库

关于 spacetimedb-csharp

面向开发者的参考技能,用于构建 SpacetimeDB 2.0 服务端模块和 C# 客户端,重点说明正确的 API 用法、环境要求与常见错误。

主要功能

  1. 01服务端模块开发指导
  2. 02C# 客户端连接与订阅
  3. 03表、Reducer 和索引用法
  4. 04环境要求与常见错误

适用场景

  1. 01实现 SpacetimeDB 表和 Reducer
  2. 02连接 C# 客户端并订阅数据
  3. 03排查构建和回调处理问题

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/spacetimedb-csharp确认已加载。

详细介绍

SpacetimeDB C# SDK

本技能提供构建 C# 服务端模块以及连接到 SpacetimeDB 2.0 的 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 SDKWASI 编译失败
未安装 WASI 工作负载dotnet workload install wasi-experimental构建失败
在 reducer 中使用 async/await仅支持同步操作不受支持
table.Name.Update(...)table.Id.Update(...)仅可通过主键更新(2.0)
未调用 FrameTick()在 Update 循环中调用 conn.FrameTick()不会触发回调
从后台线程访问 conn.Db在回调中复制数据数据竞争

硬性要求

  1. 表和 Module 必须是 partial — 代码生成所必需
  2. 在表属性中使用 Accessor = — Name = 仅用于 SQL 兼容性(2.0)
  3. 项目文件必须命名为 StdbModule.csproj — CLI 要求
  4. 需要 .NET 8 SDK — 暂不支持 .NET 9 及更高版本
  5. 安装 WASI 工作负载 — dotnet workload install wasi-experimental
  6. 支持 Procedures — 需要时使用 [SpacetimeDB.Procedure] 和 ProcedureContext
  7. Reducers 必须具有确定性 — 不得使用文件系统、网络、计时器或 Random
  8. 添加 Public = true — 客户端需要订阅某个表时
  9. 可空字段使用 T? — 不要使用 Optional<T>
  10. 自动递增字段传入 0 — 以在插入时触发 ID 生成
  11. Sum type 必须是 partial record — 不要使用 struct 或 class
  12. 完全限定 Index 属性 — 使用 [SpacetimeDB.Index.BTree],避免与 System.Index 冲突
  13. 仅通过主键更新 — 对非主键更改使用先删除再插入(2.0)
  14. 使用 SpacetimeDB.Runtime 包 — 不要使用 ServerSdk(2.0)
  15. 集合参数使用 List<T> — 不要使用数组
  16. 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
Module 和 Reducers
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);
    }
}
生命周期 Reducers
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)

Reducer 回调已在 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 Type
[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
错误处理

在 reducer 中抛出异常会回滚整个事务:

[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;
    }
};
调用 Reducers
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}");
};
身份和身份验证
// 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>

评论

支持 Markdown