spacetimedb-csharp

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SpacetimeDB C# SDK

SpacetimeDB C# SDK

This skill provides guidance for building C# server-side modules and C# clients that connect to SpacetimeDB 2.0.

本指南提供了构建连接到SpacetimeDB 2.0的C#服务器端模块与C#客户端的相关指导。

HALLUCINATED APIs — DO NOT USE

虚构API — 请勿使用

These APIs DO NOT EXIST. LLMs frequently hallucinate them.
csharp
// 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>!
这些API并不存在。大语言模型经常会虚构出此类API。
csharp
// 错误示例 — 这些表访问方式不存在
ctx.db.tableName                    // 大小写错误 — 请使用 ctx.Db
ctx.Db.tableName                    // 大小写错误 — 访问器必须完全匹配
ctx.Db.TableName.Get(id)            // 使用 Find,而非 Get
ctx.Db.TableName.FindById(id)       // 使用索引访问器:ctx.Db.TableName.Id.Find(id)
ctx.Db.table.field_name.Find(x)     // 错误!请使用 PascalCase:ctx.Db.Table.FieldName.Find(x)
Optional<string> field;             // 使用C#可空类型:string? field

// 错误示例 — 缺少 partial 关键字
public struct MyTable { }           // 必须为 "partial struct"
public class Module { }             // 必须为 "static partial class"

// 错误示例 — 非 partial 类型
[SpacetimeDB.Table(Accessor = "Player")]
public struct Player { }            // 错误 — 缺少 partial!

// 错误示例 — 求和类型语法(非常常见的错误)
public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { }     // 错误:结构体,缺少名称
public partial record Shape : TaggedEnum<(Circle, Rectangle)> { }     // 错误:缺少变体名称
public partial class Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }  // 错误:类

// 错误示例 — 未完全限定的 Index 属性
[Index.BTree(Accessor = "idx", Columns = new[] { "Col" })]    // 与 System.Index 存在歧义!
[SpacetimeDB.Index.BTree(Accessor = "idx", Columns = ["Col"])]  // 使用现代C#集合表达式才有效

// 错误示例 — 旧版1.0模式
[SpacetimeDB.Table(Name = "Player")]        // 使用 Accessor,而非 Name(2.0版本)
<PackageReference Include="SpacetimeDB.ServerSdk" />  // 使用 SpacetimeDB.Runtime
.WithModuleName("my-db")                    // 使用 .WithDatabaseName()(2.0版本)
ScheduleAt.Time(futureTime)                 // 使用 new ScheduleAt.Time(futureTime)

// 错误示例 — 以"On"开头的生命周期钩子
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
public static void OnClientConnected(ReducerContext ctx) { }  // 触发STDB0010错误!

// 错误示例 — 归约器中的非确定性代码
var random = new Random();          // 使用 ctx.Rng
var guid = Guid.NewGuid();          // 不允许使用
var now = DateTime.Now;             // 使用 ctx.Timestamp

// 错误示例 — 集合参数
int[] itemIds = { 1, 2, 3 };
_conn.Reducers.ProcessItems(itemIds);  // 生成的代码期望传入 List<T>!

CORRECT PATTERNS

正确用法示例

csharp
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 });

csharp
using SpacetimeDB;

// 正确的表定义 — 必须为 partial struct,使用 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;
}

// 正确的模块定义 — 必须为 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 });
    }
}

// 正确的数据库访问 — 使用 PascalCase,基于索引查找
var player = ctx.Db.Player.Id.Find(playerId);           // 唯一/主键:返回可空类型
foreach (var p in ctx.Db.Player.OwnerId.Filter(ctx.Sender)) { }  // B树索引:返回 IEnumerable

// 正确的求和类型 — 带命名元组元素的 partial record
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }

// 正确示例 — 集合参数使用 List<T>
_conn.Reducers.ProcessItems(new List<int> { 1, 2, 3 });

Common Mistakes Table

常见错误对照表

WrongRightError
Wrong .csproj name
StdbModule.csproj
Publish fails silently
.NET 9 SDK.NET 8 SDK onlyWASI compilation fails
Missing WASI workload
dotnet workload install wasi-experimental
Build fails
async/await in reducersSynchronous onlyNot supported
table.Name.Update(...)
table.Id.Update(...)
Update only via primary key (2.0)
Not calling
FrameTick()
conn.FrameTick()
in Update loop
No callbacks fire
Accessing
conn.Db
from background thread
Copy data in callbackData races

