Skip to main content

csharp-server

SpacetimeDB C# server module SDK reference. Use when writing tables, reducers, or module logic in C#.

Source facts

Repository
clockworklabs/SpacetimeDB
Last source activity
November 5, 2026 at 16:16
Detected SKILL.md language
English
Stars
25,531
Forks
1,117

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
csharp-server
description
SpacetimeDB C# server module SDK reference. Use when writing tables, reducers, or module logic in C#.
license
Apache-2.0
metadata
{"author":"clockworklabs","version":"2.0","role":"server","language":"csharp","cursor_globs":"**/*.cs","cursor_always_apply":true}
# SpacetimeDB C# SDK Reference ## Build Configuration (.NET 10) New .NET 10 modules build as managed libraries by default. Use the CLI for WASM builds, or select the runtime identifier explicitly when invoking dotnet: ```sh spacetime init my-project --lang csharp --dotnet-version 10 # From the generated module directory: dotnet build # managed DLL dotnet publish -c Release -r wasi-wasm # NativeAOT-LLVM WASM spacetime build # CLI selects WASM automatically ``` - Do not set an unconditional `RuntimeIdentifier=wasi-wasm` in .NET 10 projects. Keep WASM-only settings such as `PublishTrimmed`, `SelfContained`, and `MSBuildEnableWorkloadResolver=false` conditional on that RID. - The module must reference both `Microsoft.DotNet.ILCompiler.LLVM` and `runtime.$(NETCoreSdkPortableRuntimeIdentifier).Microsoft.DotNet.ILCompiler.LLVM` (version `10.0.0-*`) conditionally on `RuntimeIdentifier=wasi-wasm`. The runtime package no longer supplies these dependencies transitively. Keep the dotnet-experimental NuGet feed configured for WASM restores. - `spacetime build` and `spacetime publish` select WASM automatically. .NET 10 does not require `--native-aot` or an AOT environment variable. - Allow restore when switching between managed and WASM builds; do not reuse `--no-restore` across modes. For multi-targeted projects, select `-f net10.0` when publishing and restrict the LLVM 10 package references to .NET 10. - .NET 8 behavior is unchanged: WASM/JIT by default, with `--native-aot` selecting NativeAOT where supported. The current flag is `SPACETIMEDB_WASM_AOT`; the CLI also sets/clears `EXPERIMENTAL_WASM_AOT` for older projects. Do not manually define the C# compiler symbol to choose a .NET 10 build mode. - Module dependencies use ordinary `ProjectReference` or packaged managed assemblies. .NET 10 composition uses generated assembly descriptors, not the dependency's standalone WASM exports; no special root/library build flag is needed. ## Module Structure Reducers are static methods in a `static partial class`; tables are `public partial struct`s. This reference keeps everything in one `public static partial class Module`, which needs only `using SpacetimeDB;`: Methods exported through SpacetimeDB attributes, including reducers, procedures, views, HTTP handlers, and routers, must be `public static`; generated bindings invoke them from another class. ```csharp using SpacetimeDB; public static partial class Module { [SpacetimeDB.Table(Accessor = "ScoreRecord", Public = true)] public partial struct ScoreRecord { [PrimaryKey] [AutoInc] public ulong Id; public Identity Owner; public uint Value; } [SpacetimeDB.Reducer] public static void AddRecord(ReducerContext ctx, uint value) { ctx.Db.ScoreRecord.Insert(new ScoreRecord { Id = 0, Owner = ctx.Sender, Value = value }); } } ``` ## Tables `[SpacetimeDB.Table(...)]` on a `public partial struct`. `Accessor` should be PascalCase: ```csharp [SpacetimeDB.Table(Accessor = "Entity", Public = true)] public partial struct Entity { [PrimaryKey] [AutoInc] public ulong Id; [SpacetimeDB.Index.BTree] public Identity Owner; public string Name; [SpacetimeDB.Index.BTree] public bool Active; } ``` Options: `Accessor = "PascalCase"` (recommended), `Public = true`, `Scheduled = nameof(ReducerFn)`, `ScheduledAt = nameof(field)`, `Event = true` `ctx.Db` accessors use the `Accessor` name: `ctx.Db.Entity`, `ctx.Db.ScoreRecord`. ## Column Types | C# type | Notes | |---------|-------| | `byte` / `ushort` / `uint` / `ulong` | unsigned integers | | `U128` / `U256` | large unsigned integers (SpacetimeDB types) | | `sbyte` / `short` / `int` / `long` | signed integers | | `I128` / `I256` | large signed integers (SpacetimeDB types) | | `float` / `double` | floats | | `bool` | boolean | | `string` | text | | `List<T>` | list/array | | `Identity` | user identity | | `ConnectionId` | connection handle | | `Timestamp` | server timestamp (microseconds since epoch) | | `TimeDuration` | duration in microseconds | | `Uuid` | UUID | Optional columns: nullable types (`string? Nickname`, `uint? HighScore`) ## Column Attributes The complete set of column attributes: ```csharp [PrimaryKey] // primary key [AutoInc] // auto-increment (use 0 as placeholder on insert) [Unique] // unique constraint; indexes the column, enables .Find() [SpacetimeDB.Index.BTree] // btree index (enables .Filter() on this column) [Default(true)] // migration-safe default for a newly appended field ``` Defaults support compatible addition of a newly appended field. Do not apply `[Default(...)]` to primary-key, unique, or auto-increment fields. ## Indexes Write the index attribute fully qualified: `[SpacetimeDB.Index.BTree]`. Prefer inline for single-column; multi-column uses struct-level: ```csharp // Inline (preferred for single-column): [SpacetimeDB.Index.BTree] public ulong AuthorId; // Access: ctx.Db.Post.AuthorId.Filter(authorId) // Multi-column (struct-level): [SpacetimeDB.Table(Accessor = "Membership")] [SpacetimeDB.Index.BTree(Accessor = "ByGroupUser", Columns = new[] { nameof(GroupId), nameof(UserId) })] public partial struct Membership { public ulong GroupId; public Identity UserId; ... } ``` Prefer a multi-column index over filtering by one column and looping. ## Reducers ```csharp [SpacetimeDB.Reducer] public static void CreateEntity(ReducerContext ctx, string name, int age) { ctx.Db.Entity.Insert(new Entity { Owner = ctx.Sender, Name = name, Age = age, Active = true }); } // No arguments: [SpacetimeDB.Reducer] public static void DoReset(ReducerContext ctx) { ... } ``` ## DB Operations ```csharp var row = ctx.Db.Entity.Insert(new Entity { Name = "Sample" }); // Insert; returns the row with AutoInc fields assigned ctx.Db.Entity.Id.Find(entityId); // Find by PK → Entity? (nullable) ctx.Db.Entity.Identity.Find(ctx.Sender); // Find by unique column → Entity? if (ctx.Db.Entity.Id.Find(entityId) is { } entity) { ... } // unwrap Entity? before member access ctx.Db.Item.AuthorId.Filter(authorId); // Filter by index → IEnumerable<Item> ctx.Db.Entity.Iter(); // All rows → IEnumerable<Entity> ctx.Db.Entity.Count; // Count rows ctx.Db.Entity.Id.Update(existing with { Name = newName }); // Update by PK ctx.Db.Entity.Id.Delete(entityId); // Delete by PK ``` Note: Filter/Iter return enumerables. Use `.ToList()` if you need to sort or mutate. The pattern is `ctx.Db.{Accessor}.{ColumnName}.{Method}(value)` for all indexed column operations. ## Lifecycle Hooks ```csharp [SpacetimeDB.Reducer(ReducerKind.Init)] public static void OnInit(ReducerContext ctx) { ... } [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] public static void OnConnect(ReducerContext ctx) { ... } [SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)] public static void OnDisconnect(ReducerContext ctx) { ... } ``` `ctx.ConnectionId` is `ConnectionId?`, including in connection lifecycle reducers. Check or unwrap it before storing it in a non-nullable column or passing it to an index accessor. ## Views ```csharp // Anonymous view (same result for all clients): [SpacetimeDB.View(Accessor = "ActiveUsers", Public = true)] public static List<Entity> ActiveUsers(AnonymousViewContext ctx) { return ctx.Db.Entity.Active.Filter(true).ToList(); } // Per-user view: [SpacetimeDB.View(Accessor = "MyEntities", Public = true)] public static List<Entity> MyEntities(ViewContext ctx) { return ctx.Db.Entity.Owner.Filter(ctx.Sender).ToList(); } ``` View contexts expose read-only table handles. These support `Count` plus `Find`/`Filter` on declared indexes, but not full-table `Iter()`. Start procedural view traversal from an indexed lookup or filter. Query-builder views use `ViewContext`, `ctx.From`, and return `IQuery<T>` directly. Use `Where` for predicates and `RightSemijoin` when the result should contain right-side rows that have a matching left-side row: ```csharp ctx.From.Article().Where(article => article.Published.Eq(true)); ctx.From.Subscription().RightSemijoin( ctx.From.Account(), (subscription, account) => subscription.AccountId.Eq(account.Id) ); ``` Declare a procedural view primary key in its attribute: `[SpacetimeDB.View(Accessor = "CatalogEntry", Public = true, PrimaryKey = nameof(CatalogRow.Sku))]`. Procedural-view primary keys are explicit schema metadata. Add one only when the view itself is required to expose a primary key; a source table's primary key is not inherited by the view. Inclusive btree ranges use tuples, for example `ctx.Db.Shipment.DeliverBy.Filter((new Timestamp(1_000), new Timestamp(2_000)))`. Views can also accept arguments (2.13+), after the context: ```csharp [SpacetimeDB.View(Accessor = "EntityById", Public = true)] public static Entity? EntityById(AnonymousViewContext ctx, ulong id) => ctx.Db.Entity.Id.Find(id); ``` Floating-point arguments and primary keys are rejected on argument-bearing views. See client skills for SQL subscription syntax and restrictions. ## Client Visibility Filters ```csharp [ClientVisibilityFilter] public static readonly Filter PrivateNoteFilter = new Filter.Sql( "SELECT * FROM OwnedRow WHERE Owner = :sender" ); ``` ## Reducer Context API `ReducerContext` (`ctx`) is the only source of sender identity, time, and randomness; stdlib clocks and RNG are unavailable in modules. ```csharp // Auth: ctx.Sender is the caller's Identity if (row.Owner != ctx.Sender) throw new Exception("unauthorized"); // Server timestamp (deterministic per reducer call) ctx.Db.Item.Insert(new Item { CreatedAt = ctx.Timestamp, .. }); // Timestamp arithmetic var expiry = ctx.Timestamp + new TimeDuration(delayMicros); // Deterministic RNG int roll = ctx.Rng.Next(1, 7); // [1, 7): inclusive 1, exclusive 7 double f = ctx.Rng.NextDouble(); // [0.0, 1.0) // Timestamp → milliseconds since epoch timestamp.MicrosecondsSinceUnixEpoch / 1000 ``` ## Submodules C# assembly composition requires SpacetimeDB/Runtime 2.13+, .NET 10, and C# 14 in the root and dependencies. NativeAOT-LLVM module builds require Windows or Linux, not macOS. Reference module assemblies via `ProjectReference` or NuGet. If libraries live beneath the root project, exclude their source from the root compile glob (`<Compile Remove="libraries/**/*.cs" />`). Dependencies, including transitive ones, register once in `public` unless some module mounts them with a `Namespace` attribute: ```csharp // Root Lib.cs, before namespace/type declarations: [assembly: SpacetimeDB.Namespace(typeof(AuthLib.Marker), Accessor = "MyAuth", Name = "auth_data")] ``` The marker is any accessible type in the dependency assembly. Root code uses `ctx.Db.MyAuth.User` and `ctx.From.MyAuth.User()`; library code keeps `ctx.Db.User` and `ctx.From.User()`. To call a library helper, pass `ctx.As.MyAuth` (`tx.As.MyAuth` inside `WithTx`; 2.14+). Passing `ctx` directly works only while the library has one unambiguous instance; otherwise it throws `InvalidOperationException`. Helpers share the caller's transaction; namespaces are not helper security boundaries. `Accessor` controls C# members; `Name` pins the canonical namespace used in SQL (`auth_data.<table>`). Without `Name`, the root's case policy derives it (`MyAuth` → `my_auth` by default). Changing the canonical namespace drops its tables and their data; keep `Name` fixed to rename only accessors. Named dependencies retain their naming policy; `public` dependencies inherit the root's and cannot explicitly conflict with it. In 2.14+, any module can mount its own dependencies, and the same assembly can be mounted more than once with independent tables at each path; 2.13 allows only root mounts, once per assembly. Cross-language composition is unsupported. Named dependencies cannot declare lifecycle reducers, nonempty environments, or RLS filters. Define RLS in the root with canonical qualified table names. Named-submodule routes are ignored; root handlers may delegate to library helpers. Dependencies in `public` can contribute lifecycle reducers (subject to uniqueness), environment declarations, and routes. ## Environment Variables ```csharp [SpacetimeDB.Env] public partial struct EnvironmentSchema { public string API_KEY; [SpacetimeDB.EnvValues("development", "production")] public string MODE; public string? LOG_LEVEL; } // In a root entry point: // ctx.Env.API_KEY: string; ctx.Env.LOG_LEVEL: string? // ctx.Env.Get("LOG_LEVEL"): string? ``` Enable nullable annotations for optional `string?` fields. One `[SpacetimeDB.Env]` struct declares the environment; `[EnvValues(...)]` restricts allowed strings. Colliding accessor names remain available through `Get("KEY")`. See the concepts skill for shared environment and secret-handling rules. ## Scheduled Tables Declare the scheduled table and its reducer in the same `Module` class so `nameof(...)` resolves: ```csharp [SpacetimeDB.Table( Accessor = "TickTimer", Scheduled = nameof(Tick), ScheduledAt = nameof(ScheduledAt), Public = true )] public partial struct TickTimer { [PrimaryKey] [AutoInc] public ulong ScheduledId; public ScheduleAt ScheduledAt; } [SpacetimeDB.Reducer] public static void Tick(ReducerContext ctx, TickTimer timer) { // timer row is auto-deleted after this reducer runs } // One-time: fires once at a specific time var at = new ScheduleAt.Time(ctx.Timestamp + new TimeDuration(10_000_000)); // Repeating: fires on an interval var at = new ScheduleAt.Interval(TimeSpan.FromSeconds(5)); ctx.Db.TickTimer.Insert(new TickTimer { ScheduledId = 0, ScheduledAt = at }); ``` Scheduled reducer callbacks use the ordinary `[SpacetimeDB.Reducer]` attribute. There is no `ReducerKind.Scheduled`; the table's `Scheduled` option associates the callback. To construct a `ConnectionId` from a 128-bit numeric representation, encode it as exactly 16 little-endian bytes and call `ConnectionId.From(bytes)`. The result is nullable and must be checked before use. ## Procedures and HTTP Procedures are unstable APIs, so modules using them should include `#pragma warning disable STDB_UNSTABLE`. They receive `ProcedureContext` and may return `[SpacetimeDB.Type]` values: ```csharp [SpacetimeDB.Type] public partial struct ResultValue { public string Value; } [SpacetimeDB.Procedure] public static ResultValue Inspect(ProcedureContext ctx, string input) => new() { Value = input }; ``` Outbound HTTP is available through `ctx.Http`; handle its success/error result before using the response. Open short database transactions with `ctx.WithTx`. Perform network I/O before opening the transaction, and keep only database work inside its callback. ```csharp var result = ctx.Http.Get(uri); var text = result.Match( response => response.Body.ToStringUtf8Lossy(),
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub