--- description: "⛔ MANDATORY: Read this ENTIRE file before writing ANY SpacetimeDB C# code. Contains SDK patterns from official documentation." globs: **/*.cs alwaysApply: true --- # SpacetimeDB C# SDK ## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS These are **actual errors** observed when LLMs generate SpacetimeDB C# code: ### 1. Wrong Package Name for Server Modules ```csharp // ❌ WRONG — this package doesn't exist // ✅ CORRECT — use Runtime for server modules ``` ### 2. Missing `partial` Keyword ```csharp // ❌ WRONG — missing partial public struct MyTable { } public class Module { } // ✅ CORRECT — must be partial public partial struct MyTable { } public static partial class Module { } ``` ### 3. Lifecycle Hook Names Starting with "On" ```csharp // ❌ WRONG — will cause STDB0010 error [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] public static void OnClientConnected(ReducerContext ctx) { } // ✅ CORRECT — no "On" prefix [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] public static void ClientConnected(ReducerContext ctx) { } ``` ### 4. Wrong Timestamp Property Name ```csharp // ❌ WRONG — property doesn't exist var micros = timestamp.MicrosecondsSinceEpoch; // ✅ CORRECT — full name var micros = timestamp.MicrosecondsSinceUnixEpoch; ``` ### 5. Wrong ScheduleAt Syntax ```csharp // ❌ WRONG — ScheduleAt.Time is not a method ScheduledAt = ScheduleAt.Time(timestamp) // ✅ CORRECT — use constructor syntax ScheduledAt = new ScheduleAt.Time(new Timestamp(microseconds)) // OR with TimeSpan ScheduledAt = new ScheduleAt.Time(ctx.Timestamp + TimeSpan.FromSeconds(60)) ``` ### 6. Table Accessor Casing Is Exact ```csharp // ✅ CORRECT — match the accessor exactly [SpacetimeDB.Table(Accessor = "user", Public = true)] public partial class User { ... } ctx.Db.user.Insert(...); // ❌ WRONG — incorrect casing ctx.Db.User.Insert(...); // Accessor is "user", so property is ctx.Db.user ``` ### 7. Client Callback Signature Errors ```csharp // ❌ WRONG — guessing at signatures .OnDisconnect((conn, ctx, err) => ...) .OnConnectError((ctx, err) => ...) // ✅ CORRECT — check actual delegate types .OnDisconnect((conn, err) => ...) // DbConnection, Exception? .OnConnectError(err => ...) // Exception ``` ### 8. WithUri Takes String, Not Uri ```csharp // ❌ WRONG — Uri object not accepted .WithUri(new Uri("http://localhost:3000")) // ✅ CORRECT — use string directly .WithUri("http://localhost:3000") ``` ### 9. Missing RuntimeIdentifier for WASM Build ```xml net8.0 net8.0 wasi-wasm ``` ### 10. Subscribing Before Connected ```csharp // ❌ WRONG — subscription fails silently _conn = builder.Build(); _conn.SubscriptionBuilder().SubscribeToAllTables(); // NOT CONNECTED YET! // ✅ CORRECT — subscribe inside OnConnect callback private void OnConnected(DbConnection conn, Identity identity, string token) { conn.SubscriptionBuilder() .OnApplied(OnSubscriptionApplied) .SubscribeToAllTables(); } ``` ### 11. Nullable Struct Handling with Find() ```csharp // ❌ WRONG — accessing properties directly on nullable if (existing != null) { ctx.Db.User.Id.Update(new User { Id = existing.Id, ... }); } // ✅ CORRECT — use .Value or pattern matching if (ctx.Db.User.Id.Find(id) is User user) { ctx.Db.User.Id.Update(user with { Name = newName }); } ``` ### 12. Sum Type Syntax Errors ```csharp // ❌ WRONG — struct instead of record public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { } // ❌ WRONG — missing variant names in tuple public partial record Shape : TaggedEnum<(Circle, Rectangle)> { } // ✅ CORRECT — record with named tuple elements public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } ``` ### 13. Index Attribute Ambiguity ```csharp // ❌ WRONG — ambiguous with System.Index [Index.BTree(Accessor = "by_name", Columns = new[] { nameof(Name) })] // ✅ CORRECT — always use full namespace [SpacetimeDB.Index.BTree(Accessor = "by_name", Columns = new[] { nameof(Name) })] ``` --- ## 1) Common Mistakes Table ### Server-side errors | Wrong | Right | Error | |-------|-------|-------| | Missing `partial` keyword | `public partial struct Table` | Generated code won't compile | | `ctx.Db.User` when `Accessor = "user"` | `ctx.Db.user` | Accessors are case-sensitive | | `Optional` | `string?` | Type not found | | `ctx.Db.Table.Get(id)` | `ctx.Db.Table.Id.Find(id)` | Method not found | | Wrong .csproj name | `StdbModule.csproj` | Publish fails silently | | .NET 9 SDK | .NET 8 SDK only | WASI compilation fails | | Missing WASI workload | `dotnet workload install wasi-experimental` | Build fails | | `[Procedure]` attribute | Reducers only | Procedures not supported in C# | | Missing `Public = true` | Add to `[Table]` attribute | Clients can't subscribe | | Using `Random` | Avoid non-deterministic code | Sandbox violation | | async/await in reducers | Synchronous only | Not supported | | `[Index.BTree(...)]` | `[SpacetimeDB.Index.BTree(...)]` | Ambiguous with System.Index | | `Columns = ["A", "B"]` | `Columns = new[] { "A", "B" }` | Collection expressions invalid in attributes | | `partial struct` or `partial class : TaggedEnum` | `partial record : TaggedEnum` | Sum types must be record | | `TaggedEnum<(A, B)>` | `TaggedEnum<(A A, B B)>` | Tuple must include variant names | --- ## 2) Table Definition (CRITICAL) **Tables MUST use `partial struct` or `partial class` for code generation.** ```csharp using SpacetimeDB; // ❌ WRONG — missing partial! [SpacetimeDB.Table(Accessor = "Player")] public struct Player { } // Will not generate properly! // ✅ CORRECT — with partial keyword [SpacetimeDB.Table(Accessor = "Player", Public = true)] public partial struct Player { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public ulong Id; public Identity OwnerId; public string Name; public Timestamp CreatedAt; } // With single-column index [SpacetimeDB.Table(Accessor = "Task", Public = true)] public partial struct Task { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public ulong Id; [SpacetimeDB.Index.BTree] public Identity OwnerId; public string Title; public bool Completed; } // 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; } ``` ### 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 ``` ### Column types ```csharp byte, sbyte, short, ushort // 8/16-bit integers int, uint, long, ulong // 32/64-bit integers float, double // Floats bool // Boolean string // Text Identity // User identity Timestamp // Timestamp ScheduleAt // For scheduled tables T? // Nullable (e.g., string?) List // Arrays ``` ### Insert with auto-increment ```csharp // Insert returns the row with generated ID 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; // Get actual generated ID ``` --- ## 3) Module and Reducers **The Module class MUST be `public static partial class`.** ```csharp using SpacetimeDB; public static partial class Module { [SpacetimeDB.Reducer] public static void CreateTask(ReducerContext ctx, string title) { // Validate if (string.IsNullOrEmpty(title)) { throw new Exception("Title cannot be empty"); // Rolls back transaction } // Insert 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); } } ``` ### Update Pattern (CRITICAL) ```csharp // ✅ CORRECT — use `with` expression or provide complete row if (ctx.Db.Task.Id.Find(id) is Task task) { ctx.Db.Task.Id.Update(task with { Title = newTitle }); } // ❌ WRONG — partial update nulls out other fields! ctx.Db.Task.Id.Update(new Task { Id = id, Title = newTitle }); ``` ### Lifecycle reducers ```csharp public static partial class Module { [SpacetimeDB.Reducer(ReducerKind.Init)] public static void Init(ReducerContext ctx) { // Called once when module is first published Log.Info("Module initialized"); } [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] public static void ClientConnected(ReducerContext ctx) { // ctx.Sender is the connecting client Log.Info($"Client connected: {ctx.Sender}"); } [SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)] public static void ClientDisconnected(ReducerContext ctx) { // Clean up client state Log.Info($"Client disconnected: {ctx.Sender}"); } } ``` ### 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) ``` --- ## 4) Database Access ### Primary key operations ```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 ctx.Db.Task.Id.Update(updatedTask); // Delete by primary key ctx.Db.Task.Id.Delete(taskId); ``` ### Index operations ```csharp // Find by unique index — returns nullable if (ctx.Db.Player.Username.Find("alice") is Player player) { // Found player } // Filter by B-tree index — returns iterator foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { // Process each task } ``` ### Iterate all rows ```csharp // Full table scan foreach (var task in ctx.Db.Task.Iter()) { // Process each task } ``` --- ## 5) Custom Types **Use `[SpacetimeDB.Type]` for custom structs/enums. Must be `partial`.** ```csharp using SpacetimeDB; [SpacetimeDB.Type] public partial struct Position { public int X; public int Y; } [SpacetimeDB.Type] public partial struct PlayerStats { public int Health; public int Mana; public Position Location; } // Use in table [SpacetimeDB.Table(Accessor = "Player", Public = true)] public partial struct Player { [SpacetimeDB.PrimaryKey] public Identity Id; public string Name; public PlayerStats Stats; } ``` --- ## 6) Sum Types / Tagged Enums (CRITICAL) **Sum types MUST use `partial record` (not `partial class`) and inherit from `TaggedEnum`.** ```csharp using SpacetimeDB; // Step 1: Define variant types as partial structs with [Type] [SpacetimeDB.Type] public partial struct Circle { public int Radius; } [SpacetimeDB.Type] public partial struct Rectangle { public int Width; public int Height; } // Step 2: Define sum type as partial RECORD (not struct or class!) inheriting TaggedEnum // The tuple MUST include both the type AND a name for each variant [SpacetimeDB.Type] public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // Step 3: Use in a table [SpacetimeDB.Table(Accessor = "Drawing", Public = true)] public partial struct Drawing { [SpacetimeDB.PrimaryKey] public int Id; public Shape ShapeA; public Shape ShapeB; } ``` ### Creating sum type values ```csharp // Create variant instances using the generated nested types var circle = new Shape.Circle(new Circle { Radius = 10 }); var rect = new Shape.Rectangle(new Rectangle { Width = 4, Height = 6 }); // Insert into table ctx.Db.Drawing.Insert(new Drawing { Id = 1, ShapeA = circle, ShapeB = rect }); ``` --- ## 7) Client SDK Setup ```csharp // Connection pattern _conn = DbConnection.Builder() .WithUri("http://localhost:3000") // String, NOT Uri object! .WithModuleName("my-module") .WithToken(savedToken) // null for first connection .OnConnect(OnConnected) .OnDisconnect((conn, err) => { }) .OnConnectError(err => { }) .Build(); // Subscribe in OnConnected callback, NOT before! private void OnConnected(DbConnection conn, Identity identity, string token) { conn.SubscriptionBuilder() .OnApplied(OnSubscriptionApplied) .SubscribeToAllTables(); } ``` ### ⚠️ CRITICAL: FrameTick **You MUST call `FrameTick()` regularly** — without it, no callbacks fire: ```csharp while (running) { conn.FrameTick(); Thread.Sleep(16); // ~60 times per second } ``` ### Row Callbacks (Client-side) ```csharp // Register callbacks BEFORE subscribing _conn.Db.Task.OnInsert += (EventContext ctx, Task row) => { // Row was inserted }; _conn.Db.Task.OnUpdate += (EventContext ctx, Task oldRow, Task newRow) => { // Row was updated }; _conn.Db.Task.OnDelete += (EventContext ctx, Task row) => { // Row was deleted }; ``` ### Invoking Reducers (Client-side) ```csharp // Reducers are called as methods on conn.Reducers _conn.Reducers.CreateTask("My task"); // Register callback for reducer completion _conn.Reducers.OnCreateTask += (ctx) => { if (ctx.Event.Status is Status.Committed) { // Success } else if (ctx.Event.Status is Status.Failed failed) { // Failed: failed.Item contains error } }; ``` --- ## 8) Scheduled Tables For C# scheduled tables, `ScheduledAt = ...` must point to a field of type `ScheduleAt` on the scheduled table. ```csharp using SpacetimeDB; [SpacetimeDB.Table(Accessor = "Reminder", Scheduled = nameof(SendReminder))] public partial struct Reminder { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public ulong Id; public string Message; public ScheduleAt ScheduledAt; } public static partial class Module { // Scheduled reducer receives the full row [SpacetimeDB.Reducer] public static void SendReminder(ReducerContext ctx, Reminder reminder) { Log.Info($"Reminder: {reminder.Message}"); // Row is automatically deleted after reducer completes } [SpacetimeDB.Reducer] public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs) { var futureTime = ctx.Timestamp + TimeSpan.FromSeconds(delaySecs); ctx.Db.Reminder.Insert(new Reminder { Id = 0, Message = message, ScheduledAt = new ScheduleAt.Time(futureTime) }); } [SpacetimeDB.Reducer] public static void CancelReminder(ReducerContext ctx, ulong reminderId) { ctx.Db.Reminder.Id.Delete(reminderId); } } ``` --- ## 9) Logging ```csharp using SpacetimeDB; Log.Debug("Debug message"); Log.Info("Information"); Log.Warn("Warning"); Log.Error("Error occurred"); Log.Panic("Critical failure"); // Terminates execution ``` --- ## 10) Timestamps ### Server-side ```csharp // Use ctx.Timestamp for current time ctx.Db.Task.Insert(new Task { // ... CreatedAt = ctx.Timestamp }); // Never use DateTime.Now - it's non-deterministic! ``` ### Client-side ```csharp // Timestamp has MicrosecondsSinceUnixEpoch property var dateTime = DateTimeOffset.FromUnixTimeMilliseconds( row.CreatedAt.MicrosecondsSinceUnixEpoch / 1000 ).LocalDateTime; ``` --- ## 11) Project Setup ### Required .csproj (MUST be named `StdbModule.csproj`) ```xml net8.0 wasi-wasm Exe enable enable ``` ### Prerequisites ```bash # Install .NET 8 SDK (required, not .NET 9) # Download from https://dotnet.microsoft.com/download/dotnet/8.0 # Install WASI workload dotnet workload install wasi-experimental ``` --- ## 12) Commands ```bash # Start local server spacetime start # Publish module spacetime publish --module-path # Clear database and republish spacetime publish --clear-database -y --module-path # Generate bindings spacetime generate --lang csharp --out-dir /SpacetimeDB --module-path # View logs spacetime logs ``` --- ## 13) Hard Requirements **C#-specific:** 1. **Tables and Module MUST be `partial`** — required for code generation 2. **Use PascalCase for table access** — `ctx.Db.TableName`, not `ctx.Db.tableName` 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. **C# does NOT support procedures** — use reducers only 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` 10. **Pass `0` for auto-increment** — to trigger ID generation on insert 11. **MUST call `FrameTick()` regularly** — client callbacks won't fire otherwise 12. **DO NOT edit generated bindings** — regenerate with `spacetime generate`