错误用法正确用法错误说明
错误的.csproj文件名
StdbModule.csproj
发布过程会静默失败
.NET 9 SDK仅使用.NET 8 SDKWASI编译失败
缺少WASI工作负载
dotnet workload install wasi-experimental
构建失败
归约器中使用async/await仅支持同步代码不支持异步
table.Name.Update(...)
table.Id.Update(...)
2.0版本仅支持通过主键更新
未调用
FrameTick()
在更新循环中调用
conn.FrameTick()
不会触发任何回调
从后台线程访问
conn.Db
在回调中复制数据会导致数据竞争

Hard Requirements

硬性要求

  1. Tables and Module MUST be
    partial
    — required for code generation
  2. Use
    Accessor =
    in table attributes
    Name =
    is only for SQL compatibility (2.0)
  3. Project file MUST be named
    StdbModule.csproj
    — CLI requirement
  4. Requires .NET 8 SDK — .NET 9 and newer not yet supported
  5. Install WASI workload
    dotnet workload install wasi-experimental
  6. Procedures are supported — use
    [SpacetimeDB.Procedure]
    with
    ProcedureContext
    when needed
  7. Reducers must be deterministic — no filesystem, network, timers, or
    Random
  8. Add
    Public = true
    — if clients need to subscribe to a table
  9. Use
    T?
    for nullable fields
    — not
    Optional<T>
  10. Pass
    0
    for auto-increment
    — to trigger ID generation on insert
  11. Sum types must be
    partial record
    — not struct or class
  12. Fully qualify Index attribute
    [SpacetimeDB.Index.BTree]
    to avoid System.Index ambiguity
  13. Update only via primary key — use delete+insert for non-PK changes (2.0)
  14. Use
    SpacetimeDB.Runtime
    package
    — not
    ServerSdk
    (2.0)
  15. Use
    List<T>
    for collection parameters
    — not arrays
  16. Identity
    is in
    SpacetimeDB
    namespace
    — not
    SpacetimeDB.Types

  1. 表和模块必须标记为
    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. 支持过程调用 — 必要时使用
    [SpacetimeDB.Procedure]
    ProcedureContext
  7. 归约器必须是确定性的 — 禁止访问文件系统、网络、计时器或使用
    Random
  8. 添加
    Public = true
    — 如果客户端需要订阅该表
  9. 使用
    T?
    表示可空字段
    — 不要使用
    Optional<T>
  10. 自增字段传入
    0
    — 插入时触发ID生成
  11. 求和类型必须是
    partial record
    — 不能是结构体或类
  12. 完全限定Index属性 — 使用
    [SpacetimeDB.Index.BTree]
    避免与System.Index冲突
  13. 仅通过主键更新 — 非主键变更请使用删除+插入(2.0版本)
  14. 使用
    SpacetimeDB.Runtime
    — 不要使用
    ServerSdk
    (2.0版本)
  15. 集合参数使用
    List<T>
    — 不要使用数组
  16. Identity
    位于
    SpacetimeDB
    命名空间
    — 不是
    SpacetimeDB.Types

Server-Side Module Development

服务器端模块开发

Table Definition

表定义

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

// 多列索引(必须使用完全限定的属性!)
[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;
}

Field Attributes

字段属性

csharp
[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
csharp
[SpacetimeDB.PrimaryKey]     // 每个表必须且只能有一个主键
[SpacetimeDB.AutoInc]        // 自增(仅适用于整数字段)
[SpacetimeDB.Unique]         // 唯一约束
[SpacetimeDB.Index.BTree]    // 单列B树索引
[SpacetimeDB.Default(value)] // 新字段的默认值

SpacetimeDB Column Types

SpacetimeDB列类型

csharp
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)
Standard C# primitives (
bool
,
byte
..
ulong
,
float
,
double
,
string
) are all supported.
csharp
Identity                     // 用户身份(位于SpacetimeDB命名空间,而非SpacetimeDB.Types)
Timestamp                    // 时间戳(服务器端使用ctx.Timestamp,禁止使用DateTime.Now)
ScheduleAt                   // 用于定时表
T?                           // 可空类型(例如string?)
List<T>                      // 集合(使用List,而非数组)
标准C#基元类型(
bool
byte
..
ulong
float
double
string
)均受支持。

Insert with Auto-Increment

自增插入

csharp
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
csharp
var player = ctx.Db.Player.Insert(new Player
{
    Id = 0,  // 传入0触发自增
    OwnerId = ctx.Sender,
    Name = name,
    CreatedAt = ctx.Timestamp
});
ulong newId = player.Id;  // Insert方法返回包含生成ID的行

Module and Reducers

模块与归约器

csharp
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);
    }
}
csharp
using SpacetimeDB;

public static partial class Module
{
    [SpacetimeDB.Reducer]
    public static void CreateTask(ReducerContext ctx, string title)
    {
        if (string.IsNullOrEmpty(title))
            throw new Exception("标题不能为空");

        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("任务不存在");
        if (task.OwnerId != ctx.Sender)
            throw new Exception("无权限操作");

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

Lifecycle Reducers

生命周期归约器

csharp
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 });
        }
    }
}
csharp
public static partial class Module
{
    [SpacetimeDB.Reducer(ReducerKind.Init)]
    public static void Init(ReducerContext ctx)
    {
        Log.Info("模块初始化完成");
    }

    // 重要:不要添加"On"前缀!
    [SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
    public static void ClientConnected(ReducerContext ctx)
    {
        Log.Info($"客户端已连接: {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 });
        }
    }
}

Event Tables (2.0)

事件表(2.0版本)

Reducer callbacks are removed in 2.0. Use event tables +
OnInsert
instead.
csharp
[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 });
}
Client subscribes and uses
OnInsert
:
csharp
conn.Db.DamageEvent.OnInsert += (ctx, evt) => {
    PlayDamageAnimation(evt.Target, evt.Amount);
};
Event tables must be subscribed explicitly — they are excluded from
SubscribeToAllTables()
.
2.0版本已移除归约器回调,请使用事件表 +
OnInsert
替代。
csharp
[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
csharp
conn.Db.DamageEvent.OnInsert += (ctx, evt) => {
    PlayDamageAnimation(evt.Target, evt.Amount);
};
事件表必须显式订阅 — 它们不会被包含在
SubscribeToAllTables()
中。

Database Access

数据库访问

csharp
// 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;
csharp
// 通过主键查找 — 返回可空类型,使用模式匹配
if (ctx.Db.Task.Id.Find(taskId) is Task task) { /* 使用task */ }

// 通过主键更新(2.0版本:仅主键支持.Update)
ctx.Db.Task.Id.Update(task with { Title = newTitle });

// 通过主键删除
ctx.Db.Task.Id.Delete(taskId);

// 通过唯一索引查找 — 返回可空类型
if (ctx.Db.Player.Username.Find("alice") is Player player) { }

// 通过B树索引过滤 — 返回迭代器
foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { }

// 全表扫描 — 大表请避免使用
foreach (var task in ctx.Db.Task.Iter()) { }
var count = ctx.Db.Task.Count;

Custom Types and Sum Types

自定义类型与求和类型

csharp
[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 });
csharp
[SpacetimeDB.Type]
public partial struct Position { public int X; public int Y; }

// 求和类型必须是带命名元组的partial record
[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)> { }

// 创建求和类型值
var circle = new Shape.Circle(new Circle { Radius = 10 });

Scheduled Tables

定时表

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

Logging

日志

csharp
Log.Debug("Debug message");
Log.Info("Information");
Log.Warn("Warning");
Log.Error("Error occurred");
Log.Exception("Critical failure");  // Logs at error level
csharp
Log.Debug("调试消息");
Log.Info("信息消息");
Log.Warn("警告消息");
Log.Error("发生错误");
Log.Exception("严重故障");  // 以错误级别记录

ReducerContext API

ReducerContext API

csharp
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
csharp
ctx.Sender          // 调用者的身份标识
ctx.Timestamp       // 当前时间戳
ctx.Db              // 数据库访问入口
ctx.Identity        // 模块自身的身份标识
ctx.ConnectionId    // 连接ID(可空)
ctx.SenderAuth      // 授权上下文(JWT声明、内部调用检测)
ctx.Rng             // 确定性随机数生成器

Error Handling

错误处理

Throwing an exception in a reducer rolls back the entire transaction:
csharp
[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 });
}

在归约器中抛出异常会回滚整个事务:
csharp
[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("发送方不存在");

    if (sender.Credits < amount)
        throw new Exception("余额不足");

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

Project Setup

项目设置

Required .csproj (MUST be named
StdbModule.csproj
)

必需的.csproj文件(必须命名为
StdbModule.csproj

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

Prerequisites

前置条件

bash
undefined
bash
undefined

Install .NET 8 SDK (required, not .NET 9)

安装.NET 8 SDK(必需,不要使用.NET 9)

Install WASI workload

安装WASI工作负载

dotnet workload install wasi-experimental

---
dotnet workload install wasi-experimental

---

Client SDK

客户端SDK

Installation

安装

bash
dotnet add package SpacetimeDB.ClientSDK
bash
dotnet add package SpacetimeDB.ClientSDK

Generate Module Bindings

生成模块绑定

bash
spacetime generate --lang csharp --out-dir module_bindings --module-path PATH_TO_MODULE
This creates
SpacetimeDBClient.g.cs
,
Tables/*.g.cs
,
Reducers/*.g.cs
, and
Types/*.g.cs
.
bash
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
文件。

Connection Setup

连接设置

csharp
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();
}
csharp
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($"连接失败: {err}"))
    .OnDisconnect((conn, err) => { if (err != null) Console.Error.WriteLine(err); })
    .Build();

void OnConnected(DbConnection conn, Identity identity, string authToken)
{
    // 将authToken保存到持久化存储以便重新连接
    Console.WriteLine($"已连接: {identity}");
    conn.SubscriptionBuilder()
        .OnApplied(OnSubscriptionApplied)
        .SubscribeToAllTables();
}

Critical: FrameTick

关键:FrameTick

The SDK does NOT automatically process messages. You must call
FrameTick()
regularly.
csharp
// Console application
while (running) { conn.FrameTick(); Thread.Sleep(16); }

// Unity: call conn?.FrameTick() in Update()
Warning: Do NOT call
FrameTick()
from a background thread. It modifies
conn.Db
and can cause data races.
SDK不会自动处理消息。你必须定期调用
FrameTick()
csharp
// 控制台应用
while (running) { conn.FrameTick(); Thread.Sleep(16); }

// Unity:在Update()中调用conn?.FrameTick()
警告:不要从后台线程调用
FrameTick()
。它会修改
conn.Db
,可能导致数据竞争。

Subscribing to Tables

订阅表

csharp
// 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"));
Warning:
SubscribeToAllTables()
cannot be mixed with
Subscribe()
on the same connection.
csharp
// SQL查询
conn.SubscriptionBuilder()
    .OnApplied(OnSubscriptionApplied)
    .OnError((ctx, err) => Console.Error.WriteLine($"订阅失败: {err}"))
    .Subscribe(new[] {
        "SELECT * FROM player",
        "SELECT * FROM message WHERE sender = :sender"
    });

// 订阅所有表(仅开发环境使用)
conn.SubscriptionBuilder()
    .OnApplied(OnSubscriptionApplied)
    .SubscribeToAllTables();

// 订阅句柄,用于后续取消订阅
SubscriptionHandle handle = conn.SubscriptionBuilder()
    .OnApplied(ctx => Console.WriteLine("已应用"))
    .Subscribe(new[] { "SELECT * FROM player" });

handle.UnsubscribeThen(ctx => Console.WriteLine("已取消订阅"));
警告:同一连接上不能混合使用
SubscribeToAllTables()
Subscribe()

Accessing the Client Cache

访问客户端缓存

csharp
// 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)) { }
csharp
// 遍历所有行
foreach (var player in ctx.Db.Player.Iter()) { Console.WriteLine(player.Name); }

// 统计行数
int playerCount = ctx.Db.Player.Count;

// 通过唯一/主键查找 — 返回可空类型
Player? player = ctx.Db.Player.Identity.Find(someIdentity);
if (player != null) { Console.WriteLine(player.Name); }

// 通过B树索引过滤 — 返回IEnumerable
foreach (var p in ctx.Db.Player.Level.Filter(1)) { }

Row Event Callbacks

行事件回调

csharp
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;
    }
};
csharp
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
    Console.WriteLine($"玩家加入: {player.Name}");
};

ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => {
    Console.WriteLine($"玩家离开: {player.Name}");
};

ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => {
    Console.WriteLine($"玩家 {oldRow.Name} 更名为 {newRow.Name}");
};

// 检查事件来源
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
    switch (ctx.Event)
    {
        case Event<Reducer>.SubscribeApplied:
            break;  // 初始订阅数据
        case Event<Reducer>.Reducer(var reducerEvent):
            Console.WriteLine($"归约器: {reducerEvent.Reducer}");
            break;
    }
};

Calling Reducers

调用归约器

csharp
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}");
};
csharp
ctx.Reducers.SendMessage("Hello, world!");
ctx.Reducers.CreatePlayer("NewPlayer");

// 归约器完成回调
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
    if (ctx.Event.Status is Status.Committed)
        Console.WriteLine($"消息已发送: {text}");
    else if (ctx.Event.Status is Status.Failed(var reason))
        Console.Error.WriteLine($"发送失败: {reason}");
};

// 未处理的归约器错误
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => {
    Console.Error.WriteLine($"归约器错误: {ex.Message}");
};

Identity and Authentication

身份与认证

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

csharp
// 在OnConnect回调中 — 保存token以便重新连接
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
    // 将authToken保存到持久化存储(文件、配置、PlayerPrefs等)
    SaveToken(authToken);
}

// 使用保存的token重新连接
string savedToken = LoadToken();
DbConnection.Builder()
    .WithUri("http://localhost:3000")
    .WithDatabaseName("my-database")
    .WithToken(savedToken)
    .OnConnect(OnConnected)
    .Build();

// 传入null或省略WithToken以匿名连接

Commands

命令

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