From 846f6c697c7d45981d731751738b9a98354799dd Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:00:51 -0500 Subject: [PATCH 1/4] Add AgentSkills.io integration for AI coding assistants Create 5 skill files that enable developers to install SpacetimeDB expertise into Claude Code, Cursor, Cline, and 40+ other AI agents via `npx skills add spacetimedb/spacetimedb`. Skills included: - spacetimedb-rust: Server module development (tables, reducers, macros) - spacetimedb-typescript: TypeScript SDK (connection, subscriptions, React) - spacetimedb-csharp: C#/Unity SDK (BSATN, Unity integration, threading) - spacetimedb-cli: CLI workflows (init, build, publish, generate) - spacetimedb-concepts: Core architecture (tables, reducers, subscriptions) --- .claude/skills/spacetimedb-cli/SKILL.md | 239 +++++ .claude/skills/spacetimedb-concepts/SKILL.md | 462 +++++++++ .claude/skills/spacetimedb-csharp/SKILL.md | 882 ++++++++++++++++++ .claude/skills/spacetimedb-rust/SKILL.md | 687 ++++++++++++++ .../skills/spacetimedb-typescript/SKILL.md | 663 +++++++++++++ skills | 1 + 6 files changed, 2934 insertions(+) create mode 100644 .claude/skills/spacetimedb-cli/SKILL.md create mode 100644 .claude/skills/spacetimedb-concepts/SKILL.md create mode 100644 .claude/skills/spacetimedb-csharp/SKILL.md create mode 100644 .claude/skills/spacetimedb-rust/SKILL.md create mode 100644 .claude/skills/spacetimedb-typescript/SKILL.md create mode 120000 skills diff --git a/.claude/skills/spacetimedb-cli/SKILL.md b/.claude/skills/spacetimedb-cli/SKILL.md new file mode 100644 index 00000000000..77f3cdc678c --- /dev/null +++ b/.claude/skills/spacetimedb-cli/SKILL.md @@ -0,0 +1,239 @@ +--- +name: spacetimedb-cli +description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers +triggers: + - spacetime init + - spacetime build + - spacetime publish + - spacetime dev + - spacetime sql + - spacetime call + - spacetime logs + - spacetime server + - spacetime login + - spacetime generate + - how do I use the CLI + - CLI command +--- + +# SpacetimeDB CLI + +Use this skill when the user needs help with the `spacetime` CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues. + +## Quick Reference + +### Project Initialization & Development + +```bash +# Initialize new project +spacetime init my-project --lang rust|csharp|typescript +spacetime init my-project --template + +# Build module +spacetime build # release build +spacetime build --debug # faster iteration, slower runtime + +# Dev mode (auto-rebuild, auto-publish, generates bindings) +spacetime dev +spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings + +# Generate client bindings +spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings +``` + +### Publishing & Deployment + +```bash +# Publish to Maincloud (default) +spacetime publish my-database --yes + +# Publish to local server +spacetime publish my-database --server local --yes + +# Publish with data handling +spacetime publish my-database --delete-data always # always clear data +spacetime publish my-database --delete-data on-conflict # clear only if schema conflicts +spacetime publish my-database --delete-data never # never clear (default) + +# Allow breaking client changes +spacetime publish my-database --break-clients +``` + +### Database Interaction + +```bash +# SQL queries +spacetime sql my-database "SELECT * FROM users" +spacetime sql my-database --interactive # REPL mode + +# Call reducers +spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}' + +# Subscribe to changes +spacetime subscribe my-database "SELECT * FROM users" --num-updates 10 + +# View logs +spacetime logs my-database -f # follow logs +spacetime logs my-database -n 100 # last 100 lines + +# Describe schema +spacetime describe my-database --json +spacetime describe my-database table users --json +spacetime describe my-database reducer my_reducer --json +``` + +### Database Management + +```bash +# List databases +spacetime list + +# Delete database +spacetime delete my-database + +# Rename database +spacetime rename --to new-name +``` + +### Server Management + +```bash +# List configured servers +spacetime server list + +# Add server +spacetime server add local http://localhost:3000 --default +spacetime server add myserver https://my-spacetime.example.com + +# Set default server +spacetime server set-default local + +# Test connectivity +spacetime server ping local + +# Start local instance +spacetime start + +# Clear local data +spacetime server clear +``` + +### Authentication + +```bash +# Login (opens browser) +spacetime login + +# Login with token +spacetime login --token + +# Show login status +spacetime login show + +# Logout +spacetime logout +``` + +### Energy/Billing + +```bash +spacetime energy balance +spacetime energy balance --identity +``` + +## Default Servers + +| Name | URL | Description | +|------|-----|-------------| +| `maincloud` | `https://spacetimedb.com` | Production cloud (default) | +| `local` | `http://127.0.0.1:3000` | Local development server | + +## Common Workflows + +### New Project Setup + +```bash +# 1. Login +spacetime login + +# 2. Create project +spacetime init my-game --lang rust +cd my-game + +# 3. Start dev mode (auto-rebuilds and publishes) +spacetime dev +``` + +### Local Development + +```bash +# Start local server (in separate terminal) +spacetime start + +# Publish to local +spacetime publish my-db --server local --delete-data always --yes + +# Query local database +spacetime sql my-db --server local "SELECT * FROM players" +``` + +### Generate Client Bindings + +```bash +# After building module +spacetime build +spacetime generate --lang typescript --out-dir ./client/src/bindings + +# Or use dev mode which auto-generates +spacetime dev --client-lang typescript --module-bindings-path ./client/src/bindings +``` + +## Common Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--server` | `-s` | Target server (nickname, hostname, or URL) | +| `--yes` | `-y` | Non-interactive mode (skip confirmations) | +| `--anonymous` | | Use anonymous identity | +| `--identity` | `-i` | Specify identity to use | +| `--project-path` | `-p` | Path to module project | + +## Troubleshooting + +### "Not logged in" +```bash +spacetime login +# Or use --anonymous for public operations +``` + +### "Server not responding" +```bash +spacetime server ping +# For local: ensure spacetime start is running +``` + +### "Schema conflict" +```bash +# Clear data and republish +spacetime publish my-db --delete-data always --yes +``` + +### "Build failed" +```bash +# Check Rust/C# toolchain +rustup show +# For Rust modules, ensure wasm32-unknown-unknown target +rustup target add wasm32-unknown-unknown +``` + +## Module Languages + +**Server-side (modules):** Rust, C#, TypeScript +**Client SDKs:** TypeScript, C#, Rust, Python, Unreal Engine + +## Notes + +- Many commands are marked UNSTABLE and may change +- Default server is `maincloud` unless configured otherwise +- Use `--yes` flag in scripts to avoid interactive prompts +- Dev mode watches files and auto-rebuilds on changes diff --git a/.claude/skills/spacetimedb-concepts/SKILL.md b/.claude/skills/spacetimedb-concepts/SKILL.md new file mode 100644 index 00000000000..0c96056743d --- /dev/null +++ b/.claude/skills/spacetimedb-concepts/SKILL.md @@ -0,0 +1,462 @@ +--- +name: spacetimedb-concepts +description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "1.0" +--- + +# SpacetimeDB Core Concepts + +SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely. + +## What SpacetimeDB Is + +SpacetimeDB combines a database and application server into a single deployable unit. Clients connect directly to the database and execute application logic inside it. The system is optimized for real-time applications requiring maximum speed and minimum latency. + +Key characteristics: + +- **In-memory execution**: All application state lives in memory for sub-millisecond access +- **Persistent storage**: Data is automatically persisted to a write-ahead log (WAL) for durability +- **Real-time synchronization**: Changes are automatically pushed to subscribed clients +- **Single deployment**: No separate servers, containers, or infrastructure to manage + +SpacetimeDB powers BitCraft Online, an MMORPG where the entire game backend (chat, items, resources, terrain, player positions) runs as a single SpacetimeDB module. + +## The Five Zen Principles + +SpacetimeDB is built on five core principles that guide both development and usage: + +1. **Everything is a Table**: Your entire application state lives in tables. No separate cache layer, no Redis, no in-memory state to synchronize. The database IS your state. + +2. **Everything is Persistent**: SpacetimeDB persists everything by default, including full history. Persistence only increases latency, never decreases throughput. Modern SSDs can write 15+ GB/s. + +3. **Everything is Real-Time**: Clients are replicas of server state. Subscribe to data and it flows automatically. No polling, no fetching. + +4. **Everything is Transactional**: Every reducer runs atomically. Either all changes succeed or all roll back. No partial updates, no corrupted state. + +5. **Everything is Programmable**: Modules are real code (Rust, C#, TypeScript) running inside the database. Full Turing-complete power for any logic. + +## Tables + +Tables store all data in SpacetimeDB. They use the relational model and support SQL queries for subscriptions. + +### Defining Tables + +Tables are defined using language-specific attributes: + +**Rust:** +```rust +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + #[auto_inc] + id: u32, + #[index(btree)] + name: String, + #[unique] + email: String, +} +``` + +**C#:** +```csharp +[SpacetimeDB.Table(Name = "Player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public uint Id; + [SpacetimeDB.Index.BTree] + public string Name; + [SpacetimeDB.Unique] + public string Email; +} +``` + +**TypeScript:** +```typescript +const players = table( + { name: 'players', public: true }, + { + id: t.u32().primaryKey().autoInc(), + name: t.string().index('btree'), + email: t.string().unique(), + } +); +``` + +### Table Visibility + +- **Private tables** (default): Only accessible by reducers and the database owner +- **Public tables**: Exposed for client read access through subscriptions. Writes still require reducers. + +### Table Design Principles + +Organize data by access pattern, not by entity: + +**Decomposed approach (recommended):** +``` +Player PlayerState PlayerStats +id <-- player_id player_id +name position_x total_kills + position_y total_deaths + velocity_x play_time + velocity_y +``` + +Benefits: +- Reduced bandwidth (clients subscribing to positions do not receive settings updates) +- Cache efficiency (similar update frequencies in contiguous memory) +- Schema evolution (add columns without affecting other tables) +- Semantic clarity (each table has single responsibility) + +## Reducers + +Reducers are transactional functions that modify database state. They are the ONLY way to mutate tables in SpacetimeDB. + +### Key Properties + +- **Transactional**: Run in isolated database transactions +- **Atomic**: Either all changes succeed or all roll back +- **Isolated**: Cannot interact with the outside world (no network, no filesystem) +- **Callable**: Clients invoke reducers as remote procedure calls + +### Defining Reducers + +**Rust:** +```rust +#[spacetimedb::reducer] +pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String> { + if name.is_empty() { + return Err("Name cannot be empty".to_string()); + } + ctx.db.user().insert(User { id: 0, name, email }); + Ok(()) +} +``` + +**C#:** +```csharp +[SpacetimeDB.Reducer] +public static void CreateUser(ReducerContext ctx, string name, string email) +{ + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Name cannot be empty"); + ctx.Db.User.Insert(new User { Id = 0, Name = name, Email = email }); +} +``` + +### ReducerContext + +Every reducer receives a `ReducerContext` providing: +- `ctx.db`: Access to all tables (read and write) +- `ctx.sender`: The Identity of the caller +- `ctx.connection_id`: The connection ID of the caller +- `ctx.timestamp`: The current timestamp + +### Critical Rules + +1. **No global state**: Relying on static variables is undefined behavior +2. **No side effects**: Reducers cannot make network requests or access files +3. **Store state in tables**: All persistent state must be in tables + +## Subscriptions + +Subscriptions replicate database rows to clients in real-time. When you subscribe to a query, SpacetimeDB sends matching rows immediately and pushes updates whenever those rows change. + +### How Subscriptions Work + +1. **Subscribe**: Register SQL queries describing needed data +2. **Receive initial data**: All matching rows are sent immediately +3. **Receive updates**: Real-time updates when subscribed rows change +4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`) to handle changes + +### Client-Side Usage + +**TypeScript:** +```typescript +const conn = DbConnection.builder() + .withUri('wss://maincloud.spacetimedb.com') + .withModuleName('my_module') + .onConnect((ctx) => { + ctx.subscriptionBuilder() + .onApplied(() => console.log('Subscription ready!')) + .subscribe(['SELECT * FROM user', 'SELECT * FROM message']); + }) + .build(); + +// React to changes +conn.db.user.onInsert((ctx, user) => console.log(`New user: ${user.name}`)); +conn.db.user.onDelete((ctx, user) => console.log(`User left: ${user.name}`)); +conn.db.user.onUpdate((ctx, old, new_) => console.log(`${old.name} -> ${new_.name}`)); +``` + +### Subscription Best Practices + +1. **Group subscriptions by lifetime**: Keep always-needed data separate from temporary subscriptions +2. **Subscribe before unsubscribing**: When updating subscriptions, subscribe to new data first +3. **Avoid overlapping queries**: Distinct queries returning overlapping data cause redundant processing +4. **Use indexes**: Queries on indexed columns are efficient; full table scans are expensive + +## Modules + +Modules are WebAssembly bundles containing application logic that runs inside the database. + +### Module Components + +- **Tables**: Define the data schema +- **Reducers**: Define callable functions that modify state +- **Views**: Define read-only computed queries +- **Procedures**: (Beta) Functions that can have side effects (HTTP requests) + +### Module Languages + +Server-side modules can be written in: +- Rust +- C# +- TypeScript (beta) + +### Module Lifecycle + +1. **Write**: Define tables and reducers in your chosen language +2. **Compile**: Build to WebAssembly using the SpacetimeDB CLI +3. **Publish**: Upload to a SpacetimeDB host with `spacetime publish` +4. **Hot-swap**: Republish to update code without disconnecting clients + +## Identity + +Identity is SpacetimeDB's authentication system based on OpenID Connect (OIDC). + +### Identity Concepts + +- **Identity**: A long-lived, globally unique identifier for a user. Derived from OIDC issuer and subject claims. +- **ConnectionId**: Identifies a specific client connection. A user may have multiple connections. + +### Identity in Reducers + +```rust +#[spacetimedb::reducer] +pub fn do_something(ctx: &ReducerContext) { + let caller_identity = ctx.sender; // Who is calling this reducer? + // Use identity for authorization checks +} +``` + +### Authentication Providers + +SpacetimeDB works with any OIDC provider: +- **SpacetimeAuth**: Built-in managed provider (simple, production-ready) +- **Third-party**: Auth0, Clerk, Keycloak, Google, GitHub, etc. + +## SATS (SpacetimeDB Algebraic Type System) + +SATS is the type system and serialization format used throughout SpacetimeDB. + +### Core Types + +| Category | Types | +|----------|-------| +| Primitives | `Bool`, `U8`-`U256`, `I8`-`I256`, `F32`, `F64`, `String` | +| Composite | `ProductType` (structs), `SumType` (enums/tagged unions) | +| Collections | `Array`, `Map` | +| Special | `Identity`, `ConnectionId`, `ScheduleAt` | + +### Serialization Formats + +- **BSATN**: Binary format for module-host communication and row storage +- **SATS-JSON**: JSON format for HTTP API and WebSocket text protocol + +### Type Compatibility + +Types must implement `SpacetimeType` to be used in tables and reducers. This is automatic for primitive types and structs using the appropriate attributes. + +## Client-Server Data Flow + +### Write Path (Client to Database) + +1. Client calls reducer (e.g., `ctx.reducers.createUser("Alice")`) +2. Request sent over WebSocket to SpacetimeDB host +3. Host validates identity and executes reducer in transaction +4. On success, changes are committed; on error, all changes roll back +5. Subscribed clients receive updates for affected rows + +### Read Path (Database to Client) + +1. Client subscribes with SQL queries (e.g., `SELECT * FROM user`) +2. Server evaluates query and sends matching rows +3. Client maintains local cache of subscribed data +4. When subscribed data changes, server pushes delta updates +5. Client cache is automatically updated; callbacks fire + +### Data Flow Diagram + +``` +┌─────────────────────────────────────────────────────────┐ +│ CLIENT │ +│ ┌─────────────┐ ┌─────────────────────────────┐ │ +│ │ Reducers │────>│ Local Cache (Read) │ │ +│ │ (Write) │ │ - Tables from subscriptions│ │ +│ └─────────────┘ │ - Automatically synced │ │ +│ │ └─────────────────────────────┘ │ +└─────────│──────────────────────────│───────────────────┘ + │ WebSocket │ Updates pushed + v │ +┌─────────────────────────────────────────────────────────┐ +│ SpacetimeDB │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Module │ │ +│ │ - Reducers (transactional logic) │ │ +│ │ - Tables (in-memory + persisted) │ │ +│ │ - Subscriptions (real-time queries) │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## When to Use SpacetimeDB + +### Ideal Use Cases + +- **Real-time games**: MMOs, multiplayer games, turn-based games +- **Collaborative applications**: Document editing, whiteboards, design tools +- **Chat and messaging**: Real-time communication with presence +- **Live dashboards**: Streaming analytics and monitoring +- **IoT applications**: Sensor data with real-time updates + +### Key Decision Factors + +Choose SpacetimeDB when you need: +- Sub-10ms latency for reads and writes +- Automatic real-time synchronization +- Transactional guarantees for all operations +- Simplified architecture (no separate cache, queue, or server) + +### Less Suitable For + +- **Batch analytics**: SpacetimeDB is optimized for OLTP, not OLAP +- **Large blob storage**: Better suited for structured relational data +- **Stateless APIs**: Traditional REST APIs don't need real-time sync + +## Comparison to Traditional Architectures + +### Traditional Stack + +``` +Client + │ + v +Load Balancer + │ + v +Web/Game Servers (stateless or stateful) + │ + ├──> Cache (Redis) + │ + v +Database (PostgreSQL, MySQL) + │ + v +Message Queue (for real-time) +``` + +**Pain points:** +- Multiple systems to deploy and manage +- Cache invalidation complexity +- State synchronization between servers +- Manual real-time implementation +- Horizontal scaling complexity + +### SpacetimeDB Stack + +``` +Client + │ + v +SpacetimeDB Host + │ + v +Module (your logic + tables) +``` + +**Benefits:** +- Single deployment target +- No cache layer needed (in-memory by design) +- Automatic real-time synchronization +- Built-in horizontal scaling (future) +- Transactional guarantees everywhere + +### Smart Contract Comparison + +SpacetimeDB modules are conceptually similar to smart contracts: +- Application logic runs inside the data layer +- Transactions are atomic and verified +- State changes are deterministic + +Key differences: +- SpacetimeDB is orders of magnitude faster (no consensus overhead) +- Full relational database capabilities +- No blockchain or cryptocurrency involved +- Designed for real-time, not eventual consistency + +## Quick Reference + +### CLI Commands + +```bash +# Install SpacetimeDB +curl -sSf https://install.spacetimedb.com | sh + +# Start local server +spacetime start + +# Create and publish module +spacetime init my-module --lang rust +spacetime publish my-module + +# Generate client bindings +spacetime generate --out-dir ./client/bindings +``` + +### Common Patterns + +**Authentication check in reducer:** +```rust +#[spacetimedb::reducer] +fn admin_action(ctx: &ReducerContext) -> Result<(), String> { + let admin = ctx.db.admin().identity().find(&ctx.sender) + .ok_or("Not an admin")?; + // ... perform admin action + Ok(()) +} +``` + +**Moving between tables (state machine):** +```rust +#[spacetimedb::reducer] +fn login(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx.db.logged_out_player().identity().find(&ctx.sender) + .ok_or("Not found")?; + ctx.db.player().insert(player.clone()); + ctx.db.logged_out_player().identity().delete(&ctx.sender); + Ok(()) +} +``` + +**Scheduled reducer:** +```rust +#[spacetimedb::table(name = reminder, scheduled(send_reminder))] +pub struct Reminder { + #[primary_key] + #[auto_inc] + id: u64, + scheduled_at: ScheduleAt, + message: String, +} + +#[spacetimedb::reducer] +fn send_reminder(ctx: &ReducerContext, reminder: Reminder) { + // This runs at the scheduled time + log::info!("Reminder: {}", reminder.message); +} +``` diff --git a/.claude/skills/spacetimedb-csharp/SKILL.md b/.claude/skills/spacetimedb-csharp/SKILL.md new file mode 100644 index 00000000000..b6bd3e9af8d --- /dev/null +++ b/.claude/skills/spacetimedb-csharp/SKILL.md @@ -0,0 +1,882 @@ +--- +name: spacetimedb-csharp +description: Build C# and Unity clients for SpacetimeDB. Use when integrating SpacetimeDB with Unity games or .NET applications. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "1.0" +--- + +# SpacetimeDB C# / Unity SDK + +This skill provides comprehensive guidance for building C# and Unity clients that connect to SpacetimeDB modules. + +## Overview + +The SpacetimeDB C# SDK enables .NET applications and Unity games to: +- Connect to SpacetimeDB databases over WebSocket +- Subscribe to real-time table updates +- Invoke reducers (server-side functions) +- Maintain a local cache of subscribed data +- Handle authentication via Identity tokens + +**Critical Requirement**: The C# SDK requires manual connection advancement. You must call `FrameTick()` regularly to process messages. + +## Installation + +### .NET Console/Library Applications + +Add the NuGet package: + +```bash +dotnet add package SpacetimeDB.ClientSDK +``` + +### Unity Applications + +Add via Unity Package Manager using the git URL: + +``` +https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git +``` + +Steps: +1. Open Window > Package Manager +2. Click the + button in top-left +3. Select "Add package from git URL" +4. Paste the URL above and click Add + +## Generate Module Bindings + +Before using the SDK, generate type-safe bindings from your module: + +```bash +mkdir -p module_bindings +spacetime generate --lang cs --out-dir module_bindings --project-path PATH_TO_MODULE +``` + +This creates: +- `SpacetimeDBClient.g.cs` - Main client types (DbConnection, contexts, builders) +- `Tables/*.g.cs` - Table handle classes with typed access +- `Reducers/*.g.cs` - Reducer invocation methods +- `Types/*.g.cs` - Row types and custom types from the module + +## Connection Setup + +### Basic Connection Pattern + +```csharp +using SpacetimeDB; +using SpacetimeDB.Types; + +DbConnection? conn = null; + +conn = DbConnection.Builder() + .WithUri("http://localhost:3000") // SpacetimeDB server URL + .WithModuleName("my-database") // Database name or Identity + .OnConnect(OnConnected) // Connection success callback + .OnConnectError((err) => { // Connection failure callback + Console.Error.WriteLine($"Connection failed: {err}"); + }) + .OnDisconnect((conn, err) => { // Disconnection callback + if (err != null) { + Console.Error.WriteLine($"Disconnected with error: {err}"); + } + }) + .Build(); + +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + Console.WriteLine($"Connected with Identity: {identity}"); + // Save authToken for reconnection + // Set up subscriptions here +} +``` + +### Connection Builder Methods + +| Method | Description | +|--------|-------------| +| `WithUri(string uri)` | SpacetimeDB server URI (required) | +| `WithModuleName(string name)` | Database name or Identity (required) | +| `WithToken(string token)` | Auth token for reconnection | +| `WithConfirmedReads(bool)` | Wait for durable writes before returning | +| `OnConnect(callback)` | Called on successful connection | +| `OnConnectError(callback)` | Called if connection fails | +| `OnDisconnect(callback)` | Called when disconnected | +| `Build()` | Create and open the connection | + +## Critical: Advancing the Connection + +**The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly. + +### Console Application Loop + +```csharp +while (true) +{ + conn.FrameTick(); + Thread.Sleep(16); // ~60 FPS +} +``` + +### Unity MonoBehaviour Pattern + +```csharp +public class SpacetimeManager : MonoBehaviour +{ + private DbConnection conn; + + void Update() + { + conn?.FrameTick(); + } +} +``` + +**Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races with main thread access. + +## Subscribing to Tables + +### Using SQL Queries + +```csharp +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .OnError((ctx, err) => { + Console.Error.WriteLine($"Subscription failed: {err}"); + }) + .Subscribe(new[] { + "SELECT * FROM player", + "SELECT * FROM message WHERE sender = :sender" + }); +} + +void OnSubscriptionApplied(SubscriptionEventContext ctx) +{ + Console.WriteLine("Subscription ready - data available"); + // Access ctx.Db to read subscribed rows +} +``` + +### Using Typed Query Builder + +```csharp +conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .OnError((ctx, err) => Console.Error.WriteLine(err)) + .AddQuery(qb => qb.From.Player().Build()) + .AddQuery(qb => qb.From.Message().Where(c => c.Sender.Eq(identity)).Build()) + .Subscribe(); +``` + +### Subscribe to All Tables (Development Only) + +```csharp +conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); +``` + +**Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection. + +### Subscription Handle + +```csharp +SubscriptionHandle handle = conn.SubscriptionBuilder() + .OnApplied(ctx => Console.WriteLine("Applied")) + .Subscribe(new[] { "SELECT * FROM player" }); + +// Later: unsubscribe +handle.UnsubscribeThen(ctx => { + Console.WriteLine("Unsubscribed"); +}); + +// Check status +bool isActive = handle.IsActive; +bool isEnded = handle.IsEnded; +``` + +## Accessing the Client Cache + +Subscribed data is stored in `conn.Db` (or `ctx.Db` in callbacks). + +### Iterating All Rows + +```csharp +foreach (var player in ctx.Db.Player.Iter()) +{ + Console.WriteLine($"Player: {player.Name}"); +} +``` + +### Count Rows + +```csharp +int playerCount = ctx.Db.Player.Count; +``` + +### Find by Unique/Primary Key + +For columns marked `[Unique]` or `[PrimaryKey]` on the server: + +```csharp +// Find by unique column +Player? player = ctx.Db.Player.Identity.Find(someIdentity); + +// Returns null if not found +if (player != null) +{ + Console.WriteLine($"Found: {player.Name}"); +} +``` + +### Filter by BTree Index + +For columns with `[Index.BTree]` on the server: + +```csharp +// Filter returns IEnumerable +IEnumerable levelOnePlayers = ctx.Db.Player.Level.Filter(1); + +int count = levelOnePlayers.Count(); +``` + +### Remote Query (Ad-hoc SQL) + +```csharp +var result = ctx.Db.Player.RemoteQuery("WHERE level > 10"); +Player[] highLevelPlayers = result.Result; +``` + +## Row Event Callbacks + +Register callbacks to react to table changes: + +### OnInsert + +```csharp +ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { + Console.WriteLine($"Player joined: {player.Name}"); +}; +``` + +### OnDelete + +```csharp +ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => { + Console.WriteLine($"Player left: {player.Name}"); +}; +``` + +### OnUpdate + +Fires when a row with a primary key is replaced: + +```csharp +ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => { + Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}"); +}; +``` + +### Checking Event Source + +```csharp +ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { + switch (ctx.Event) + { + case Event.SubscribeApplied: + // Initial subscription data + break; + case Event.Reducer(var reducerEvent): + // Change from a reducer + Console.WriteLine($"Reducer: {reducerEvent.Reducer}"); + break; + } +}; +``` + +## Calling Reducers + +Reducers are server-side functions that modify the database. + +### Invoke a Reducer + +```csharp +// Reducers are methods on ctx.Reducers or conn.Reducers +ctx.Reducers.SendMessage("Hello, world!"); +ctx.Reducers.CreatePlayer("NewPlayer"); +ctx.Reducers.UpdateScore(playerId, 100); +``` + +### Reducer Callbacks + +React when a reducer completes (success or failure): + +```csharp +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 + +Catch reducer errors without specific handlers: + +```csharp +conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => { + Console.Error.WriteLine($"Reducer error: {ex.Message}"); +}; +``` + +### Reducer Event Properties + +```csharp +conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => { + ReducerEvent evt = ctx.Event; + + Timestamp when = evt.Timestamp; + Status status = evt.Status; + Identity caller = evt.CallerIdentity; + ConnectionId? callerId = evt.CallerConnectionId; + U128? energy = evt.EnergyConsumed; +}; +``` + +## Identity and Authentication + +### Getting Current Identity + +```csharp +// In OnConnect callback +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + // identity - your unique identifier + // authToken - save this for reconnection + PlayerPrefs.SetString("SpacetimeToken", authToken); +} + +// From any context +Identity? myIdentity = ctx.Identity; +ConnectionId myConnectionId = ctx.ConnectionId; +``` + +### Reconnecting with Token + +```csharp +string savedToken = PlayerPrefs.GetString("SpacetimeToken", null); + +DbConnection.Builder() + .WithUri("http://localhost:3000") + .WithModuleName("my-database") + .WithToken(savedToken) // Reconnect as same identity + .OnConnect(OnConnected) + .Build(); +``` + +### Anonymous Connection + +Pass `null` to `WithToken` or omit it entirely for a new anonymous identity. + +## BSATN Serialization + +SpacetimeDB uses BSATN (Binary SpacetimeDB Algebraic Type Notation) for serialization. The SDK handles this automatically for generated types. + +### Supported Types + +| C# Type | SpacetimeDB Type | +|---------|------------------| +| `bool` | Bool | +| `byte`, `sbyte` | U8, I8 | +| `ushort`, `short` | U16, I16 | +| `uint`, `int` | U32, I32 | +| `ulong`, `long` | U64, I64 | +| `U128`, `I128` | U128, I128 | +| `U256`, `I256` | U256, I256 | +| `float`, `double` | F32, F64 | +| `string` | String | +| `List` | Array | +| `T?` (nullable) | Option | +| `Identity` | Identity | +| `ConnectionId` | ConnectionId | +| `Timestamp` | Timestamp | +| `Uuid` | Uuid | + +### Custom Types + +Types marked with `[SpacetimeDB.Type]` on the server are generated as C# types: + +```csharp +// Server-side (Rust or C#) +[SpacetimeDB.Type] +public partial struct Vector3 +{ + public float X; + public float Y; + public float Z; +} + +// Client-side (auto-generated) +public partial struct Vector3 : IEquatable +{ + public float X; + public float Y; + public float Z; + // BSATN serialization methods included +} +``` + +### TaggedEnum (Sum Types) + +```csharp +// Server +[SpacetimeDB.Type] +public partial record GameEvent : TaggedEnum<( + string PlayerJoined, + string PlayerLeft, + (string player, int score) ScoreUpdate +)>; + +// Client usage +switch (gameEvent) +{ + case GameEvent.PlayerJoined(var name): + Console.WriteLine($"{name} joined"); + break; + case GameEvent.ScoreUpdate((var player, var score)): + Console.WriteLine($"{player} scored {score}"); + break; +} +``` + +### Result Type + +```csharp +// Result for success/error handling +Result result = ...; + +if (result is Result.Ok(var player)) +{ + Console.WriteLine($"Success: {player.Name}"); +} +else if (result is Result.Err(var error)) +{ + Console.Error.WriteLine($"Error: {error}"); +} +``` + +## Unity Integration + +### Project Setup + +1. Add the SpacetimeDB package via Package Manager +2. Generate bindings and add to your Unity project +3. Create a manager MonoBehaviour + +### SpacetimeManager Pattern + +```csharp +using UnityEngine; +using SpacetimeDB; +using SpacetimeDB.Types; + +public class SpacetimeManager : MonoBehaviour +{ + public static SpacetimeManager Instance { get; private set; } + + [SerializeField] private string serverUri = "http://localhost:3000"; + [SerializeField] private string moduleName = "my-game"; + + private DbConnection conn; + public DbConnection Connection => conn; + + void Awake() + { + if (Instance != null) + { + Destroy(gameObject); + return; + } + Instance = this; + DontDestroyOnLoad(gameObject); + } + + void Start() + { + Connect(); + } + + void Update() + { + // CRITICAL: Must call every frame + conn?.FrameTick(); + } + + void OnDestroy() + { + conn?.Disconnect(); + } + + public void Connect() + { + string token = PlayerPrefs.GetString("SpacetimeToken", null); + + conn = DbConnection.Builder() + .WithUri(serverUri) + .WithModuleName(moduleName) + .WithToken(token) + .OnConnect(OnConnected) + .OnConnectError(OnConnectError) + .OnDisconnect(OnDisconnect) + .Build(); + } + + private void OnConnected(DbConnection conn, Identity identity, string authToken) + { + Debug.Log($"Connected as {identity}"); + PlayerPrefs.SetString("SpacetimeToken", authToken); + + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .OnError((ctx, err) => Debug.LogError($"Subscription error: {err}")) + .SubscribeToAllTables(); + } + + private void OnConnectError(Exception err) + { + Debug.LogError($"Connection failed: {err}"); + } + + private void OnDisconnect(DbConnection conn, Exception err) + { + if (err != null) + { + Debug.LogError($"Disconnected: {err}"); + } + } + + private void OnSubscriptionApplied(SubscriptionEventContext ctx) + { + Debug.Log("Subscription ready"); + // Initialize game state from ctx.Db + } +} +``` + +### Unity-Specific Considerations + +1. **Main Thread Only**: All SpacetimeDB callbacks run on the main thread (during `FrameTick()`) + +2. **Scene Loading**: Use `DontDestroyOnLoad` for the connection manager + +3. **Reconnection**: Handle disconnects gracefully for mobile/poor connectivity + +4. **PlayerPrefs**: Use for token persistence (or use a more secure method for production) + +### Spawning GameObjects from Table Data + +```csharp +public class PlayerSpawner : MonoBehaviour +{ + [SerializeField] private GameObject playerPrefab; + private Dictionary playerObjects = new(); + + void Start() + { + var conn = SpacetimeManager.Instance.Connection; + + conn.Db.Player.OnInsert += OnPlayerInsert; + conn.Db.Player.OnDelete += OnPlayerDelete; + conn.Db.Player.OnUpdate += OnPlayerUpdate; + + // Spawn existing players + foreach (var player in conn.Db.Player.Iter()) + { + SpawnPlayer(player); + } + } + + void OnPlayerInsert(EventContext ctx, Player player) + { + // Skip if this is initial subscription data we already handled + if (ctx.Event is Event.SubscribeApplied) return; + + SpawnPlayer(player); + } + + void OnPlayerDelete(EventContext ctx, Player player) + { + if (playerObjects.TryGetValue(player.Identity, out var go)) + { + Destroy(go); + playerObjects.Remove(player.Identity); + } + } + + void OnPlayerUpdate(EventContext ctx, Player oldPlayer, Player newPlayer) + { + if (playerObjects.TryGetValue(newPlayer.Identity, out var go)) + { + // Update position, etc. + go.transform.position = new Vector3(newPlayer.X, newPlayer.Y, newPlayer.Z); + } + } + + void SpawnPlayer(Player player) + { + var go = Instantiate(playerPrefab); + go.transform.position = new Vector3(player.X, player.Y, player.Z); + playerObjects[player.Identity] = go; + } +} +``` + +## Thread Safety + +The C# SDK is NOT thread-safe. Follow these rules: + +1. **Call `FrameTick()` from ONE thread only** (main thread recommended) + +2. **All callbacks run during `FrameTick()`** on the calling thread + +3. **Do NOT access `conn.Db` from other threads** while `FrameTick()` may be running + +4. **Background work**: Copy data out of callbacks, process on background threads + +```csharp +// Safe pattern for background processing +conn.Db.Player.OnInsert += (ctx, player) => { + // Copy the data + var playerData = new PlayerDTO { + Id = player.Id, + Name = player.Name + }; + + // Process on background thread + Task.Run(() => ProcessPlayerAsync(playerData)); +}; +``` + +## Error Handling + +### Connection Errors + +```csharp +.OnConnectError((err) => { + // Network errors, invalid module name, etc. + Debug.LogError($"Connect error: {err}"); +}) +``` + +### Subscription Errors + +```csharp +.OnError((ctx, err) => { + // Invalid SQL, schema changes, etc. + Debug.LogError($"Subscription error: {err}"); +}) +``` + +### Reducer Errors + +```csharp +conn.Reducers.OnMyReducer += (ctx, args) => { + if (ctx.Event.Status is Status.Failed(var reason)) + { + Debug.LogError($"Reducer failed: {reason}"); + } +}; + +// Catch-all for unhandled reducer errors +conn.OnUnhandledReducerError += (ctx, ex) => { + Debug.LogError($"Unhandled: {ex}"); +}; +``` + +## Complete Example + +```csharp +using System; +using SpacetimeDB; +using SpacetimeDB.Types; + +class Program +{ + static DbConnection? conn; + static bool running = true; + + static void Main() + { + conn = DbConnection.Builder() + .WithUri("http://localhost:3000") + .WithModuleName("chat") + .OnConnect(OnConnect) + .OnConnectError(err => Console.Error.WriteLine($"Failed: {err}")) + .OnDisconnect((c, err) => running = false) + .Build(); + + while (running) + { + conn.FrameTick(); + Thread.Sleep(16); + } + } + + static void OnConnect(DbConnection conn, Identity id, string token) + { + Console.WriteLine($"Connected as {id}"); + + // Set up callbacks + conn.Db.Message.OnInsert += (ctx, msg) => { + Console.WriteLine($"[{msg.Sender}]: {msg.Text}"); + }; + + conn.Reducers.OnSendMessage += (ctx, text) => { + if (ctx.Event.Status is Status.Failed(var reason)) + { + Console.Error.WriteLine($"Send failed: {reason}"); + } + }; + + // Subscribe + conn.SubscriptionBuilder() + .OnApplied(ctx => { + Console.WriteLine("Ready! Type messages:"); + StartInputLoop(ctx); + }) + .Subscribe(new[] { "SELECT * FROM message" }); + } + + static void StartInputLoop(SubscriptionEventContext ctx) + { + Task.Run(() => { + while (running) + { + var input = Console.ReadLine(); + if (!string.IsNullOrEmpty(input)) + { + ctx.Reducers.SendMessage(input); + } + } + }); + } +} +``` + +## Common Patterns + +### Optimistic Updates + +```csharp +// Show immediate feedback, correct on server response +void SendMessage(string text) +{ + // Optimistic: show immediately + AddMessageToUI(myIdentity, text, isPending: true); + + // Send to server + conn.Reducers.SendMessage(text); +} + +conn.Reducers.OnSendMessage += (ctx, text) => { + if (ctx.Event.CallerIdentity == conn.Identity) + { + if (ctx.Event.Status is Status.Committed) + { + // Confirm the pending message + ConfirmPendingMessage(text); + } + else + { + // Remove failed message + RemovePendingMessage(text); + } + } +}; +``` + +### Local Player Detection + +```csharp +conn.Db.Player.OnInsert += (ctx, player) => { + bool isLocalPlayer = player.Identity == ctx.Identity; + + if (isLocalPlayer) + { + // This is our player + SetupLocalPlayerController(player); + } + else + { + // Remote player + SpawnRemotePlayer(player); + } +}; +``` + +### Waiting for Specific Data + +```csharp +async Task WaitForPlayerAsync(Identity playerId) +{ + var tcs = new TaskCompletionSource(); + + void Handler(EventContext ctx, Player player) + { + if (player.Identity == playerId) + { + tcs.TrySetResult(player); + conn.Db.Player.OnInsert -= Handler; + } + } + + // Check if already exists + var existing = conn.Db.Player.Identity.Find(playerId); + if (existing != null) return existing; + + conn.Db.Player.OnInsert += Handler; + return await tcs.Task; +} +``` + +## Troubleshooting + +### Connection Issues + +- **"Connection refused"**: Check server is running at the specified URI +- **"Module not found"**: Verify module name matches published database +- **Timeout**: Check firewall/network, ensure `FrameTick()` is being called + +### No Callbacks Firing + +- **Check `FrameTick()`**: Must be called regularly +- **Check subscription**: Ensure `OnApplied` fired successfully +- **Check callback registration**: Register before subscribing + +### Data Not Appearing + +- **Check SQL syntax**: Invalid queries fail silently +- **Check table visibility**: Tables must be `Public = true` in the module +- **Check subscription scope**: Only subscribed rows are cached + +### Unity-Specific + +- **NullReferenceException in Update**: Guard with `conn?.FrameTick()` +- **Missing types**: Regenerate bindings after module changes +- **Assembly errors**: Ensure SpacetimeDB assemblies are in correct folder + +## References + +- [C# SDK Reference](https://spacetimedb.com/docs/sdks/c-sharp) +- [Unity Tutorial](https://spacetimedb.com/docs/unity/part-1) +- [SpacetimeDB SQL Reference](https://spacetimedb.com/docs/sql) +- [GitHub: Unity Demo (Blackholio)](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) diff --git a/.claude/skills/spacetimedb-rust/SKILL.md b/.claude/skills/spacetimedb-rust/SKILL.md new file mode 100644 index 00000000000..386dc4332b9 --- /dev/null +++ b/.claude/skills/spacetimedb-rust/SKILL.md @@ -0,0 +1,687 @@ +--- +name: spacetimedb-rust +description: Develop SpacetimeDB server modules in Rust. Use when writing reducers, tables, or module logic. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "1.0" +--- + +# SpacetimeDB Rust Module Development + +SpacetimeDB modules are WebAssembly applications that run inside the database. They define tables to store data and reducers to modify data. Clients connect directly to the database and execute application logic inside it. + +## Project Setup + +### Cargo.toml Requirements + +```toml +[package] +name = "my-module" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = "1.0" +log = "0.4" +``` + +The `crate-type = ["cdylib"]` is required for WebAssembly compilation. + +### Essential Imports + +```rust +use spacetimedb::{ReducerContext, Table}; +``` + +Additional imports as needed: +```rust +use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt}; +use spacetimedb::sats::{i256, u256}; // For 256-bit integers +``` + +## Table Definitions + +Tables store data in SpacetimeDB. Define tables using the `#[spacetimedb::table]` macro on a struct. + +### Basic Table + +```rust +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + #[auto_inc] + id: u64, + name: String, + score: u32, +} +``` + +### Table Attributes + +| Attribute | Description | +|-----------|-------------| +| `name = identifier` | Required. The table name used in `ctx.db.{name}()` | +| `public` | Makes table visible to clients via subscriptions | +| `scheduled(reducer_name)` | Creates a schedule table that triggers the named reducer | +| `index(name = idx, btree(columns = [a, b]))` | Creates a multi-column index | + +### Column Attributes + +| Attribute | Description | +|-----------|-------------| +| `#[primary_key]` | Unique identifier for the row (one per table max) | +| `#[unique]` | Enforces uniqueness, enables `find()` method | +| `#[auto_inc]` | Auto-generates unique integer values when inserting 0 | +| `#[index(btree)]` | Creates a B-tree index for efficient lookups | +| `#[default(value)]` | Default value for migrations (must be const-evaluable) | + +### Supported Column Types + +**Primitives**: `u8`, `u16`, `u32`, `u64`, `u128`, `u256`, `i8`, `i16`, `i32`, `i64`, `i128`, `i256`, `f32`, `f64`, `bool`, `String` + +**SpacetimeDB Types**: `Identity`, `ConnectionId`, `Timestamp`, `Uuid`, `ScheduleAt` + +**Collections**: `Vec`, `Option`, `Result` where inner types are also supported + +**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]` + +## Reducers + +Reducers are transactional functions that modify database state. They run inside the database and are the only way to mutate tables. + +### Basic Reducer + +```rust +#[spacetimedb::reducer] +pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> { + if name.is_empty() { + return Err("Name cannot be empty".to_string()); + } + + ctx.db.player().insert(Player { + id: 0, // auto_inc assigns the value + name, + score: 0, + }); + + Ok(()) +} +``` + +### Reducer Rules + +1. First parameter must be `&ReducerContext` +2. Additional parameters must implement `SpacetimeType` +3. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display` +4. All changes roll back on panic or `Err` return +5. Reducers run in isolation from concurrent reducers +6. Cannot make network requests or access filesystem +7. Must import `Table` trait for table operations: `use spacetimedb::Table;` + +## ReducerContext + +The `ReducerContext` provides access to the database and caller information. + +### Properties + +```rust +#[spacetimedb::reducer] +pub fn example(ctx: &ReducerContext) { + // Database access + let _table = ctx.db.player(); + + // Caller identity (always present) + let caller: Identity = ctx.sender; + + // Connection ID (None for scheduled/system reducers) + let conn: Option = ctx.connection_id; + + // Invocation timestamp + let when: Timestamp = ctx.timestamp; + + // Module's own identity + let module_id: Identity = ctx.identity(); + + // Random number generation (deterministic) + let random_val: u32 = ctx.random(); + + // UUID generation + let uuid = ctx.new_uuid_v4().unwrap(); // Random UUID + let uuid = ctx.new_uuid_v7().unwrap(); // Timestamp-based UUID + + // Check if caller is internal (scheduled reducer) + if ctx.sender_auth().is_internal() { + // Called by scheduler, not external client + } +} +``` + +## Table Operations + +### Insert + +```rust +// Insert returns the row with auto_inc values populated +let player = ctx.db.player().insert(Player { + id: 0, // auto_inc fills this + name: "Alice".to_string(), + score: 100, +}); +log::info!("Created player with id: {}", player.id); +``` + +### Find by Unique/Primary Key + +```rust +// find() returns Option +if let Some(player) = ctx.db.player().id().find(123) { + log::info!("Found: {}", player.name); +} +``` + +### Filter by Indexed Column + +```rust +// filter() returns an iterator +for player in ctx.db.player().name().filter("Alice") { + log::info!("Player {}: score {}", player.id, player.score); +} + +// Range queries (Rust range syntax) +for player in ctx.db.player().score().filter(50..=100) { + log::info!("{} has score {}", player.name, player.score); +} +``` + +### Update + +Updates require a unique column. Find the row, modify it, then call `update()`: + +```rust +if let Some(mut player) = ctx.db.player().id().find(123) { + player.score += 10; + ctx.db.player().id().update(player); +} +``` + +### Delete + +```rust +// Delete by unique key +ctx.db.player().id().delete(&123); + +// Delete by indexed column (returns count) +let deleted = ctx.db.player().name().delete("Alice"); +log::info!("Deleted {} rows", deleted); + +// Delete by range +ctx.db.player().score().delete(..50); // Delete all with score < 50 +``` + +### Iterate All Rows + +```rust +for player in ctx.db.player().iter() { + log::info!("{}: {}", player.name, player.score); +} + +// Count rows +let total = ctx.db.player().count(); +``` + +## Indexes + +### Single-Column Index + +```rust +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + id: u64, + #[index(btree)] + level: u32, + name: String, +} +``` + +### Multi-Column Index + +```rust +#[spacetimedb::table( + name = score, + public, + index(name = by_player_level, btree(columns = [player_id, level])) +)] +pub struct Score { + player_id: u32, + level: u32, + points: i64, +} +``` + +### Querying Multi-Column Indexes + +```rust +// Prefix match (first column only) +for score in ctx.db.score().by_player_level().filter(&123u32) { + log::info!("Level {}: {} points", score.level, score.points); +} + +// Full match +for score in ctx.db.score().by_player_level().filter((123u32, 5u32)) { + log::info!("Points: {}", score.points); +} + +// Prefix with range on second column +for score in ctx.db.score().by_player_level().filter((123u32, 1u32..=10u32)) { + log::info!("Level {}: {} points", score.level, score.points); +} +``` + +## Identity and Authentication + +### Storing User Identity + +```rust +#[spacetimedb::table(name = user_profile, public)] +pub struct UserProfile { + #[primary_key] + identity: Identity, + display_name: String, + created_at: Timestamp, +} + +#[spacetimedb::reducer] +pub fn create_profile(ctx: &ReducerContext, display_name: String) -> Result<(), String> { + // Check if profile already exists + if ctx.db.user_profile().identity().find(ctx.sender).is_some() { + return Err("Profile already exists".to_string()); + } + + ctx.db.user_profile().insert(UserProfile { + identity: ctx.sender, + display_name, + created_at: ctx.timestamp, + }); + + Ok(()) +} +``` + +### Verifying Caller Identity + +```rust +#[spacetimedb::reducer] +pub fn update_my_profile(ctx: &ReducerContext, new_name: String) -> Result<(), String> { + // Only allow users to update their own profile + if let Some(mut profile) = ctx.db.user_profile().identity().find(ctx.sender) { + profile.display_name = new_name; + ctx.db.user_profile().identity().update(profile); + Ok(()) + } else { + Err("Profile not found".to_string()) + } +} +``` + +## Lifecycle Reducers + +### Init Reducer + +Runs once when the module is first published or database is cleared: + +```rust +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Database initializing..."); + + // Set up default data + if ctx.db.config().count() == 0 { + ctx.db.config().insert(Config { + key: "version".to_string(), + value: "1.0.0".to_string(), + }); + } + + Ok(()) +} +``` + +### Client Connected + +Runs when a client establishes a connection: + +```rust +#[spacetimedb::reducer(client_connected)] +pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Client connected: {}", ctx.sender); + + // connection_id is guaranteed to be Some + let conn_id = ctx.connection_id.unwrap(); + + // Create or update user session + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: true, ..user }); + } else { + ctx.db.user().insert(User { + identity: ctx.sender, + online: true, + name: None, + }); + } + + Ok(()) +} +``` + +### Client Disconnected + +Runs when a client connection terminates: + +```rust +#[spacetimedb::reducer(client_disconnected)] +pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Client disconnected: {}", ctx.sender); + + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); + } + + Ok(()) +} +``` + +## Scheduled Reducers + +Schedule reducers to run at specific times or intervals. + +### Define a Schedule Table + +```rust +use spacetimedb::ScheduleAt; +use std::time::Duration; + +#[spacetimedb::table(name = game_tick_schedule, scheduled(game_tick))] +pub struct GameTickSchedule { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: ScheduleAt, +} + +#[spacetimedb::reducer] +fn game_tick(ctx: &ReducerContext, schedule: GameTickSchedule) { + // Verify this is an internal call (from scheduler) + if !ctx.sender_auth().is_internal() { + log::warn!("External call to scheduled reducer rejected"); + return; + } + + // Game logic here + log::info!("Game tick at {:?}", ctx.timestamp); +} +``` + +### Scheduling at Intervals + +```rust +#[spacetimedb::reducer] +fn start_game_loop(ctx: &ReducerContext) { + // Schedule game tick every 100ms + ctx.db.game_tick_schedule().insert(GameTickSchedule { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()), + }); +} +``` + +### Scheduling at Specific Times + +```rust +#[spacetimedb::reducer] +fn schedule_reminder(ctx: &ReducerContext, delay_secs: u64) { + let run_at = ctx.timestamp + Duration::from_secs(delay_secs); + + ctx.db.reminder_schedule().insert(ReminderSchedule { + scheduled_id: 0, + scheduled_at: ScheduleAt::Time(run_at), + message: "Time's up!".to_string(), + }); +} +``` + +## Error Handling + +### Sender Errors (Expected) + +Return errors for invalid client input: + +```rust +#[spacetimedb::reducer] +pub fn transfer_credits( + ctx: &ReducerContext, + to_user: Identity, + amount: u32, +) -> Result<(), String> { + let sender = ctx.db.user().identity().find(ctx.sender) + .ok_or("Sender not found")?; + + if sender.credits < amount { + return Err("Insufficient credits".to_string()); + } + + // Perform transfer... + Ok(()) +} +``` + +### Programmer Errors (Bugs) + +Use panic for unexpected states that indicate bugs: + +```rust +#[spacetimedb::reducer] +pub fn process_data(ctx: &ReducerContext, data: Vec) { + // This should never happen - indicates a bug + assert!(!data.is_empty(), "Unexpected empty data"); + + // Use expect for operations that should always succeed + let parsed = parse_data(&data).expect("Failed to parse data"); +} +``` + +## Custom Types + +Define custom types using `#[derive(SpacetimeType)]`: + +```rust +use spacetimedb::SpacetimeType; + +#[derive(SpacetimeType)] +pub enum PlayerStatus { + Active, + Idle, + Away, +} + +#[derive(SpacetimeType)] +pub struct Position { + x: f32, + y: f32, + z: f32, +} + +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + id: u64, + status: PlayerStatus, + position: Position, +} +``` + +## Multiple Tables from Same Type + +Apply multiple `#[spacetimedb::table]` attributes to create separate tables with the same schema: + +```rust +#[spacetimedb::table(name = online_player, public)] +#[spacetimedb::table(name = offline_player)] +pub struct Player { + #[primary_key] + identity: Identity, + name: String, +} + +#[spacetimedb::reducer] +fn player_logout(ctx: &ReducerContext) { + if let Some(player) = ctx.db.online_player().identity().find(ctx.sender) { + ctx.db.offline_player().insert(player.clone()); + ctx.db.online_player().identity().delete(&ctx.sender); + } +} +``` + +## Logging + +Use the `log` crate for debug output. View logs with `spacetime logs `: + +```rust +log::trace!("Detailed trace info"); +log::debug!("Debug information"); +log::info!("General information"); +log::warn!("Warning message"); +log::error!("Error occurred"); +``` + +Never use `println!`, `eprintln!`, or `dbg!` in modules. + +## Common Patterns + +### Player Session Management + +```rust +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + identity: Identity, + name: Option, + online: bool, + last_seen: Timestamp, +} + +#[spacetimedb::reducer(client_connected)] +pub fn on_connect(ctx: &ReducerContext) { + match ctx.db.player().identity().find(ctx.sender) { + Some(player) => { + ctx.db.player().identity().update(Player { + online: true, + last_seen: ctx.timestamp, + ..player + }); + } + None => { + ctx.db.player().insert(Player { + identity: ctx.sender, + name: None, + online: true, + last_seen: ctx.timestamp, + }); + } + } +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn on_disconnect(ctx: &ReducerContext) { + if let Some(player) = ctx.db.player().identity().find(ctx.sender) { + ctx.db.player().identity().update(Player { + online: false, + last_seen: ctx.timestamp, + ..player + }); + } +} +``` + +### Sequential ID Generation (Gap-Free) + +Auto-increment may have gaps after crashes. For strictly sequential IDs: + +```rust +#[spacetimedb::table(name = counter)] +pub struct Counter { + #[primary_key] + name: String, + value: u64, +} + +#[spacetimedb::reducer] +fn create_invoice(ctx: &ReducerContext, amount: u64) -> Result<(), String> { + let mut counter = ctx.db.counter().name().find(&"invoice".to_string()) + .unwrap_or(Counter { name: "invoice".to_string(), value: 0 }); + + counter.value += 1; + ctx.db.counter().name().update(counter.clone()); + + ctx.db.invoice().insert(Invoice { + invoice_number: counter.value, + amount, + }); + + Ok(()) +} +``` + +### Owner-Only Reducers + +```rust +#[spacetimedb::table(name = admin)] +pub struct Admin { + #[primary_key] + identity: Identity, +} + +#[spacetimedb::reducer] +fn admin_action(ctx: &ReducerContext) -> Result<(), String> { + if ctx.db.admin().identity().find(ctx.sender).is_none() { + return Err("Not authorized".to_string()); + } + + // Admin-only logic here + Ok(()) +} +``` + +## Build and Deploy + +```bash +# Build the module +spacetime build + +# Deploy to local instance +spacetime publish my_database + +# Deploy with database clear (DESTROYS DATA) +spacetime publish my_database --delete-data + +# View logs +spacetime logs my_database + +# Call a reducer +spacetime call my_database create_player "Alice" + +# Run SQL query +spacetime sql my_database "SELECT * FROM player" +``` + +## Important Constraints + +1. **No Global State**: Static/global variables are undefined behavior across reducer calls +2. **No Side Effects**: Reducers cannot make network requests or file I/O +3. **Deterministic Execution**: Use `ctx.random()` and `ctx.new_uuid_*()` for randomness +4. **Transactional**: All reducer changes roll back on failure +5. **Isolated**: Reducers don't see concurrent changes until commit diff --git a/.claude/skills/spacetimedb-typescript/SKILL.md b/.claude/skills/spacetimedb-typescript/SKILL.md new file mode 100644 index 00000000000..502434cf1e5 --- /dev/null +++ b/.claude/skills/spacetimedb-typescript/SKILL.md @@ -0,0 +1,663 @@ +--- +name: spacetimedb-typescript +description: Build TypeScript clients for SpacetimeDB. Use when connecting to SpacetimeDB from web apps, Node.js, Deno, Bun, or other JavaScript runtimes. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "1.0" +--- + +# SpacetimeDB TypeScript SDK + +Build real-time TypeScript clients that connect directly to SpacetimeDB modules. The SDK provides type-safe database access, automatic synchronization, and reactive updates for web apps, Node.js, Deno, Bun, and other JavaScript runtimes. + +## Installation + +```bash +npm install spacetimedb +# or +pnpm add spacetimedb +# or +yarn add spacetimedb +``` + +For Node.js 18-21, install the `undici` peer dependency: + +```bash +npm install spacetimedb undici +``` + +Node.js 22+ and browser environments work out of the box. + +## Generating Type Bindings + +Before using the SDK, generate TypeScript bindings from your SpacetimeDB module: + +```bash +spacetime generate --lang typescript --out-dir ./src/module_bindings --project-path ./server +``` + +This creates a `module_bindings` directory with: +- `index.ts` - Main exports including `DbConnection`, `tables`, `reducers`, `query` +- Type definitions for each table (e.g., `player_table.ts`, `user_table.ts`) +- Type definitions for each reducer (e.g., `create_player_reducer.ts`) +- Custom type definitions (e.g., `point_type.ts`) + +## Basic Connection Setup + +```typescript +import { DbConnection } from './module_bindings'; + +const connection = DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_database') + .onConnect((conn, identity, token) => { + console.log('Connected with identity:', identity.toHexString()); + + // Store token for reconnection + localStorage.setItem('spacetimedb_token', token); + + // Subscribe to tables after connection + conn.subscriptionBuilder().subscribe('SELECT * FROM player'); + }) + .onDisconnect((ctx) => { + console.log('Disconnected'); + }) + .onConnectError((ctx, error) => { + console.error('Connection error:', error); + }) + .build(); +``` + +## Connection Builder Options + +```typescript +DbConnection.builder() + // Required: SpacetimeDB server URI + .withUri('ws://localhost:3000') + + // Required: Database module name or address + .withModuleName('my_database') + + // Optional: Authentication token for reconnection + .withToken(localStorage.getItem('spacetimedb_token') ?? undefined) + + // Optional: Enable compression (default: 'gzip') + .withCompression('gzip') // or 'none' + + // Optional: Light mode reduces network traffic + .withLightMode(true) + + // Optional: Wait for durable writes before receiving updates + .withConfirmedReads(true) + + // Connection lifecycle callbacks + .onConnect((conn, identity, token) => { /* ... */ }) + .onDisconnect((ctx, error) => { /* ... */ }) + .onConnectError((ctx, error) => { /* ... */ }) + + .build(); +``` + +## Subscribing to Tables + +Subscriptions sync table data to the client cache. Use SQL queries to filter what data you receive. + +### Basic Subscription + +```typescript +connection.subscriptionBuilder() + .onApplied((ctx) => { + console.log('Subscription applied, cache is ready'); + }) + .onError((ctx, error) => { + console.error('Subscription error:', error); + }) + .subscribe('SELECT * FROM player'); +``` + +### Multiple Queries + +```typescript +connection.subscriptionBuilder() + .subscribe([ + 'SELECT * FROM player', + 'SELECT * FROM game_state', + 'SELECT * FROM message WHERE room_id = 1' + ]); +``` + +### Typed Query Builder + +Use the generated `query` object for type-safe queries: + +```typescript +import { query } from './module_bindings'; + +// Simple query - selects all rows +connection.subscriptionBuilder() + .subscribe(query.player.build()); + +// Query with WHERE clause +connection.subscriptionBuilder() + .subscribe( + query.player + .where(row => row.name.eq('Alice')) + .build() + ); + +// Complex conditions +connection.subscriptionBuilder() + .subscribe( + query.player + .where(row => row.score.gte(100)) + .where(row => row.isActive.eq(true)) + .build() + ); +``` + +### Subscribe to All Tables + +For development or small datasets: + +```typescript +connection.subscriptionBuilder().subscribeToAllTables(); +``` + +### Unsubscribing + +```typescript +const handle = connection.subscriptionBuilder() + .onApplied(() => console.log('Subscribed')) + .subscribe('SELECT * FROM player'); + +// Later, unsubscribe +handle.unsubscribe(); + +// Or with callback when complete +handle.unsubscribeThen((ctx) => { + console.log('Unsubscribed successfully'); +}); +``` + +## Accessing Table Data + +After subscription, access cached data through `connection.db`: + +```typescript +// Iterate all rows +for (const player of connection.db.player.iter()) { + console.log(player.name, player.score); +} + +// Convert to array +const players = Array.from(connection.db.player.iter()); + +// Count rows +const count = connection.db.player.count(); + +// Find by primary key (if table has one) +const player = connection.db.player.id.find(42); + +// Find by indexed column +const alice = connection.db.player.name.find('Alice'); +``` + +## Table Event Callbacks + +Listen for real-time changes to table data: + +```typescript +// Row inserted +connection.db.player.onInsert((ctx, player) => { + console.log('New player:', player.name); +}); + +// Row deleted +connection.db.player.onDelete((ctx, player) => { + console.log('Player left:', player.name); +}); + +// Row updated (requires primary key on table) +connection.db.player.onUpdate((ctx, oldPlayer, newPlayer) => { + console.log(`${oldPlayer.name} score: ${oldPlayer.score} -> ${newPlayer.score}`); +}); + +// Remove callbacks +const onInsertCb = (ctx, player) => console.log(player); +connection.db.player.onInsert(onInsertCb); +connection.db.player.removeOnInsert(onInsertCb); +``` + +### Event Context + +Callbacks receive an `EventContext` with information about the event: + +```typescript +connection.db.player.onInsert((ctx, player) => { + // Access to database + const allPlayers = Array.from(ctx.db.player.iter()); + + // Check event type + if (ctx.event.tag === 'Reducer') { + const { callerIdentity, reducer, status } = ctx.event.value; + console.log(`Triggered by reducer: ${reducer.name}`); + } + + // Call other reducers + ctx.reducers.sendMessage({ playerId: player.id, text: 'Welcome!' }); +}); +``` + +## Calling Reducers + +Reducers are server-side functions that modify the database: + +```typescript +// Call a reducer +connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } }); + +// Listen for reducer results +connection.reducers.onCreatePlayer((ctx, args) => { + const { callerIdentity, status, timestamp, energyConsumed } = ctx.event; + + if (status.tag === 'Committed') { + console.log('Player created successfully'); + } else if (status.tag === 'Failed') { + console.error('Failed:', status.value); + } +}); + +// Remove reducer callback +connection.reducers.removeOnCreatePlayer(callback); +``` + +### Reducer Flags + +Control how the server handles reducer calls: + +```typescript +// NoSuccessNotify: Don't send TransactionUpdate on success (reduces traffic) +connection.setReducerFlags.movePlayer('NoSuccessNotify'); + +// FullUpdate: Always send full TransactionUpdate (default) +connection.setReducerFlags.movePlayer('FullUpdate'); +``` + +## Identity and Authentication + +```typescript +import { Identity } from 'spacetimedb'; + +// Get current identity +const identity = connection.identity; +console.log(identity?.toHexString()); + +// Compare identities +if (identity?.isEqual(otherIdentity)) { + console.log('Same user'); +} + +// Create from hex string +const parsed = Identity.fromString('0x1234...'); + +// Zero identity +const zero = Identity.zero(); +``` + +### Persisting Authentication + +```typescript +// On connect, save the token +.onConnect((conn, identity, token) => { + localStorage.setItem('auth_token', token); + localStorage.setItem('identity', identity.toHexString()); +}) + +// On reconnect, use saved token +.withToken(localStorage.getItem('auth_token') ?? undefined) +``` + +## React Integration + +The SDK includes React hooks for reactive UI updates. + +### Provider Setup + +```tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; +import { DbConnection, query } from './module_bindings'; +import App from './App'; + +const connectionBuilder = DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_game') + .onConnect((conn, identity, token) => { + console.log('Connected:', identity.toHexString()); + conn.subscriptionBuilder().subscribe(query.player.build()); + }) + .onDisconnect(() => console.log('Disconnected')) + .onConnectError((ctx, err) => console.error('Error:', err)); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +); +``` + +### useSpacetimeDB Hook + +Access connection state: + +```tsx +import { useSpacetimeDB } from 'spacetimedb/react'; + +function ConnectionStatus() { + const { isActive, identity, token, connectionId, connectionError } = useSpacetimeDB(); + + if (connectionError) { + return
Error: {connectionError.message}
; + } + + if (!isActive) { + return
Connecting...
; + } + + return
Connected as {identity?.toHexString()}
; +} +``` + +### useTable Hook + +Subscribe to table data with reactive updates: + +```tsx +import { useTable, where, eq } from 'spacetimedb/react'; +import { tables } from './module_bindings'; + +function PlayerList() { + // All players + const [players, isLoading] = useTable(tables.player); + + if (isLoading) return
Loading...
; + + return ( +
    + {players.map(player => ( +
  • {player.name}: {player.score}
  • + ))} +
+ ); +} + +function FilteredPlayerList() { + // Filtered players with callbacks + const [activePlayers, isLoading] = useTable( + tables.player, + where(eq('isActive', true)), + { + onInsert: (player) => console.log('Player joined:', player.name), + onDelete: (player) => console.log('Player left:', player.name), + onUpdate: (oldPlayer, newPlayer) => { + console.log(`${oldPlayer.name} updated`); + }, + } + ); + + return ( +
    + {activePlayers.map(player => ( +
  • {player.name}
  • + ))} +
+ ); +} +``` + +### useReducer Hook + +Call reducers from components: + +```tsx +import { useReducer } from 'spacetimedb/react'; +import { reducers } from './module_bindings'; + +function CreatePlayerForm() { + const createPlayer = useReducer(reducers.createPlayer); + const [name, setName] = useState(''); + + const handleSubmit = (e) => { + e.preventDefault(); + createPlayer({ name, location: { x: 0, y: 0 } }); + setName(''); + }; + + return ( +
+ setName(e.target.value)} /> + +
+ ); +} +``` + +## Vue Integration + +The SDK includes Vue composables: + +```typescript +import { SpacetimeDBProvider, useSpacetimeDB, useTable, useReducer } from 'spacetimedb/vue'; +``` + +Usage is similar to React hooks. + +## Svelte Integration + +The SDK includes Svelte stores: + +```typescript +import { SpacetimeDBProvider, useSpacetimeDB, useTable, useReducer } from 'spacetimedb/svelte'; +``` + +## Server-Side Usage (Node.js, Deno, Bun) + +The SDK works in server-side JavaScript runtimes: + +```typescript +import { DbConnection } from './module_bindings'; + +async function main() { + const connection = DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_database') + .onConnect((conn, identity, token) => { + console.log('Connected:', identity.toHexString()); + + conn.subscriptionBuilder() + .onApplied(() => { + // Process data + for (const player of conn.db.player.iter()) { + console.log(player); + } + }) + .subscribe('SELECT * FROM player'); + }) + .build(); +} + +main(); +``` + +## Error Handling + +### Connection Errors + +```typescript +DbConnection.builder() + .onConnectError((ctx, error) => { + console.error('Failed to connect:', error.message); + + // Implement retry logic + setTimeout(() => { + // Rebuild connection + }, 5000); + }) + .build(); +``` + +### Subscription Errors + +```typescript +connection.subscriptionBuilder() + .onError((ctx, error) => { + console.error('Subscription failed:', error.message); + }) + .subscribe('SELECT * FROM player'); +``` + +### Reducer Errors + +```typescript +connection.reducers.onCreatePlayer((ctx, args) => { + const { status } = ctx.event; + + switch (status.tag) { + case 'Committed': + console.log('Success'); + break; + case 'Failed': + console.error('Reducer failed:', status.value); + break; + case 'OutOfEnergy': + console.error('Out of energy'); + break; + } +}); +``` + +## Disconnecting + +```typescript +// Gracefully disconnect +connection.disconnect(); +``` + +## Type Reference + +### Core Types + +```typescript +import { + Identity, // User identity (256-bit) + ConnectionId, // Connection identifier + Timestamp, // SpacetimeDB timestamp + TimeDuration, // Duration type + Uuid, // UUID type +} from 'spacetimedb'; +``` + +### Generated Types + +```typescript +// From your module_bindings +import { + DbConnection, // Connection class + DbConnectionBuilder, // Builder class + SubscriptionBuilder, // Subscription builder + SubscriptionHandle, // Subscription handle + EventContext, // Event callback context + ReducerEventContext, // Reducer callback context + ErrorContext, // Error callback context + tables, // Table accessors for useTable + reducers, // Reducer definitions for useReducer + query, // Typed query builder + + // Your custom types + Player, + Point, + // ... etc +} from './module_bindings'; +``` + +## Best Practices + +1. **Store auth tokens**: Save the token from `onConnect` for seamless reconnection. + +2. **Subscribe after connect**: Set up subscriptions in the `onConnect` callback. + +3. **Use typed queries**: Prefer the `query` builder over raw SQL strings for type safety. + +4. **Handle all connection states**: Implement `onConnect`, `onDisconnect`, and `onConnectError`. + +5. **Use light mode for high-frequency updates**: Enable `.withLightMode(true)` for games or real-time apps. + +6. **Unsubscribe when done**: Clean up subscriptions when components unmount or data is no longer needed. + +7. **Use primary keys**: Define primary keys on tables to enable `onUpdate` callbacks. + +## Common Patterns + +### Reconnection Logic + +```typescript +function createConnection(token?: string) { + return DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_database') + .withToken(token) + .onConnect((conn, identity, newToken) => { + localStorage.setItem('token', newToken); + setupSubscriptions(conn); + }) + .onDisconnect(() => { + // Reconnect after delay + setTimeout(() => { + createConnection(localStorage.getItem('token') ?? undefined); + }, 3000); + }) + .build(); +} +``` + +### Optimistic Updates + +```typescript +function PlayerScore({ player }) { + const updateScore = useReducer(reducers.updateScore); + const [optimisticScore, setOptimisticScore] = useState(player.score); + + const handleClick = () => { + setOptimisticScore(prev => prev + 1); + updateScore({ playerId: player.id, delta: 1 }); + }; + + // Sync with actual data + useEffect(() => { + setOptimisticScore(player.score); + }, [player.score]); + + return
Score: {optimisticScore}
; +} +``` + +### Filtering with Multiple Conditions + +```typescript +// Using query builder +query.player + .where(row => row.team.eq('red')) + .where(row => row.score.gte(100)) + .build(); + +// Using React hooks +const [redTeamHighScorers] = useTable( + tables.player, + where(eq('team', 'red')), // Additional filtering in client +); +const filtered = redTeamHighScorers.filter(p => p.score >= 100); +``` diff --git a/skills b/skills new file mode 120000 index 00000000000..ec22b3dafd3 --- /dev/null +++ b/skills @@ -0,0 +1 @@ +.claude/skills \ No newline at end of file From 5e18e2c446c348512b1d451bd6913d5ebbf6a263 Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:58:36 -0500 Subject: [PATCH 2/4] Enhance skills with hallucinated APIs and common mistakes Incorporate critical information from reference files: - Add "HALLUCINATED APIs" sections with wrong patterns to avoid - Add "Common Mistakes Tables" for server/client errors - Add hard requirements for each language - Add procedures (beta) sections for Rust/TypeScript - Add sum types/TaggedEnum section for C# - Add views and ctx.withTx() patterns for TypeScript - Add RLS patterns for Rust - Add feature implementation and debugging checklists for concepts --- .claude/skills/spacetimedb-concepts/SKILL.md | 113 ++- .claude/skills/spacetimedb-csharp/SKILL.md | 700 ++++++++++++++++-- .claude/skills/spacetimedb-rust/SKILL.md | 209 +++++- .../skills/spacetimedb-typescript/SKILL.md | 372 +++++++++- 4 files changed, 1288 insertions(+), 106 deletions(-) diff --git a/.claude/skills/spacetimedb-concepts/SKILL.md b/.claude/skills/spacetimedb-concepts/SKILL.md index 0c96056743d..a0e1997f1f6 100644 --- a/.claude/skills/spacetimedb-concepts/SKILL.md +++ b/.claude/skills/spacetimedb-concepts/SKILL.md @@ -4,13 +4,74 @@ description: Understand SpacetimeDB architecture and core concepts. Use when lea license: Apache-2.0 metadata: author: clockworklabs - version: "1.0" + version: "1.1" --- # SpacetimeDB Core Concepts SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely. +--- + +## Critical Rules (Read First) + +These five rules prevent the most common SpacetimeDB mistakes: + +1. **Reducers are transactional** — they do not return data to callers. Use subscriptions to read data. +2. **Reducers must be deterministic** — no filesystem, network, timers, or random. All state must come from tables. +3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries. +4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns. +5. **`ctx.sender` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender` for authorization. + +--- + +## Feature Implementation Checklist + +When implementing a feature that spans backend and client: + +1. **Backend:** Define table(s) to store the data +2. **Backend:** Define reducer(s) to mutate the data +3. **Client:** Subscribe to the table(s) +4. **Client:** Call the reducer(s) from UI — **do not skip this step** +5. **Client:** Render the data from the table(s) + +**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. + +--- + +## Debugging Checklist + +When things are not working: + +1. Is SpacetimeDB server running? (`spacetime start`) +2. Is the module published? (`spacetime publish`) +3. Are client bindings generated? (`spacetime generate`) +4. Check server logs for errors (`spacetime logs `) +5. **Is the reducer actually being called from the client?** + +--- + +## CLI Commands + +```bash +# Start local SpacetimeDB +spacetime start + +# Publish module +spacetime publish --project-path + +# Clear and republish +spacetime publish --clear-database -y --project-path + +# Generate client bindings +spacetime generate --lang --out-dir --project-path + +# View logs +spacetime logs +``` + +--- + ## What SpacetimeDB Is SpacetimeDB combines a database and application server into a single deployable unit. Clients connect directly to the database and execute application logic inside it. The system is optimized for real-time applications requiring maximum speed and minimum latency. @@ -123,6 +184,14 @@ Reducers are transactional functions that modify database state. They are the ON - **Isolated**: Cannot interact with the outside world (no network, no filesystem) - **Callable**: Clients invoke reducers as remote procedure calls +### Critical Reducer Rules + +1. **No global state**: Relying on static variables is undefined behavior +2. **No side effects**: Reducers cannot make network requests or access files +3. **Store state in tables**: All persistent state must be in tables +4. **No return data**: Reducers do not return data to callers — use subscriptions +5. **Must be deterministic**: No random, no timers, no external I/O + ### Defining Reducers **Rust:** @@ -152,16 +221,10 @@ public static void CreateUser(ReducerContext ctx, string name, string email) Every reducer receives a `ReducerContext` providing: - `ctx.db`: Access to all tables (read and write) -- `ctx.sender`: The Identity of the caller +- `ctx.sender`: The Identity of the caller (use this for authorization, never trust args) - `ctx.connection_id`: The connection ID of the caller - `ctx.timestamp`: The current timestamp -### Critical Rules - -1. **No global state**: Relying on static variables is undefined behavior -2. **No side effects**: Reducers cannot make network requests or access files -3. **Store state in tables**: All persistent state must be in tables - ## Subscriptions Subscriptions replicate database rows to clients in real-time. When you subscribe to a query, SpacetimeDB sends matching rows immediately and pushes updates whenever those rows change. @@ -241,6 +304,7 @@ Identity is SpacetimeDB's authentication system based on OpenID Connect (OIDC). pub fn do_something(ctx: &ReducerContext) { let caller_identity = ctx.sender; // Who is calling this reducer? // Use identity for authorization checks + // NEVER trust identity passed as a reducer argument } ``` @@ -336,7 +400,7 @@ Choose SpacetimeDB when you need: - **Batch analytics**: SpacetimeDB is optimized for OLTP, not OLAP - **Large blob storage**: Better suited for structured relational data -- **Stateless APIs**: Traditional REST APIs don't need real-time sync +- **Stateless APIs**: Traditional REST APIs do not need real-time sync ## Comparison to Traditional Architectures @@ -399,26 +463,7 @@ Key differences: - No blockchain or cryptocurrency involved - Designed for real-time, not eventual consistency -## Quick Reference - -### CLI Commands - -```bash -# Install SpacetimeDB -curl -sSf https://install.spacetimedb.com | sh - -# Start local server -spacetime start - -# Create and publish module -spacetime init my-module --lang rust -spacetime publish my-module - -# Generate client bindings -spacetime generate --out-dir ./client/bindings -``` - -### Common Patterns +## Common Patterns **Authentication check in reducer:** ```rust @@ -460,3 +505,13 @@ fn send_reminder(ctx: &ReducerContext, reminder: Reminder) { log::info!("Reminder: {}", reminder.message); } ``` + +--- + +## Editing Behavior + +When modifying SpacetimeDB code: + +- Make the smallest change necessary +- Do NOT touch unrelated files, configs, or dependencies +- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo diff --git a/.claude/skills/spacetimedb-csharp/SKILL.md b/.claude/skills/spacetimedb-csharp/SKILL.md index b6bd3e9af8d..099dc0db446 100644 --- a/.claude/skills/spacetimedb-csharp/SKILL.md +++ b/.claude/skills/spacetimedb-csharp/SKILL.md @@ -1,17 +1,587 @@ --- name: spacetimedb-csharp -description: Build C# and Unity clients for SpacetimeDB. Use when integrating SpacetimeDB with Unity games or .NET applications. +description: Build C# modules and Unity clients for SpacetimeDB. Covers server-side module development and client SDK integration. license: Apache-2.0 metadata: author: clockworklabs - version: "1.0" + version: "1.1" + tested_with: "SpacetimeDB runtime 1.11.x, .NET 8 SDK" --- -# SpacetimeDB C# / Unity SDK +# SpacetimeDB C# SDK -This skill provides comprehensive guidance for building C# and Unity clients that connect to SpacetimeDB modules. +This skill provides comprehensive guidance for building C# server-side modules and Unity/C# clients that connect to SpacetimeDB. -## Overview +--- + +## HALLUCINATED APIs — DO NOT USE + +**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** + +```csharp +// WRONG — these do not exist +[SpacetimeDB.Procedure] // C# does NOT support procedures yet! +ctx.db.tableName // Wrong casing, should be PascalCase +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 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(Name = "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(Name = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index! +[Index.BTree(Name = "idx", Columns = ["Col"])] // Collection expressions don't work in attributes! +``` + +### CORRECT PATTERNS + +```csharp +// CORRECT IMPORTS +using SpacetimeDB; + +// CORRECT TABLE — must be partial struct +[SpacetimeDB.Table(Name = "player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + + 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); +var player = ctx.Db.Player.OwnerId.Find(ctx.Sender); + +// CORRECT SUM TYPE — partial record with named tuple elements +[SpacetimeDB.Type] +public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } +``` + +### DO NOT + +- **Forget `partial` keyword** — required on all tables and Module class +- **Use lowercase table access** — `ctx.Db.Player` not `ctx.Db.player` +- **Try to use procedures** — C# does not support procedures yet +- **Use `Optional`** — use C# nullable syntax `T?` instead +- **Use struct for sum types** — must be `partial record` + +--- + +## Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| Missing `partial` keyword | `public partial struct Table` | Generated code won't compile | +| `ctx.Db.player` (lowercase) | `ctx.Db.Player` (PascalCase) | Property not found | +| `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 : TaggedEnum` | `partial record : TaggedEnum` | Sum types must be record | +| `TaggedEnum<(A, B)>` | `TaggedEnum<(A A, B B)>` | Tuple must include variant names | + +### Client-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| Wrong namespace | `using SpacetimeDB.ClientApi;` | Types not found | +| Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire | +| Accessing `conn.Db` from background thread | Copy data in callback, process elsewhere | Data races | + +--- + +## Hard Requirements + +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. **Sum types must be `partial record`** — not struct or class +12. **Fully qualify Index attribute** — `[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity + +--- + +## Server-Side Module Development + +### Table Definition (CRITICAL) + +**Tables MUST use `partial struct` or `partial class` for code generation.** + +```csharp +using SpacetimeDB; + +// WRONG — missing partial! +[SpacetimeDB.Table(Name = "player")] +public struct Player { } // Will not generate properly! + +// RIGHT — with partial keyword +[SpacetimeDB.Table(Name = "player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + + public Identity OwnerId; + public string Name; + public Timestamp CreatedAt; +} + +// With indexes +[SpacetimeDB.Table(Name = "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 +[SpacetimeDB.Table(Name = "score", Public = true)] +[SpacetimeDB.Index.BTree(Name = "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 +``` + +### 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) + { + var task = ctx.Db.Task.Id.Find(taskId); + if (task is null) + { + throw new Exception("Task not found"); + } + + if (task.Value.OwnerId != ctx.Sender) + { + throw new Exception("Not authorized"); + } + + ctx.Db.Task.Id.Update(task.Value 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) + { + // Called once when module is first published + Log.Info("Module initialized"); + } + + [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] + public static void OnConnect(ReducerContext ctx) + { + // ctx.Sender is the connecting client + Log.Info($"Client connected: {ctx.Sender}"); + } + + [SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)] + public static void OnDisconnect(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) +``` + +### Database Access + +#### Naming Convention + +- **Tables**: Use PascalCase singular names in the `Name` attribute + - `[Table(Name = "User")]` → `ctx.Db.User` + - `[Table(Name = "PlayerStats")]` → `ctx.Db.PlayerStats` +- **Indexes**: PascalCase, match field name + - Field `OwnerId` with `[Index.BTree]` → `ctx.Db.User.OwnerId` + +#### Primary Key Operations + +```csharp +// Find by primary key — returns nullable +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 +} +``` + +### 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(Name = "player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + public Identity Id; + + public string Name; + public PlayerStats Stats; +} +``` + +### Sum Types / Tagged Enums (CRITICAL) + +**Sum types MUST use `partial record` 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!) 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(Name = "drawings", 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 }); +``` + +#### COMMON SUM TYPE MISTAKES + +| Wrong | Right | Why | +|-------|-------|-----| +| `partial struct Shape : TaggedEnum<...>` | `partial record Shape : TaggedEnum<...>` | Must be `record`, not `struct` | +| `TaggedEnum<(Circle, Rectangle)>` | `TaggedEnum<(Circle Circle, Rectangle Rectangle)>` | Tuple must have names | +| `new Shape { ... }` | `new Shape.Circle(new Circle { ... })` | Use nested variant constructor | + +### Scheduled Tables + +```csharp +using SpacetimeDB; + +[SpacetimeDB.Table(Name = "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 +{ + // 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 = ScheduleAt.Time(futureTime) + }); + } + + [SpacetimeDB.Reducer] + public static void CancelReminder(ReducerContext ctx, ulong reminderId) + { + ctx.Db.Reminder.Id.Delete(reminderId); + } +} +``` + +### Logging + +```csharp +using SpacetimeDB; + +Log.Debug("Debug message"); +Log.Info("Information"); +Log.Warn("Warning"); +Log.Error("Error occurred"); +Log.Panic("Critical failure"); // Terminates execution +``` + +### Data Visibility + +**`Public = true` exposes ALL rows to ALL clients.** + +| Scenario | Pattern | +|----------|---------| +| Everyone sees all rows | `[Table(Name = "x", Public = true)]` | +| Server-only data | `[Table(Name = "x")]` (private by default) | + +### 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 +``` + +### Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --project-path + +# Clear database and republish +spacetime publish --clear-database -y --project-path + +# Generate bindings +spacetime generate --lang csharp --out-dir /SpacetimeDB --project-path + +# View logs +spacetime logs +``` + +--- + +## Client-Side SDK + +### Overview The SpacetimeDB C# SDK enables .NET applications and Unity games to: - Connect to SpacetimeDB databases over WebSocket @@ -22,9 +592,9 @@ The SpacetimeDB C# SDK enables .NET applications and Unity games to: **Critical Requirement**: The C# SDK requires manual connection advancement. You must call `FrameTick()` regularly to process messages. -## Installation +### Installation -### .NET Console/Library Applications +#### .NET Console/Library Applications Add the NuGet package: @@ -32,7 +602,7 @@ Add the NuGet package: dotnet add package SpacetimeDB.ClientSDK ``` -### Unity Applications +#### Unity Applications Add via Unity Package Manager using the git URL: @@ -46,7 +616,7 @@ Steps: 3. Select "Add package from git URL" 4. Paste the URL above and click Add -## Generate Module Bindings +### Generate Module Bindings Before using the SDK, generate type-safe bindings from your module: @@ -61,9 +631,9 @@ This creates: - `Reducers/*.g.cs` - Reducer invocation methods - `Types/*.g.cs` - Row types and custom types from the module -## Connection Setup +### Connection Setup -### Basic Connection Pattern +#### Basic Connection Pattern ```csharp using SpacetimeDB; @@ -93,7 +663,7 @@ void OnConnected(DbConnection conn, Identity identity, string authToken) } ``` -### Connection Builder Methods +#### Connection Builder Methods | Method | Description | |--------|-------------| @@ -106,11 +676,11 @@ void OnConnected(DbConnection conn, Identity identity, string authToken) | `OnDisconnect(callback)` | Called when disconnected | | `Build()` | Create and open the connection | -## Critical: Advancing the Connection +### Critical: Advancing the Connection **The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly. -### Console Application Loop +#### Console Application Loop ```csharp while (true) @@ -120,7 +690,7 @@ while (true) } ``` -### Unity MonoBehaviour Pattern +#### Unity MonoBehaviour Pattern ```csharp public class SpacetimeManager : MonoBehaviour @@ -136,9 +706,9 @@ public class SpacetimeManager : MonoBehaviour **Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races with main thread access. -## Subscribing to Tables +### Subscribing to Tables -### Using SQL Queries +#### Using SQL Queries ```csharp void OnConnected(DbConnection conn, Identity identity, string authToken) @@ -161,7 +731,7 @@ void OnSubscriptionApplied(SubscriptionEventContext ctx) } ``` -### Using Typed Query Builder +#### Using Typed Query Builder ```csharp conn.SubscriptionBuilder() @@ -172,7 +742,7 @@ conn.SubscriptionBuilder() .Subscribe(); ``` -### Subscribe to All Tables (Development Only) +#### Subscribe to All Tables (Development Only) ```csharp conn.SubscriptionBuilder() @@ -182,7 +752,7 @@ conn.SubscriptionBuilder() **Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection. -### Subscription Handle +#### Subscription Handle ```csharp SubscriptionHandle handle = conn.SubscriptionBuilder() @@ -199,11 +769,11 @@ bool isActive = handle.IsActive; bool isEnded = handle.IsEnded; ``` -## Accessing the Client Cache +### Accessing the Client Cache Subscribed data is stored in `conn.Db` (or `ctx.Db` in callbacks). -### Iterating All Rows +#### Iterating All Rows ```csharp foreach (var player in ctx.Db.Player.Iter()) @@ -212,13 +782,13 @@ foreach (var player in ctx.Db.Player.Iter()) } ``` -### Count Rows +#### Count Rows ```csharp int playerCount = ctx.Db.Player.Count; ``` -### Find by Unique/Primary Key +#### Find by Unique/Primary Key For columns marked `[Unique]` or `[PrimaryKey]` on the server: @@ -233,7 +803,7 @@ if (player != null) } ``` -### Filter by BTree Index +#### Filter by BTree Index For columns with `[Index.BTree]` on the server: @@ -244,18 +814,18 @@ IEnumerable levelOnePlayers = ctx.Db.Player.Level.Filter(1); int count = levelOnePlayers.Count(); ``` -### Remote Query (Ad-hoc SQL) +#### Remote Query (Ad-hoc SQL) ```csharp var result = ctx.Db.Player.RemoteQuery("WHERE level > 10"); Player[] highLevelPlayers = result.Result; ``` -## Row Event Callbacks +### Row Event Callbacks Register callbacks to react to table changes: -### OnInsert +#### OnInsert ```csharp ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { @@ -263,7 +833,7 @@ ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { }; ``` -### OnDelete +#### OnDelete ```csharp ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => { @@ -271,7 +841,7 @@ ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => { }; ``` -### OnUpdate +#### OnUpdate Fires when a row with a primary key is replaced: @@ -281,7 +851,7 @@ ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => { }; ``` -### Checking Event Source +#### Checking Event Source ```csharp ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { @@ -298,11 +868,11 @@ ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { }; ``` -## Calling Reducers +### Calling Reducers Reducers are server-side functions that modify the database. -### Invoke a Reducer +#### Invoke a Reducer ```csharp // Reducers are methods on ctx.Reducers or conn.Reducers @@ -311,7 +881,7 @@ ctx.Reducers.CreatePlayer("NewPlayer"); ctx.Reducers.UpdateScore(playerId, 100); ``` -### Reducer Callbacks +#### Reducer Callbacks React when a reducer completes (success or failure): @@ -328,7 +898,7 @@ conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => { }; ``` -### Unhandled Reducer Errors +#### Unhandled Reducer Errors Catch reducer errors without specific handlers: @@ -338,7 +908,7 @@ conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => { }; ``` -### Reducer Event Properties +#### Reducer Event Properties ```csharp conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => { @@ -352,9 +922,9 @@ conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => { }; ``` -## Identity and Authentication +### Identity and Authentication -### Getting Current Identity +#### Getting Current Identity ```csharp // In OnConnect callback @@ -370,7 +940,7 @@ Identity? myIdentity = ctx.Identity; ConnectionId myConnectionId = ctx.ConnectionId; ``` -### Reconnecting with Token +#### Reconnecting with Token ```csharp string savedToken = PlayerPrefs.GetString("SpacetimeToken", null); @@ -383,15 +953,15 @@ DbConnection.Builder() .Build(); ``` -### Anonymous Connection +#### Anonymous Connection Pass `null` to `WithToken` or omit it entirely for a new anonymous identity. -## BSATN Serialization +### BSATN Serialization SpacetimeDB uses BSATN (Binary SpacetimeDB Algebraic Type Notation) for serialization. The SDK handles this automatically for generated types. -### Supported Types +#### Supported Types | C# Type | SpacetimeDB Type | |---------|------------------| @@ -411,7 +981,7 @@ SpacetimeDB uses BSATN (Binary SpacetimeDB Algebraic Type Notation) for serializ | `Timestamp` | Timestamp | | `Uuid` | Uuid | -### Custom Types +#### Custom Types Types marked with `[SpacetimeDB.Type]` on the server are generated as C# types: @@ -435,7 +1005,7 @@ public partial struct Vector3 : IEquatable } ``` -### TaggedEnum (Sum Types) +#### TaggedEnum (Sum Types) on Client ```csharp // Server @@ -458,7 +1028,7 @@ switch (gameEvent) } ``` -### Result Type +#### Result Type ```csharp // Result for success/error handling @@ -474,15 +1044,15 @@ else if (result is Result.Err(var error)) } ``` -## Unity Integration +### Unity Integration -### Project Setup +#### Project Setup 1. Add the SpacetimeDB package via Package Manager 2. Generate bindings and add to your Unity project 3. Create a manager MonoBehaviour -### SpacetimeManager Pattern +#### SpacetimeManager Pattern ```csharp using UnityEngine; @@ -572,7 +1142,7 @@ public class SpacetimeManager : MonoBehaviour } ``` -### Unity-Specific Considerations +#### Unity-Specific Considerations 1. **Main Thread Only**: All SpacetimeDB callbacks run on the main thread (during `FrameTick()`) @@ -582,7 +1152,7 @@ public class SpacetimeManager : MonoBehaviour 4. **PlayerPrefs**: Use for token persistence (or use a more secure method for production) -### Spawning GameObjects from Table Data +#### Spawning GameObjects from Table Data ```csharp public class PlayerSpawner : MonoBehaviour @@ -640,7 +1210,7 @@ public class PlayerSpawner : MonoBehaviour } ``` -## Thread Safety +### Thread Safety The C# SDK is NOT thread-safe. Follow these rules: @@ -666,9 +1236,9 @@ conn.Db.Player.OnInsert += (ctx, player) => { }; ``` -## Error Handling +### Error Handling -### Connection Errors +#### Connection Errors ```csharp .OnConnectError((err) => { @@ -677,7 +1247,7 @@ conn.Db.Player.OnInsert += (ctx, player) => { }) ``` -### Subscription Errors +#### Subscription Errors ```csharp .OnError((ctx, err) => { @@ -686,7 +1256,7 @@ conn.Db.Player.OnInsert += (ctx, player) => { }) ``` -### Reducer Errors +#### Reducer Errors ```csharp conn.Reducers.OnMyReducer += (ctx, args) => { @@ -702,7 +1272,7 @@ conn.OnUnhandledReducerError += (ctx, ex) => { }; ``` -## Complete Example +### Complete Console Example ```csharp using System; @@ -772,9 +1342,9 @@ class Program } ``` -## Common Patterns +### Common Patterns -### Optimistic Updates +#### Optimistic Updates ```csharp // Show immediate feedback, correct on server response @@ -804,7 +1374,7 @@ conn.Reducers.OnSendMessage += (ctx, text) => { }; ``` -### Local Player Detection +#### Local Player Detection ```csharp conn.Db.Player.OnInsert += (ctx, player) => { @@ -823,7 +1393,7 @@ conn.Db.Player.OnInsert += (ctx, player) => { }; ``` -### Waiting for Specific Data +#### Waiting for Specific Data ```csharp async Task WaitForPlayerAsync(Identity playerId) @@ -848,6 +1418,8 @@ async Task WaitForPlayerAsync(Identity playerId) } ``` +--- + ## Troubleshooting ### Connection Issues @@ -874,6 +1446,14 @@ async Task WaitForPlayerAsync(Identity playerId) - **Missing types**: Regenerate bindings after module changes - **Assembly errors**: Ensure SpacetimeDB assemblies are in correct folder +### Build Issues + +- **WASI compilation fails**: Ensure .NET 8 SDK (not 9+), install WASI workload +- **Publish fails silently**: Ensure project is named `StdbModule.csproj` +- **Generated code errors**: Ensure all tables/types have `partial` keyword + +--- + ## References - [C# SDK Reference](https://spacetimedb.com/docs/sdks/c-sharp) diff --git a/.claude/skills/spacetimedb-rust/SKILL.md b/.claude/skills/spacetimedb-rust/SKILL.md index 386dc4332b9..0a5b149911e 100644 --- a/.claude/skills/spacetimedb-rust/SKILL.md +++ b/.claude/skills/spacetimedb-rust/SKILL.md @@ -4,13 +4,112 @@ description: Develop SpacetimeDB server modules in Rust. Use when writing reduce license: Apache-2.0 metadata: author: clockworklabs - version: "1.0" + version: "1.1" --- # SpacetimeDB Rust Module Development SpacetimeDB modules are WebAssembly applications that run inside the database. They define tables to store data and reducers to modify data. Clients connect directly to the database and execute application logic inside it. +> **Tested with:** SpacetimeDB runtime 1.11.x, `spacetimedb` crate 1.1.x + +--- + +## HALLUCINATED APIs — DO NOT USE + +**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** + +```rust +// WRONG — these macros/attributes don't exist +#[spacetimedb::table] // Use #[table] after importing +#[spacetimedb::reducer] // Use #[reducer] after importing +#[derive(Table)] // Tables use #[table] attribute, not derive +#[derive(Reducer)] // Reducers use #[reducer] attribute + +// WRONG — SpacetimeType on tables +#[derive(SpacetimeType)] // DO NOT use on #[table] structs! +#[table(name = my_table)] +pub struct MyTable { ... } + +// WRONG — mutable context +pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext + +// WRONG — table access without parentheses +ctx.db.player // Should be ctx.db.player() +ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id) +``` + +### CORRECT PATTERNS: + +```rust +// CORRECT IMPORTS +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +use spacetimedb::SpacetimeType; // Only for custom types, NOT tables + +// CORRECT TABLE — no SpacetimeType derive! +#[table(name = player, public)] +pub struct Player { + #[primary_key] + pub id: u64, + pub name: String, +} + +// CORRECT REDUCER — immutable context reference +#[reducer] +pub fn create_player(ctx: &ReducerContext, name: String) { + ctx.db.player().insert(Player { id: 0, name }); +} + +// CORRECT TABLE ACCESS — methods with parentheses +let player = ctx.db.player().id().find(&player_id); +``` + +### DO NOT: +- **Derive `SpacetimeType` on `#[table]` structs** — the macro handles this +- **Use mutable context** — `&ReducerContext`, not `&mut ReducerContext` +- **Forget `Table` trait import** — required for table operations +- **Use field access for tables** — `ctx.db.player()` not `ctx.db.player` + +--- + +## Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros | +| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" | +| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index | +| `&mut ReducerContext` | `&ReducerContext` | Wrong context type | +| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" | +| `#[table(name = "my_table")]` | `#[table(name = my_table)]` | String literals not allowed | +| Missing `public` on table | Add `public` flag | Clients can't subscribe | +| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path | +| Network/filesystem in reducer | Use procedures instead | Sandbox violation | +| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed | + +### Client-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| Wrong crate name | `spacetimedb-sdk` | Dependency not found | +| Manual event loop | Use `tokio` runtime | Async issues | + +--- + +## Hard Requirements + +1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this +2. **Import `Table` trait** — required for all table operations +3. **Use `&ReducerContext`** — not `&mut ReducerContext` +4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table` +5. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG +6. **Use `ctx.random()` or `ctx.rng`** — not `rand` crate for random numbers +7. **Add `public` flag** — if clients need to subscribe to a table + +--- + ## Project Setup ### Cargo.toml Requirements @@ -89,6 +188,58 @@ pub struct Player { **Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]` +### Insert Returns the Row + +```rust +// Insert and get the auto-generated ID +let row = ctx.db.task().insert(Task { + id: 0, // Placeholder for auto_inc + owner_id: ctx.sender, + title: "New task".to_string(), + created_at: ctx.timestamp, +}); +let new_id = row.id; // Get the actual ID +``` + +--- + +## Data Visibility and Row-Level Security + +**`public` flag exposes ALL rows to ALL clients.** + +| Scenario | Pattern | +|----------|---------| +| Everyone sees all rows | `#[table(name = x, public)]` | +| Users see only their data | Private table + row-level security | + +### Private Table (default) + +```rust +// No public flag — only server can read +#[table(name = secret_data)] +pub struct SecretData { ... } +``` + +### Row-Level Security (RLS) + +Use RLS to filter which rows each client can see: + +```rust +// Use row-level security for per-user visibility +#[table(name = player_data, public)] +#[rls(filter = |ctx, row| row.owner_id == ctx.sender)] +pub struct PlayerData { + #[primary_key] + pub id: u64, + pub owner_id: Identity, + pub data: String, +} +``` + +With RLS, clients can subscribe to the table but only see rows where the filter returns `true` for their identity. + +--- + ## Reducers Reducers are transactional functions that modify database state. They run inside the database and are the only way to mutate tables. @@ -454,6 +605,59 @@ fn schedule_reminder(ctx: &ReducerContext, delay_secs: u64) { } ``` +--- + +## Procedures (Beta) + +**Procedures are for side effects (HTTP, filesystem) that reducers can't do.** + +Procedures are currently unstable. Enable with: + +```toml +# Cargo.toml +[dependencies] +spacetimedb = { version = "1.*", features = ["unstable"] } +``` + +```rust +use spacetimedb::{procedure, ProcedureContext}; + +// Simple procedure +#[procedure] +fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 { + a as u64 + b as u64 +} + +// Procedure with database access +#[procedure] +fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { + // HTTP request (allowed in procedures, not reducers) + let data = fetch_from_url(&url)?; + + // Database access requires explicit transaction + ctx.try_with_tx(|tx| { + tx.db.external_data().insert(ExternalData { + id: 0, + content: data, + }); + Ok(()) + })?; + + Ok(()) +} +``` + +### Key Differences from Reducers + +| Reducers | Procedures | +|----------|------------| +| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | +| Direct `ctx.db` access | Must use `ctx.with_tx()` | +| No HTTP/network | HTTP allowed | +| No return values | Can return data | + +--- + ## Error Handling ### Sender Errors (Expected) @@ -676,6 +880,9 @@ spacetime call my_database create_player "Alice" # Run SQL query spacetime sql my_database "SELECT * FROM player" + +# Generate bindings +spacetime generate --lang rust --out-dir /src/module_bindings --project-path ``` ## Important Constraints diff --git a/.claude/skills/spacetimedb-typescript/SKILL.md b/.claude/skills/spacetimedb-typescript/SKILL.md index 502434cf1e5..8a52b871182 100644 --- a/.claude/skills/spacetimedb-typescript/SKILL.md +++ b/.claude/skills/spacetimedb-typescript/SKILL.md @@ -11,6 +11,94 @@ metadata: Build real-time TypeScript clients that connect directly to SpacetimeDB modules. The SDK provides type-safe database access, automatic synchronization, and reactive updates for web apps, Node.js, Deno, Bun, and other JavaScript runtimes. +--- + +## HALLUCINATED APIs — DO NOT USE + +**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** + +```typescript +// WRONG PACKAGE — does not exist +import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk"; + +// WRONG — these methods don't exist +SpacetimeDBClient.connect(...); +SpacetimeDBClient.call("reducer_name", [...]); +connection.call("reducer_name", [arg1, arg2]); + +// WRONG — positional reducer arguments +conn.reducers.doSomething("value"); // WRONG! +``` + +### CORRECT PATTERNS: + +```typescript +// CORRECT IMPORTS +import { DbConnection, tables } from './module_bindings'; // Generated! +import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react'; + +// CORRECT REDUCER CALLS — object syntax, not positional! +conn.reducers.doSomething({ value: 'test' }); +conn.reducers.updateItem({ itemId: 1n, newValue: 42 }); + +// CORRECT DATA ACCESS — useTable returns [rows, isLoading] +const [items, isLoading] = useTable(tables.item); +``` + +### DO NOT: +- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)` +- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings` + +--- + +## Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| Missing `package.json` | Create `package.json` | "could not detect language" | +| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" | +| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle | +| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error | +| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error | +| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" | +| `.filter()` on unique column | `.find()` on unique column | TypeError | +| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" | +| `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID | +| `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" | +| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" | +| Multi-column index `.filter()` | Use single-column index | PANIC or silent empty results | +| `.iter()` in views | Use index lookups only | Views can't scan tables | +| `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions | +| `ctx.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable | + +### Client-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath | +| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax | +| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render | +| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring | +| Optimistic UI updates | Let subscriptions drive state | Desync issues | +| `` | `connectionBuilder={...}` | Wrong prop name | + +--- + +## Hard Requirements + +1. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +2. **Reducers are transactional** — they do not return data +3. **Reducers must be deterministic** — no filesystem, network, timers, random +4. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args +5. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb` +6. **useTable returns a tuple** — `const [rows, isLoading] = useTable(tables.myTable)` +7. **Memoize connectionBuilder** — wrap in `useMemo(() => ..., [])` to prevent reconnects +8. **Views can only use index lookups** — `.iter()` is not allowed in views + +--- + ## Installation ```bash @@ -251,12 +339,15 @@ connection.db.player.onInsert((ctx, player) => { ## Calling Reducers -Reducers are server-side functions that modify the database: +Reducers are server-side functions that modify the database. **CRITICAL: Use object syntax, not positional arguments.** ```typescript -// Call a reducer +// CORRECT: Object syntax connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } }); +// WRONG: Positional arguments +// connection.reducers.createPlayer('Alice', { x: 0, y: 0 }); // DO NOT DO THIS + // Listen for reducer results connection.reducers.onCreatePlayer((ctx, args) => { const { callerIdentity, status, timestamp, energyConsumed } = ctx.event; @@ -272,6 +363,10 @@ connection.reducers.onCreatePlayer((ctx, args) => { connection.reducers.removeOnCreatePlayer(callback); ``` +### Snake_case to camelCase conversion +- Server: `spacetimedb.reducer('do_something', ...)` +- Client: `conn.reducers.doSomething({ ... })` + ### Reducer Flags Control how the server handles reducer calls: @@ -284,6 +379,109 @@ connection.setReducerFlags.movePlayer('NoSuccessNotify'); connection.setReducerFlags.movePlayer('FullUpdate'); ``` +## Views + +Views provide filtered access to private table data based on the connected user. + +### ViewContext vs AnonymousViewContext + +```typescript +// ViewContext — has ctx.sender, result varies per user (computed per-subscriber) +spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => { + return [...ctx.db.item.by_owner.filter(ctx.sender)]; +}); + +// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf) +spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => { + return [...ctx.db.player.by_score.filter(/* top scores */)]; +}); +``` + +### CRITICAL: Views can only use index lookups + +```typescript +// WRONG — views cannot use .iter() +spacetimedb.view( + { name: 'my_data_wrong', public: true }, + t.array(PrivateData.rowType), + (ctx) => [...ctx.db.privateData.iter()] // NOT ALLOWED +); + +// RIGHT — use index lookup +spacetimedb.view( + { name: 'my_data', public: true }, + t.array(PrivateData.rowType), + (ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)] +); +``` + +### Subscribing to Views + +Views require explicit subscription: + +```typescript +conn.subscriptionBuilder().subscribe([ + 'SELECT * FROM public_table', + 'SELECT * FROM my_data', // Views need explicit SQL! +]); +``` + +## Procedures (Beta) + +**Procedures are for side effects (HTTP requests, etc.) that reducers can't do.** + +Procedures are currently in beta. API may change. + +### Defining a procedure + +```typescript +spacetimedb.procedure( + 'fetch_external_data', + { url: t.string() }, + t.string(), // return type + (ctx, { url }) => { + const response = ctx.http.fetch(url); + return response.text(); + } +); +``` + +### CRITICAL: Database access in procedures + +**Procedures don't have `ctx.db`. Use `ctx.withTx()` for database access.** + +```typescript +spacetimedb.procedure('save_fetched_data', { url: t.string() }, t.unit(), (ctx, { url }) => { + // Fetch external data (outside transaction) + const response = ctx.http.fetch(url); + const data = response.text(); + + // WRONG — ctx.db doesn't exist in procedures + // ctx.db.myTable.insert({ ... }); + + // RIGHT — use ctx.withTx() for database access + ctx.withTx(tx => { + tx.db.myTable.insert({ + id: 0n, + content: data, + fetchedAt: tx.timestamp, + fetchedBy: tx.sender, + }); + }); + + return {}; +}); +``` + +### Key differences from reducers + +| Reducers | Procedures | +|----------|------------| +| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` | +| Automatic transaction | Manual transaction management | +| No HTTP/network | `ctx.http.fetch()` available | +| No return values to caller | Can return data to caller | + ## Identity and Authentication ```typescript @@ -303,6 +501,9 @@ const parsed = Identity.fromString('0x1234...'); // Zero identity const zero = Identity.zero(); + +// Compare identities using toHexString() +const isOwner = row.ownerId.toHexString() === myIdentity.toHexString(); ``` ### Persisting Authentication @@ -318,6 +519,17 @@ const zero = Identity.zero(); .withToken(localStorage.getItem('auth_token') ?? undefined) ``` +### Stale token handling + +```typescript +const onConnectError = (_ctx: ErrorContext, err: Error) => { + if (err.message?.includes('Unauthorized') || err.message?.includes('401')) { + localStorage.removeItem('auth_token'); + window.location.reload(); + } +}; +``` + ## React Integration The SDK includes React hooks for reactive UI updates. @@ -325,27 +537,39 @@ The SDK includes React hooks for reactive UI updates. ### Provider Setup ```tsx -import React from 'react'; +import React, { useMemo } from 'react'; import ReactDOM from 'react-dom/client'; import { SpacetimeDBProvider } from 'spacetimedb/react'; import { DbConnection, query } from './module_bindings'; import App from './App'; -const connectionBuilder = DbConnection.builder() - .withUri('ws://localhost:3000') - .withModuleName('my_game') - .onConnect((conn, identity, token) => { - console.log('Connected:', identity.toHexString()); - conn.subscriptionBuilder().subscribe(query.player.build()); - }) - .onDisconnect(() => console.log('Disconnected')) - .onConnectError((ctx, err) => console.error('Error:', err)); +function Root() { + // CRITICAL: Memoize to prevent reconnects on every render + const connectionBuilder = useMemo(() => + DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_game') + .withToken(localStorage.getItem('auth_token') || undefined) + .onConnect((conn, identity, token) => { + console.log('Connected:', identity.toHexString()); + localStorage.setItem('auth_token', token); + conn.subscriptionBuilder().subscribe(query.player.build()); + }) + .onDisconnect(() => console.log('Disconnected')) + .onConnectError((ctx, err) => console.error('Error:', err)), + [] // Empty deps - only create once + ); -ReactDOM.createRoot(document.getElementById('root')!).render( - + return ( + ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + ); ``` @@ -374,14 +598,14 @@ function ConnectionStatus() { ### useTable Hook -Subscribe to table data with reactive updates: +Subscribe to table data with reactive updates. **CRITICAL: Returns a tuple `[rows, isLoading]`.** ```tsx import { useTable, where, eq } from 'spacetimedb/react'; import { tables } from './module_bindings'; function PlayerList() { - // All players + // CORRECT: Tuple destructuring const [players, isLoading] = useTable(tables.player); if (isLoading) return
Loading...
; @@ -433,6 +657,7 @@ function CreatePlayerForm() { const handleSubmit = (e) => { e.preventDefault(); + // CORRECT: Object syntax createPlayer({ name, location: { x: 0, y: 0 } }); setName(''); }; @@ -493,6 +718,74 @@ async function main() { main(); ``` +## Timestamps + +### Server-side + +```typescript +import { Timestamp, ScheduleAt } from 'spacetimedb'; + +// Current time +ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp }); + +// Future time (add microseconds) +const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes +``` + +### Client-side (CRITICAL) + +**Timestamps are objects, not numbers:** + +```typescript +// WRONG +const date = new Date(row.createdAt); +const date = new Date(Number(row.createdAt / 1000n)); + +// RIGHT +const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n)); +``` + +### ScheduleAt on client + +```typescript +// ScheduleAt is a tagged union +if (scheduleAt.tag === 'Time') { + const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n)); +} +``` + +## Scheduled Tables + +```typescript +// Scheduled table MUST use scheduledId and scheduledAt columns +export const CleanupJob = table({ + name: 'cleanup_job', + scheduled: 'run_cleanup' // reducer name +}, { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + targetId: t.u64(), // Your custom data +}); + +// Scheduled reducer receives full row as arg +spacetimedb.reducer('run_cleanup', { arg: CleanupJob.rowType }, (ctx, { arg }) => { + // arg.scheduledId, arg.targetId available + // Row is auto-deleted after reducer completes +}); + +// Schedule a job +import { ScheduleAt } from 'spacetimedb'; +const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds +ctx.db.cleanupJob.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.time(futureTime), + targetId: someId +}); + +// Cancel a job by deleting the row +ctx.db.cleanupJob.scheduledId.delete(jobId); +``` + ## Error Handling ### Connection Errors @@ -584,6 +877,25 @@ import { } from './module_bindings'; ``` +## Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --project-path + +# Clear database and republish +spacetime publish --clear-database -y --project-path + +# Generate bindings +spacetime generate --lang typescript --out-dir /src/module_bindings --project-path + +# View logs +spacetime logs +``` + ## Best Practices 1. **Store auth tokens**: Save the token from `onConnect` for seamless reconnection. @@ -600,6 +912,10 @@ import { 7. **Use primary keys**: Define primary keys on tables to enable `onUpdate` callbacks. +8. **Memoize connectionBuilder**: Always wrap in `useMemo()` to prevent reconnects. + +9. **Let subscriptions drive state**: Avoid optimistic updates; let the server be the source of truth. + ## Common Patterns ### Reconnection Logic @@ -661,3 +977,27 @@ const [redTeamHighScorers] = useTable( ); const filtered = redTeamHighScorers.filter(p => p.score >= 100); ``` + +## Project Structure + +### Server (`backend/spacetimedb/`) +``` +src/schema.ts -> Tables, export spacetimedb +src/index.ts -> Reducers, lifecycle, import schema +package.json -> { "type": "module", "dependencies": { "spacetimedb": "^1.11.0" } } +tsconfig.json -> Standard config +``` + +### Avoiding circular imports +``` +schema.ts -> defines tables AND exports spacetimedb +index.ts -> imports spacetimedb from ./schema, defines reducers +``` + +### Client (`client/`) +``` +src/module_bindings/ -> Generated (spacetime generate) +src/main.tsx -> Provider, connection setup +src/App.tsx -> UI components +src/config.ts -> MODULE_NAME, SPACETIMEDB_URI +``` From 515d8dab17b4b64c7a098ad915af2aa96af72a83 Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:21:23 -0500 Subject: [PATCH 3/4] Replace symlink with real files for npx skills compatibility The symlink wasn't being followed when cloned from GitHub. Both skills/ and .claude/skills/ now contain the actual files. --- skills | 1 - skills/spacetimedb-cli/SKILL.md | 239 ++++ skills/spacetimedb-concepts/SKILL.md | 517 +++++++++ skills/spacetimedb-csharp/SKILL.md | 1462 ++++++++++++++++++++++++ skills/spacetimedb-rust/SKILL.md | 894 +++++++++++++++ skills/spacetimedb-typescript/SKILL.md | 1003 ++++++++++++++++ 6 files changed, 4115 insertions(+), 1 deletion(-) delete mode 120000 skills create mode 100644 skills/spacetimedb-cli/SKILL.md create mode 100644 skills/spacetimedb-concepts/SKILL.md create mode 100644 skills/spacetimedb-csharp/SKILL.md create mode 100644 skills/spacetimedb-rust/SKILL.md create mode 100644 skills/spacetimedb-typescript/SKILL.md diff --git a/skills b/skills deleted file mode 120000 index ec22b3dafd3..00000000000 --- a/skills +++ /dev/null @@ -1 +0,0 @@ -.claude/skills \ No newline at end of file diff --git a/skills/spacetimedb-cli/SKILL.md b/skills/spacetimedb-cli/SKILL.md new file mode 100644 index 00000000000..77f3cdc678c --- /dev/null +++ b/skills/spacetimedb-cli/SKILL.md @@ -0,0 +1,239 @@ +--- +name: spacetimedb-cli +description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers +triggers: + - spacetime init + - spacetime build + - spacetime publish + - spacetime dev + - spacetime sql + - spacetime call + - spacetime logs + - spacetime server + - spacetime login + - spacetime generate + - how do I use the CLI + - CLI command +--- + +# SpacetimeDB CLI + +Use this skill when the user needs help with the `spacetime` CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues. + +## Quick Reference + +### Project Initialization & Development + +```bash +# Initialize new project +spacetime init my-project --lang rust|csharp|typescript +spacetime init my-project --template + +# Build module +spacetime build # release build +spacetime build --debug # faster iteration, slower runtime + +# Dev mode (auto-rebuild, auto-publish, generates bindings) +spacetime dev +spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings + +# Generate client bindings +spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings +``` + +### Publishing & Deployment + +```bash +# Publish to Maincloud (default) +spacetime publish my-database --yes + +# Publish to local server +spacetime publish my-database --server local --yes + +# Publish with data handling +spacetime publish my-database --delete-data always # always clear data +spacetime publish my-database --delete-data on-conflict # clear only if schema conflicts +spacetime publish my-database --delete-data never # never clear (default) + +# Allow breaking client changes +spacetime publish my-database --break-clients +``` + +### Database Interaction + +```bash +# SQL queries +spacetime sql my-database "SELECT * FROM users" +spacetime sql my-database --interactive # REPL mode + +# Call reducers +spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}' + +# Subscribe to changes +spacetime subscribe my-database "SELECT * FROM users" --num-updates 10 + +# View logs +spacetime logs my-database -f # follow logs +spacetime logs my-database -n 100 # last 100 lines + +# Describe schema +spacetime describe my-database --json +spacetime describe my-database table users --json +spacetime describe my-database reducer my_reducer --json +``` + +### Database Management + +```bash +# List databases +spacetime list + +# Delete database +spacetime delete my-database + +# Rename database +spacetime rename --to new-name +``` + +### Server Management + +```bash +# List configured servers +spacetime server list + +# Add server +spacetime server add local http://localhost:3000 --default +spacetime server add myserver https://my-spacetime.example.com + +# Set default server +spacetime server set-default local + +# Test connectivity +spacetime server ping local + +# Start local instance +spacetime start + +# Clear local data +spacetime server clear +``` + +### Authentication + +```bash +# Login (opens browser) +spacetime login + +# Login with token +spacetime login --token + +# Show login status +spacetime login show + +# Logout +spacetime logout +``` + +### Energy/Billing + +```bash +spacetime energy balance +spacetime energy balance --identity +``` + +## Default Servers + +| Name | URL | Description | +|------|-----|-------------| +| `maincloud` | `https://spacetimedb.com` | Production cloud (default) | +| `local` | `http://127.0.0.1:3000` | Local development server | + +## Common Workflows + +### New Project Setup + +```bash +# 1. Login +spacetime login + +# 2. Create project +spacetime init my-game --lang rust +cd my-game + +# 3. Start dev mode (auto-rebuilds and publishes) +spacetime dev +``` + +### Local Development + +```bash +# Start local server (in separate terminal) +spacetime start + +# Publish to local +spacetime publish my-db --server local --delete-data always --yes + +# Query local database +spacetime sql my-db --server local "SELECT * FROM players" +``` + +### Generate Client Bindings + +```bash +# After building module +spacetime build +spacetime generate --lang typescript --out-dir ./client/src/bindings + +# Or use dev mode which auto-generates +spacetime dev --client-lang typescript --module-bindings-path ./client/src/bindings +``` + +## Common Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--server` | `-s` | Target server (nickname, hostname, or URL) | +| `--yes` | `-y` | Non-interactive mode (skip confirmations) | +| `--anonymous` | | Use anonymous identity | +| `--identity` | `-i` | Specify identity to use | +| `--project-path` | `-p` | Path to module project | + +## Troubleshooting + +### "Not logged in" +```bash +spacetime login +# Or use --anonymous for public operations +``` + +### "Server not responding" +```bash +spacetime server ping +# For local: ensure spacetime start is running +``` + +### "Schema conflict" +```bash +# Clear data and republish +spacetime publish my-db --delete-data always --yes +``` + +### "Build failed" +```bash +# Check Rust/C# toolchain +rustup show +# For Rust modules, ensure wasm32-unknown-unknown target +rustup target add wasm32-unknown-unknown +``` + +## Module Languages + +**Server-side (modules):** Rust, C#, TypeScript +**Client SDKs:** TypeScript, C#, Rust, Python, Unreal Engine + +## Notes + +- Many commands are marked UNSTABLE and may change +- Default server is `maincloud` unless configured otherwise +- Use `--yes` flag in scripts to avoid interactive prompts +- Dev mode watches files and auto-rebuilds on changes diff --git a/skills/spacetimedb-concepts/SKILL.md b/skills/spacetimedb-concepts/SKILL.md new file mode 100644 index 00000000000..a0e1997f1f6 --- /dev/null +++ b/skills/spacetimedb-concepts/SKILL.md @@ -0,0 +1,517 @@ +--- +name: spacetimedb-concepts +description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "1.1" +--- + +# SpacetimeDB Core Concepts + +SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely. + +--- + +## Critical Rules (Read First) + +These five rules prevent the most common SpacetimeDB mistakes: + +1. **Reducers are transactional** — they do not return data to callers. Use subscriptions to read data. +2. **Reducers must be deterministic** — no filesystem, network, timers, or random. All state must come from tables. +3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries. +4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns. +5. **`ctx.sender` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender` for authorization. + +--- + +## Feature Implementation Checklist + +When implementing a feature that spans backend and client: + +1. **Backend:** Define table(s) to store the data +2. **Backend:** Define reducer(s) to mutate the data +3. **Client:** Subscribe to the table(s) +4. **Client:** Call the reducer(s) from UI — **do not skip this step** +5. **Client:** Render the data from the table(s) + +**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. + +--- + +## Debugging Checklist + +When things are not working: + +1. Is SpacetimeDB server running? (`spacetime start`) +2. Is the module published? (`spacetime publish`) +3. Are client bindings generated? (`spacetime generate`) +4. Check server logs for errors (`spacetime logs `) +5. **Is the reducer actually being called from the client?** + +--- + +## CLI Commands + +```bash +# Start local SpacetimeDB +spacetime start + +# Publish module +spacetime publish --project-path + +# Clear and republish +spacetime publish --clear-database -y --project-path + +# Generate client bindings +spacetime generate --lang --out-dir --project-path + +# View logs +spacetime logs +``` + +--- + +## What SpacetimeDB Is + +SpacetimeDB combines a database and application server into a single deployable unit. Clients connect directly to the database and execute application logic inside it. The system is optimized for real-time applications requiring maximum speed and minimum latency. + +Key characteristics: + +- **In-memory execution**: All application state lives in memory for sub-millisecond access +- **Persistent storage**: Data is automatically persisted to a write-ahead log (WAL) for durability +- **Real-time synchronization**: Changes are automatically pushed to subscribed clients +- **Single deployment**: No separate servers, containers, or infrastructure to manage + +SpacetimeDB powers BitCraft Online, an MMORPG where the entire game backend (chat, items, resources, terrain, player positions) runs as a single SpacetimeDB module. + +## The Five Zen Principles + +SpacetimeDB is built on five core principles that guide both development and usage: + +1. **Everything is a Table**: Your entire application state lives in tables. No separate cache layer, no Redis, no in-memory state to synchronize. The database IS your state. + +2. **Everything is Persistent**: SpacetimeDB persists everything by default, including full history. Persistence only increases latency, never decreases throughput. Modern SSDs can write 15+ GB/s. + +3. **Everything is Real-Time**: Clients are replicas of server state. Subscribe to data and it flows automatically. No polling, no fetching. + +4. **Everything is Transactional**: Every reducer runs atomically. Either all changes succeed or all roll back. No partial updates, no corrupted state. + +5. **Everything is Programmable**: Modules are real code (Rust, C#, TypeScript) running inside the database. Full Turing-complete power for any logic. + +## Tables + +Tables store all data in SpacetimeDB. They use the relational model and support SQL queries for subscriptions. + +### Defining Tables + +Tables are defined using language-specific attributes: + +**Rust:** +```rust +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + #[auto_inc] + id: u32, + #[index(btree)] + name: String, + #[unique] + email: String, +} +``` + +**C#:** +```csharp +[SpacetimeDB.Table(Name = "Player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public uint Id; + [SpacetimeDB.Index.BTree] + public string Name; + [SpacetimeDB.Unique] + public string Email; +} +``` + +**TypeScript:** +```typescript +const players = table( + { name: 'players', public: true }, + { + id: t.u32().primaryKey().autoInc(), + name: t.string().index('btree'), + email: t.string().unique(), + } +); +``` + +### Table Visibility + +- **Private tables** (default): Only accessible by reducers and the database owner +- **Public tables**: Exposed for client read access through subscriptions. Writes still require reducers. + +### Table Design Principles + +Organize data by access pattern, not by entity: + +**Decomposed approach (recommended):** +``` +Player PlayerState PlayerStats +id <-- player_id player_id +name position_x total_kills + position_y total_deaths + velocity_x play_time + velocity_y +``` + +Benefits: +- Reduced bandwidth (clients subscribing to positions do not receive settings updates) +- Cache efficiency (similar update frequencies in contiguous memory) +- Schema evolution (add columns without affecting other tables) +- Semantic clarity (each table has single responsibility) + +## Reducers + +Reducers are transactional functions that modify database state. They are the ONLY way to mutate tables in SpacetimeDB. + +### Key Properties + +- **Transactional**: Run in isolated database transactions +- **Atomic**: Either all changes succeed or all roll back +- **Isolated**: Cannot interact with the outside world (no network, no filesystem) +- **Callable**: Clients invoke reducers as remote procedure calls + +### Critical Reducer Rules + +1. **No global state**: Relying on static variables is undefined behavior +2. **No side effects**: Reducers cannot make network requests or access files +3. **Store state in tables**: All persistent state must be in tables +4. **No return data**: Reducers do not return data to callers — use subscriptions +5. **Must be deterministic**: No random, no timers, no external I/O + +### Defining Reducers + +**Rust:** +```rust +#[spacetimedb::reducer] +pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String> { + if name.is_empty() { + return Err("Name cannot be empty".to_string()); + } + ctx.db.user().insert(User { id: 0, name, email }); + Ok(()) +} +``` + +**C#:** +```csharp +[SpacetimeDB.Reducer] +public static void CreateUser(ReducerContext ctx, string name, string email) +{ + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Name cannot be empty"); + ctx.Db.User.Insert(new User { Id = 0, Name = name, Email = email }); +} +``` + +### ReducerContext + +Every reducer receives a `ReducerContext` providing: +- `ctx.db`: Access to all tables (read and write) +- `ctx.sender`: The Identity of the caller (use this for authorization, never trust args) +- `ctx.connection_id`: The connection ID of the caller +- `ctx.timestamp`: The current timestamp + +## Subscriptions + +Subscriptions replicate database rows to clients in real-time. When you subscribe to a query, SpacetimeDB sends matching rows immediately and pushes updates whenever those rows change. + +### How Subscriptions Work + +1. **Subscribe**: Register SQL queries describing needed data +2. **Receive initial data**: All matching rows are sent immediately +3. **Receive updates**: Real-time updates when subscribed rows change +4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`) to handle changes + +### Client-Side Usage + +**TypeScript:** +```typescript +const conn = DbConnection.builder() + .withUri('wss://maincloud.spacetimedb.com') + .withModuleName('my_module') + .onConnect((ctx) => { + ctx.subscriptionBuilder() + .onApplied(() => console.log('Subscription ready!')) + .subscribe(['SELECT * FROM user', 'SELECT * FROM message']); + }) + .build(); + +// React to changes +conn.db.user.onInsert((ctx, user) => console.log(`New user: ${user.name}`)); +conn.db.user.onDelete((ctx, user) => console.log(`User left: ${user.name}`)); +conn.db.user.onUpdate((ctx, old, new_) => console.log(`${old.name} -> ${new_.name}`)); +``` + +### Subscription Best Practices + +1. **Group subscriptions by lifetime**: Keep always-needed data separate from temporary subscriptions +2. **Subscribe before unsubscribing**: When updating subscriptions, subscribe to new data first +3. **Avoid overlapping queries**: Distinct queries returning overlapping data cause redundant processing +4. **Use indexes**: Queries on indexed columns are efficient; full table scans are expensive + +## Modules + +Modules are WebAssembly bundles containing application logic that runs inside the database. + +### Module Components + +- **Tables**: Define the data schema +- **Reducers**: Define callable functions that modify state +- **Views**: Define read-only computed queries +- **Procedures**: (Beta) Functions that can have side effects (HTTP requests) + +### Module Languages + +Server-side modules can be written in: +- Rust +- C# +- TypeScript (beta) + +### Module Lifecycle + +1. **Write**: Define tables and reducers in your chosen language +2. **Compile**: Build to WebAssembly using the SpacetimeDB CLI +3. **Publish**: Upload to a SpacetimeDB host with `spacetime publish` +4. **Hot-swap**: Republish to update code without disconnecting clients + +## Identity + +Identity is SpacetimeDB's authentication system based on OpenID Connect (OIDC). + +### Identity Concepts + +- **Identity**: A long-lived, globally unique identifier for a user. Derived from OIDC issuer and subject claims. +- **ConnectionId**: Identifies a specific client connection. A user may have multiple connections. + +### Identity in Reducers + +```rust +#[spacetimedb::reducer] +pub fn do_something(ctx: &ReducerContext) { + let caller_identity = ctx.sender; // Who is calling this reducer? + // Use identity for authorization checks + // NEVER trust identity passed as a reducer argument +} +``` + +### Authentication Providers + +SpacetimeDB works with any OIDC provider: +- **SpacetimeAuth**: Built-in managed provider (simple, production-ready) +- **Third-party**: Auth0, Clerk, Keycloak, Google, GitHub, etc. + +## SATS (SpacetimeDB Algebraic Type System) + +SATS is the type system and serialization format used throughout SpacetimeDB. + +### Core Types + +| Category | Types | +|----------|-------| +| Primitives | `Bool`, `U8`-`U256`, `I8`-`I256`, `F32`, `F64`, `String` | +| Composite | `ProductType` (structs), `SumType` (enums/tagged unions) | +| Collections | `Array`, `Map` | +| Special | `Identity`, `ConnectionId`, `ScheduleAt` | + +### Serialization Formats + +- **BSATN**: Binary format for module-host communication and row storage +- **SATS-JSON**: JSON format for HTTP API and WebSocket text protocol + +### Type Compatibility + +Types must implement `SpacetimeType` to be used in tables and reducers. This is automatic for primitive types and structs using the appropriate attributes. + +## Client-Server Data Flow + +### Write Path (Client to Database) + +1. Client calls reducer (e.g., `ctx.reducers.createUser("Alice")`) +2. Request sent over WebSocket to SpacetimeDB host +3. Host validates identity and executes reducer in transaction +4. On success, changes are committed; on error, all changes roll back +5. Subscribed clients receive updates for affected rows + +### Read Path (Database to Client) + +1. Client subscribes with SQL queries (e.g., `SELECT * FROM user`) +2. Server evaluates query and sends matching rows +3. Client maintains local cache of subscribed data +4. When subscribed data changes, server pushes delta updates +5. Client cache is automatically updated; callbacks fire + +### Data Flow Diagram + +``` +┌─────────────────────────────────────────────────────────┐ +│ CLIENT │ +│ ┌─────────────┐ ┌─────────────────────────────┐ │ +│ │ Reducers │────>│ Local Cache (Read) │ │ +│ │ (Write) │ │ - Tables from subscriptions│ │ +│ └─────────────┘ │ - Automatically synced │ │ +│ │ └─────────────────────────────┘ │ +└─────────│──────────────────────────│───────────────────┘ + │ WebSocket │ Updates pushed + v │ +┌─────────────────────────────────────────────────────────┐ +│ SpacetimeDB │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Module │ │ +│ │ - Reducers (transactional logic) │ │ +│ │ - Tables (in-memory + persisted) │ │ +│ │ - Subscriptions (real-time queries) │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## When to Use SpacetimeDB + +### Ideal Use Cases + +- **Real-time games**: MMOs, multiplayer games, turn-based games +- **Collaborative applications**: Document editing, whiteboards, design tools +- **Chat and messaging**: Real-time communication with presence +- **Live dashboards**: Streaming analytics and monitoring +- **IoT applications**: Sensor data with real-time updates + +### Key Decision Factors + +Choose SpacetimeDB when you need: +- Sub-10ms latency for reads and writes +- Automatic real-time synchronization +- Transactional guarantees for all operations +- Simplified architecture (no separate cache, queue, or server) + +### Less Suitable For + +- **Batch analytics**: SpacetimeDB is optimized for OLTP, not OLAP +- **Large blob storage**: Better suited for structured relational data +- **Stateless APIs**: Traditional REST APIs do not need real-time sync + +## Comparison to Traditional Architectures + +### Traditional Stack + +``` +Client + │ + v +Load Balancer + │ + v +Web/Game Servers (stateless or stateful) + │ + ├──> Cache (Redis) + │ + v +Database (PostgreSQL, MySQL) + │ + v +Message Queue (for real-time) +``` + +**Pain points:** +- Multiple systems to deploy and manage +- Cache invalidation complexity +- State synchronization between servers +- Manual real-time implementation +- Horizontal scaling complexity + +### SpacetimeDB Stack + +``` +Client + │ + v +SpacetimeDB Host + │ + v +Module (your logic + tables) +``` + +**Benefits:** +- Single deployment target +- No cache layer needed (in-memory by design) +- Automatic real-time synchronization +- Built-in horizontal scaling (future) +- Transactional guarantees everywhere + +### Smart Contract Comparison + +SpacetimeDB modules are conceptually similar to smart contracts: +- Application logic runs inside the data layer +- Transactions are atomic and verified +- State changes are deterministic + +Key differences: +- SpacetimeDB is orders of magnitude faster (no consensus overhead) +- Full relational database capabilities +- No blockchain or cryptocurrency involved +- Designed for real-time, not eventual consistency + +## Common Patterns + +**Authentication check in reducer:** +```rust +#[spacetimedb::reducer] +fn admin_action(ctx: &ReducerContext) -> Result<(), String> { + let admin = ctx.db.admin().identity().find(&ctx.sender) + .ok_or("Not an admin")?; + // ... perform admin action + Ok(()) +} +``` + +**Moving between tables (state machine):** +```rust +#[spacetimedb::reducer] +fn login(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx.db.logged_out_player().identity().find(&ctx.sender) + .ok_or("Not found")?; + ctx.db.player().insert(player.clone()); + ctx.db.logged_out_player().identity().delete(&ctx.sender); + Ok(()) +} +``` + +**Scheduled reducer:** +```rust +#[spacetimedb::table(name = reminder, scheduled(send_reminder))] +pub struct Reminder { + #[primary_key] + #[auto_inc] + id: u64, + scheduled_at: ScheduleAt, + message: String, +} + +#[spacetimedb::reducer] +fn send_reminder(ctx: &ReducerContext, reminder: Reminder) { + // This runs at the scheduled time + log::info!("Reminder: {}", reminder.message); +} +``` + +--- + +## Editing Behavior + +When modifying SpacetimeDB code: + +- Make the smallest change necessary +- Do NOT touch unrelated files, configs, or dependencies +- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo diff --git a/skills/spacetimedb-csharp/SKILL.md b/skills/spacetimedb-csharp/SKILL.md new file mode 100644 index 00000000000..099dc0db446 --- /dev/null +++ b/skills/spacetimedb-csharp/SKILL.md @@ -0,0 +1,1462 @@ +--- +name: spacetimedb-csharp +description: Build C# modules and Unity clients for SpacetimeDB. Covers server-side module development and client SDK integration. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "1.1" + tested_with: "SpacetimeDB runtime 1.11.x, .NET 8 SDK" +--- + +# SpacetimeDB C# SDK + +This skill provides comprehensive guidance for building C# server-side modules and Unity/C# clients that connect to SpacetimeDB. + +--- + +## HALLUCINATED APIs — DO NOT USE + +**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** + +```csharp +// WRONG — these do not exist +[SpacetimeDB.Procedure] // C# does NOT support procedures yet! +ctx.db.tableName // Wrong casing, should be PascalCase +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 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(Name = "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(Name = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index! +[Index.BTree(Name = "idx", Columns = ["Col"])] // Collection expressions don't work in attributes! +``` + +### CORRECT PATTERNS + +```csharp +// CORRECT IMPORTS +using SpacetimeDB; + +// CORRECT TABLE — must be partial struct +[SpacetimeDB.Table(Name = "player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + + 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); +var player = ctx.Db.Player.OwnerId.Find(ctx.Sender); + +// CORRECT SUM TYPE — partial record with named tuple elements +[SpacetimeDB.Type] +public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } +``` + +### DO NOT + +- **Forget `partial` keyword** — required on all tables and Module class +- **Use lowercase table access** — `ctx.Db.Player` not `ctx.Db.player` +- **Try to use procedures** — C# does not support procedures yet +- **Use `Optional`** — use C# nullable syntax `T?` instead +- **Use struct for sum types** — must be `partial record` + +--- + +## Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| Missing `partial` keyword | `public partial struct Table` | Generated code won't compile | +| `ctx.Db.player` (lowercase) | `ctx.Db.Player` (PascalCase) | Property not found | +| `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 : TaggedEnum` | `partial record : TaggedEnum` | Sum types must be record | +| `TaggedEnum<(A, B)>` | `TaggedEnum<(A A, B B)>` | Tuple must include variant names | + +### Client-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| Wrong namespace | `using SpacetimeDB.ClientApi;` | Types not found | +| Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire | +| Accessing `conn.Db` from background thread | Copy data in callback, process elsewhere | Data races | + +--- + +## Hard Requirements + +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. **Sum types must be `partial record`** — not struct or class +12. **Fully qualify Index attribute** — `[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity + +--- + +## Server-Side Module Development + +### Table Definition (CRITICAL) + +**Tables MUST use `partial struct` or `partial class` for code generation.** + +```csharp +using SpacetimeDB; + +// WRONG — missing partial! +[SpacetimeDB.Table(Name = "player")] +public struct Player { } // Will not generate properly! + +// RIGHT — with partial keyword +[SpacetimeDB.Table(Name = "player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + + public Identity OwnerId; + public string Name; + public Timestamp CreatedAt; +} + +// With indexes +[SpacetimeDB.Table(Name = "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 +[SpacetimeDB.Table(Name = "score", Public = true)] +[SpacetimeDB.Index.BTree(Name = "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 +``` + +### 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) + { + var task = ctx.Db.Task.Id.Find(taskId); + if (task is null) + { + throw new Exception("Task not found"); + } + + if (task.Value.OwnerId != ctx.Sender) + { + throw new Exception("Not authorized"); + } + + ctx.Db.Task.Id.Update(task.Value 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) + { + // Called once when module is first published + Log.Info("Module initialized"); + } + + [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] + public static void OnConnect(ReducerContext ctx) + { + // ctx.Sender is the connecting client + Log.Info($"Client connected: {ctx.Sender}"); + } + + [SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)] + public static void OnDisconnect(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) +``` + +### Database Access + +#### Naming Convention + +- **Tables**: Use PascalCase singular names in the `Name` attribute + - `[Table(Name = "User")]` → `ctx.Db.User` + - `[Table(Name = "PlayerStats")]` → `ctx.Db.PlayerStats` +- **Indexes**: PascalCase, match field name + - Field `OwnerId` with `[Index.BTree]` → `ctx.Db.User.OwnerId` + +#### Primary Key Operations + +```csharp +// Find by primary key — returns nullable +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 +} +``` + +### 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(Name = "player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + public Identity Id; + + public string Name; + public PlayerStats Stats; +} +``` + +### Sum Types / Tagged Enums (CRITICAL) + +**Sum types MUST use `partial record` 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!) 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(Name = "drawings", 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 }); +``` + +#### COMMON SUM TYPE MISTAKES + +| Wrong | Right | Why | +|-------|-------|-----| +| `partial struct Shape : TaggedEnum<...>` | `partial record Shape : TaggedEnum<...>` | Must be `record`, not `struct` | +| `TaggedEnum<(Circle, Rectangle)>` | `TaggedEnum<(Circle Circle, Rectangle Rectangle)>` | Tuple must have names | +| `new Shape { ... }` | `new Shape.Circle(new Circle { ... })` | Use nested variant constructor | + +### Scheduled Tables + +```csharp +using SpacetimeDB; + +[SpacetimeDB.Table(Name = "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 +{ + // 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 = ScheduleAt.Time(futureTime) + }); + } + + [SpacetimeDB.Reducer] + public static void CancelReminder(ReducerContext ctx, ulong reminderId) + { + ctx.Db.Reminder.Id.Delete(reminderId); + } +} +``` + +### Logging + +```csharp +using SpacetimeDB; + +Log.Debug("Debug message"); +Log.Info("Information"); +Log.Warn("Warning"); +Log.Error("Error occurred"); +Log.Panic("Critical failure"); // Terminates execution +``` + +### Data Visibility + +**`Public = true` exposes ALL rows to ALL clients.** + +| Scenario | Pattern | +|----------|---------| +| Everyone sees all rows | `[Table(Name = "x", Public = true)]` | +| Server-only data | `[Table(Name = "x")]` (private by default) | + +### 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 +``` + +### Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --project-path + +# Clear database and republish +spacetime publish --clear-database -y --project-path + +# Generate bindings +spacetime generate --lang csharp --out-dir /SpacetimeDB --project-path + +# View logs +spacetime logs +``` + +--- + +## Client-Side SDK + +### Overview + +The SpacetimeDB C# SDK enables .NET applications and Unity games to: +- Connect to SpacetimeDB databases over WebSocket +- Subscribe to real-time table updates +- Invoke reducers (server-side functions) +- Maintain a local cache of subscribed data +- Handle authentication via Identity tokens + +**Critical Requirement**: The C# SDK requires manual connection advancement. You must call `FrameTick()` regularly to process messages. + +### Installation + +#### .NET Console/Library Applications + +Add the NuGet package: + +```bash +dotnet add package SpacetimeDB.ClientSDK +``` + +#### Unity Applications + +Add via Unity Package Manager using the git URL: + +``` +https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git +``` + +Steps: +1. Open Window > Package Manager +2. Click the + button in top-left +3. Select "Add package from git URL" +4. Paste the URL above and click Add + +### Generate Module Bindings + +Before using the SDK, generate type-safe bindings from your module: + +```bash +mkdir -p module_bindings +spacetime generate --lang cs --out-dir module_bindings --project-path PATH_TO_MODULE +``` + +This creates: +- `SpacetimeDBClient.g.cs` - Main client types (DbConnection, contexts, builders) +- `Tables/*.g.cs` - Table handle classes with typed access +- `Reducers/*.g.cs` - Reducer invocation methods +- `Types/*.g.cs` - Row types and custom types from the module + +### Connection Setup + +#### Basic Connection Pattern + +```csharp +using SpacetimeDB; +using SpacetimeDB.Types; + +DbConnection? conn = null; + +conn = DbConnection.Builder() + .WithUri("http://localhost:3000") // SpacetimeDB server URL + .WithModuleName("my-database") // Database name or Identity + .OnConnect(OnConnected) // Connection success callback + .OnConnectError((err) => { // Connection failure callback + Console.Error.WriteLine($"Connection failed: {err}"); + }) + .OnDisconnect((conn, err) => { // Disconnection callback + if (err != null) { + Console.Error.WriteLine($"Disconnected with error: {err}"); + } + }) + .Build(); + +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + Console.WriteLine($"Connected with Identity: {identity}"); + // Save authToken for reconnection + // Set up subscriptions here +} +``` + +#### Connection Builder Methods + +| Method | Description | +|--------|-------------| +| `WithUri(string uri)` | SpacetimeDB server URI (required) | +| `WithModuleName(string name)` | Database name or Identity (required) | +| `WithToken(string token)` | Auth token for reconnection | +| `WithConfirmedReads(bool)` | Wait for durable writes before returning | +| `OnConnect(callback)` | Called on successful connection | +| `OnConnectError(callback)` | Called if connection fails | +| `OnDisconnect(callback)` | Called when disconnected | +| `Build()` | Create and open the connection | + +### Critical: Advancing the Connection + +**The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly. + +#### Console Application Loop + +```csharp +while (true) +{ + conn.FrameTick(); + Thread.Sleep(16); // ~60 FPS +} +``` + +#### Unity MonoBehaviour Pattern + +```csharp +public class SpacetimeManager : MonoBehaviour +{ + private DbConnection conn; + + void Update() + { + conn?.FrameTick(); + } +} +``` + +**Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races with main thread access. + +### Subscribing to Tables + +#### Using SQL Queries + +```csharp +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .OnError((ctx, err) => { + Console.Error.WriteLine($"Subscription failed: {err}"); + }) + .Subscribe(new[] { + "SELECT * FROM player", + "SELECT * FROM message WHERE sender = :sender" + }); +} + +void OnSubscriptionApplied(SubscriptionEventContext ctx) +{ + Console.WriteLine("Subscription ready - data available"); + // Access ctx.Db to read subscribed rows +} +``` + +#### Using Typed Query Builder + +```csharp +conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .OnError((ctx, err) => Console.Error.WriteLine(err)) + .AddQuery(qb => qb.From.Player().Build()) + .AddQuery(qb => qb.From.Message().Where(c => c.Sender.Eq(identity)).Build()) + .Subscribe(); +``` + +#### Subscribe to All Tables (Development Only) + +```csharp +conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); +``` + +**Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection. + +#### Subscription Handle + +```csharp +SubscriptionHandle handle = conn.SubscriptionBuilder() + .OnApplied(ctx => Console.WriteLine("Applied")) + .Subscribe(new[] { "SELECT * FROM player" }); + +// Later: unsubscribe +handle.UnsubscribeThen(ctx => { + Console.WriteLine("Unsubscribed"); +}); + +// Check status +bool isActive = handle.IsActive; +bool isEnded = handle.IsEnded; +``` + +### Accessing the Client Cache + +Subscribed data is stored in `conn.Db` (or `ctx.Db` in callbacks). + +#### Iterating All Rows + +```csharp +foreach (var player in ctx.Db.Player.Iter()) +{ + Console.WriteLine($"Player: {player.Name}"); +} +``` + +#### Count Rows + +```csharp +int playerCount = ctx.Db.Player.Count; +``` + +#### Find by Unique/Primary Key + +For columns marked `[Unique]` or `[PrimaryKey]` on the server: + +```csharp +// Find by unique column +Player? player = ctx.Db.Player.Identity.Find(someIdentity); + +// Returns null if not found +if (player != null) +{ + Console.WriteLine($"Found: {player.Name}"); +} +``` + +#### Filter by BTree Index + +For columns with `[Index.BTree]` on the server: + +```csharp +// Filter returns IEnumerable +IEnumerable levelOnePlayers = ctx.Db.Player.Level.Filter(1); + +int count = levelOnePlayers.Count(); +``` + +#### Remote Query (Ad-hoc SQL) + +```csharp +var result = ctx.Db.Player.RemoteQuery("WHERE level > 10"); +Player[] highLevelPlayers = result.Result; +``` + +### Row Event Callbacks + +Register callbacks to react to table changes: + +#### OnInsert + +```csharp +ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { + Console.WriteLine($"Player joined: {player.Name}"); +}; +``` + +#### OnDelete + +```csharp +ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => { + Console.WriteLine($"Player left: {player.Name}"); +}; +``` + +#### OnUpdate + +Fires when a row with a primary key is replaced: + +```csharp +ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => { + Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}"); +}; +``` + +#### Checking Event Source + +```csharp +ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { + switch (ctx.Event) + { + case Event.SubscribeApplied: + // Initial subscription data + break; + case Event.Reducer(var reducerEvent): + // Change from a reducer + Console.WriteLine($"Reducer: {reducerEvent.Reducer}"); + break; + } +}; +``` + +### Calling Reducers + +Reducers are server-side functions that modify the database. + +#### Invoke a Reducer + +```csharp +// Reducers are methods on ctx.Reducers or conn.Reducers +ctx.Reducers.SendMessage("Hello, world!"); +ctx.Reducers.CreatePlayer("NewPlayer"); +ctx.Reducers.UpdateScore(playerId, 100); +``` + +#### Reducer Callbacks + +React when a reducer completes (success or failure): + +```csharp +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 + +Catch reducer errors without specific handlers: + +```csharp +conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => { + Console.Error.WriteLine($"Reducer error: {ex.Message}"); +}; +``` + +#### Reducer Event Properties + +```csharp +conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => { + ReducerEvent evt = ctx.Event; + + Timestamp when = evt.Timestamp; + Status status = evt.Status; + Identity caller = evt.CallerIdentity; + ConnectionId? callerId = evt.CallerConnectionId; + U128? energy = evt.EnergyConsumed; +}; +``` + +### Identity and Authentication + +#### Getting Current Identity + +```csharp +// In OnConnect callback +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + // identity - your unique identifier + // authToken - save this for reconnection + PlayerPrefs.SetString("SpacetimeToken", authToken); +} + +// From any context +Identity? myIdentity = ctx.Identity; +ConnectionId myConnectionId = ctx.ConnectionId; +``` + +#### Reconnecting with Token + +```csharp +string savedToken = PlayerPrefs.GetString("SpacetimeToken", null); + +DbConnection.Builder() + .WithUri("http://localhost:3000") + .WithModuleName("my-database") + .WithToken(savedToken) // Reconnect as same identity + .OnConnect(OnConnected) + .Build(); +``` + +#### Anonymous Connection + +Pass `null` to `WithToken` or omit it entirely for a new anonymous identity. + +### BSATN Serialization + +SpacetimeDB uses BSATN (Binary SpacetimeDB Algebraic Type Notation) for serialization. The SDK handles this automatically for generated types. + +#### Supported Types + +| C# Type | SpacetimeDB Type | +|---------|------------------| +| `bool` | Bool | +| `byte`, `sbyte` | U8, I8 | +| `ushort`, `short` | U16, I16 | +| `uint`, `int` | U32, I32 | +| `ulong`, `long` | U64, I64 | +| `U128`, `I128` | U128, I128 | +| `U256`, `I256` | U256, I256 | +| `float`, `double` | F32, F64 | +| `string` | String | +| `List` | Array | +| `T?` (nullable) | Option | +| `Identity` | Identity | +| `ConnectionId` | ConnectionId | +| `Timestamp` | Timestamp | +| `Uuid` | Uuid | + +#### Custom Types + +Types marked with `[SpacetimeDB.Type]` on the server are generated as C# types: + +```csharp +// Server-side (Rust or C#) +[SpacetimeDB.Type] +public partial struct Vector3 +{ + public float X; + public float Y; + public float Z; +} + +// Client-side (auto-generated) +public partial struct Vector3 : IEquatable +{ + public float X; + public float Y; + public float Z; + // BSATN serialization methods included +} +``` + +#### TaggedEnum (Sum Types) on Client + +```csharp +// Server +[SpacetimeDB.Type] +public partial record GameEvent : TaggedEnum<( + string PlayerJoined, + string PlayerLeft, + (string player, int score) ScoreUpdate +)>; + +// Client usage +switch (gameEvent) +{ + case GameEvent.PlayerJoined(var name): + Console.WriteLine($"{name} joined"); + break; + case GameEvent.ScoreUpdate((var player, var score)): + Console.WriteLine($"{player} scored {score}"); + break; +} +``` + +#### Result Type + +```csharp +// Result for success/error handling +Result result = ...; + +if (result is Result.Ok(var player)) +{ + Console.WriteLine($"Success: {player.Name}"); +} +else if (result is Result.Err(var error)) +{ + Console.Error.WriteLine($"Error: {error}"); +} +``` + +### Unity Integration + +#### Project Setup + +1. Add the SpacetimeDB package via Package Manager +2. Generate bindings and add to your Unity project +3. Create a manager MonoBehaviour + +#### SpacetimeManager Pattern + +```csharp +using UnityEngine; +using SpacetimeDB; +using SpacetimeDB.Types; + +public class SpacetimeManager : MonoBehaviour +{ + public static SpacetimeManager Instance { get; private set; } + + [SerializeField] private string serverUri = "http://localhost:3000"; + [SerializeField] private string moduleName = "my-game"; + + private DbConnection conn; + public DbConnection Connection => conn; + + void Awake() + { + if (Instance != null) + { + Destroy(gameObject); + return; + } + Instance = this; + DontDestroyOnLoad(gameObject); + } + + void Start() + { + Connect(); + } + + void Update() + { + // CRITICAL: Must call every frame + conn?.FrameTick(); + } + + void OnDestroy() + { + conn?.Disconnect(); + } + + public void Connect() + { + string token = PlayerPrefs.GetString("SpacetimeToken", null); + + conn = DbConnection.Builder() + .WithUri(serverUri) + .WithModuleName(moduleName) + .WithToken(token) + .OnConnect(OnConnected) + .OnConnectError(OnConnectError) + .OnDisconnect(OnDisconnect) + .Build(); + } + + private void OnConnected(DbConnection conn, Identity identity, string authToken) + { + Debug.Log($"Connected as {identity}"); + PlayerPrefs.SetString("SpacetimeToken", authToken); + + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .OnError((ctx, err) => Debug.LogError($"Subscription error: {err}")) + .SubscribeToAllTables(); + } + + private void OnConnectError(Exception err) + { + Debug.LogError($"Connection failed: {err}"); + } + + private void OnDisconnect(DbConnection conn, Exception err) + { + if (err != null) + { + Debug.LogError($"Disconnected: {err}"); + } + } + + private void OnSubscriptionApplied(SubscriptionEventContext ctx) + { + Debug.Log("Subscription ready"); + // Initialize game state from ctx.Db + } +} +``` + +#### Unity-Specific Considerations + +1. **Main Thread Only**: All SpacetimeDB callbacks run on the main thread (during `FrameTick()`) + +2. **Scene Loading**: Use `DontDestroyOnLoad` for the connection manager + +3. **Reconnection**: Handle disconnects gracefully for mobile/poor connectivity + +4. **PlayerPrefs**: Use for token persistence (or use a more secure method for production) + +#### Spawning GameObjects from Table Data + +```csharp +public class PlayerSpawner : MonoBehaviour +{ + [SerializeField] private GameObject playerPrefab; + private Dictionary playerObjects = new(); + + void Start() + { + var conn = SpacetimeManager.Instance.Connection; + + conn.Db.Player.OnInsert += OnPlayerInsert; + conn.Db.Player.OnDelete += OnPlayerDelete; + conn.Db.Player.OnUpdate += OnPlayerUpdate; + + // Spawn existing players + foreach (var player in conn.Db.Player.Iter()) + { + SpawnPlayer(player); + } + } + + void OnPlayerInsert(EventContext ctx, Player player) + { + // Skip if this is initial subscription data we already handled + if (ctx.Event is Event.SubscribeApplied) return; + + SpawnPlayer(player); + } + + void OnPlayerDelete(EventContext ctx, Player player) + { + if (playerObjects.TryGetValue(player.Identity, out var go)) + { + Destroy(go); + playerObjects.Remove(player.Identity); + } + } + + void OnPlayerUpdate(EventContext ctx, Player oldPlayer, Player newPlayer) + { + if (playerObjects.TryGetValue(newPlayer.Identity, out var go)) + { + // Update position, etc. + go.transform.position = new Vector3(newPlayer.X, newPlayer.Y, newPlayer.Z); + } + } + + void SpawnPlayer(Player player) + { + var go = Instantiate(playerPrefab); + go.transform.position = new Vector3(player.X, player.Y, player.Z); + playerObjects[player.Identity] = go; + } +} +``` + +### Thread Safety + +The C# SDK is NOT thread-safe. Follow these rules: + +1. **Call `FrameTick()` from ONE thread only** (main thread recommended) + +2. **All callbacks run during `FrameTick()`** on the calling thread + +3. **Do NOT access `conn.Db` from other threads** while `FrameTick()` may be running + +4. **Background work**: Copy data out of callbacks, process on background threads + +```csharp +// Safe pattern for background processing +conn.Db.Player.OnInsert += (ctx, player) => { + // Copy the data + var playerData = new PlayerDTO { + Id = player.Id, + Name = player.Name + }; + + // Process on background thread + Task.Run(() => ProcessPlayerAsync(playerData)); +}; +``` + +### Error Handling + +#### Connection Errors + +```csharp +.OnConnectError((err) => { + // Network errors, invalid module name, etc. + Debug.LogError($"Connect error: {err}"); +}) +``` + +#### Subscription Errors + +```csharp +.OnError((ctx, err) => { + // Invalid SQL, schema changes, etc. + Debug.LogError($"Subscription error: {err}"); +}) +``` + +#### Reducer Errors + +```csharp +conn.Reducers.OnMyReducer += (ctx, args) => { + if (ctx.Event.Status is Status.Failed(var reason)) + { + Debug.LogError($"Reducer failed: {reason}"); + } +}; + +// Catch-all for unhandled reducer errors +conn.OnUnhandledReducerError += (ctx, ex) => { + Debug.LogError($"Unhandled: {ex}"); +}; +``` + +### Complete Console Example + +```csharp +using System; +using SpacetimeDB; +using SpacetimeDB.Types; + +class Program +{ + static DbConnection? conn; + static bool running = true; + + static void Main() + { + conn = DbConnection.Builder() + .WithUri("http://localhost:3000") + .WithModuleName("chat") + .OnConnect(OnConnect) + .OnConnectError(err => Console.Error.WriteLine($"Failed: {err}")) + .OnDisconnect((c, err) => running = false) + .Build(); + + while (running) + { + conn.FrameTick(); + Thread.Sleep(16); + } + } + + static void OnConnect(DbConnection conn, Identity id, string token) + { + Console.WriteLine($"Connected as {id}"); + + // Set up callbacks + conn.Db.Message.OnInsert += (ctx, msg) => { + Console.WriteLine($"[{msg.Sender}]: {msg.Text}"); + }; + + conn.Reducers.OnSendMessage += (ctx, text) => { + if (ctx.Event.Status is Status.Failed(var reason)) + { + Console.Error.WriteLine($"Send failed: {reason}"); + } + }; + + // Subscribe + conn.SubscriptionBuilder() + .OnApplied(ctx => { + Console.WriteLine("Ready! Type messages:"); + StartInputLoop(ctx); + }) + .Subscribe(new[] { "SELECT * FROM message" }); + } + + static void StartInputLoop(SubscriptionEventContext ctx) + { + Task.Run(() => { + while (running) + { + var input = Console.ReadLine(); + if (!string.IsNullOrEmpty(input)) + { + ctx.Reducers.SendMessage(input); + } + } + }); + } +} +``` + +### Common Patterns + +#### Optimistic Updates + +```csharp +// Show immediate feedback, correct on server response +void SendMessage(string text) +{ + // Optimistic: show immediately + AddMessageToUI(myIdentity, text, isPending: true); + + // Send to server + conn.Reducers.SendMessage(text); +} + +conn.Reducers.OnSendMessage += (ctx, text) => { + if (ctx.Event.CallerIdentity == conn.Identity) + { + if (ctx.Event.Status is Status.Committed) + { + // Confirm the pending message + ConfirmPendingMessage(text); + } + else + { + // Remove failed message + RemovePendingMessage(text); + } + } +}; +``` + +#### Local Player Detection + +```csharp +conn.Db.Player.OnInsert += (ctx, player) => { + bool isLocalPlayer = player.Identity == ctx.Identity; + + if (isLocalPlayer) + { + // This is our player + SetupLocalPlayerController(player); + } + else + { + // Remote player + SpawnRemotePlayer(player); + } +}; +``` + +#### Waiting for Specific Data + +```csharp +async Task WaitForPlayerAsync(Identity playerId) +{ + var tcs = new TaskCompletionSource(); + + void Handler(EventContext ctx, Player player) + { + if (player.Identity == playerId) + { + tcs.TrySetResult(player); + conn.Db.Player.OnInsert -= Handler; + } + } + + // Check if already exists + var existing = conn.Db.Player.Identity.Find(playerId); + if (existing != null) return existing; + + conn.Db.Player.OnInsert += Handler; + return await tcs.Task; +} +``` + +--- + +## Troubleshooting + +### Connection Issues + +- **"Connection refused"**: Check server is running at the specified URI +- **"Module not found"**: Verify module name matches published database +- **Timeout**: Check firewall/network, ensure `FrameTick()` is being called + +### No Callbacks Firing + +- **Check `FrameTick()`**: Must be called regularly +- **Check subscription**: Ensure `OnApplied` fired successfully +- **Check callback registration**: Register before subscribing + +### Data Not Appearing + +- **Check SQL syntax**: Invalid queries fail silently +- **Check table visibility**: Tables must be `Public = true` in the module +- **Check subscription scope**: Only subscribed rows are cached + +### Unity-Specific + +- **NullReferenceException in Update**: Guard with `conn?.FrameTick()` +- **Missing types**: Regenerate bindings after module changes +- **Assembly errors**: Ensure SpacetimeDB assemblies are in correct folder + +### Build Issues + +- **WASI compilation fails**: Ensure .NET 8 SDK (not 9+), install WASI workload +- **Publish fails silently**: Ensure project is named `StdbModule.csproj` +- **Generated code errors**: Ensure all tables/types have `partial` keyword + +--- + +## References + +- [C# SDK Reference](https://spacetimedb.com/docs/sdks/c-sharp) +- [Unity Tutorial](https://spacetimedb.com/docs/unity/part-1) +- [SpacetimeDB SQL Reference](https://spacetimedb.com/docs/sql) +- [GitHub: Unity Demo (Blackholio)](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) diff --git a/skills/spacetimedb-rust/SKILL.md b/skills/spacetimedb-rust/SKILL.md new file mode 100644 index 00000000000..0a5b149911e --- /dev/null +++ b/skills/spacetimedb-rust/SKILL.md @@ -0,0 +1,894 @@ +--- +name: spacetimedb-rust +description: Develop SpacetimeDB server modules in Rust. Use when writing reducers, tables, or module logic. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "1.1" +--- + +# SpacetimeDB Rust Module Development + +SpacetimeDB modules are WebAssembly applications that run inside the database. They define tables to store data and reducers to modify data. Clients connect directly to the database and execute application logic inside it. + +> **Tested with:** SpacetimeDB runtime 1.11.x, `spacetimedb` crate 1.1.x + +--- + +## HALLUCINATED APIs — DO NOT USE + +**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** + +```rust +// WRONG — these macros/attributes don't exist +#[spacetimedb::table] // Use #[table] after importing +#[spacetimedb::reducer] // Use #[reducer] after importing +#[derive(Table)] // Tables use #[table] attribute, not derive +#[derive(Reducer)] // Reducers use #[reducer] attribute + +// WRONG — SpacetimeType on tables +#[derive(SpacetimeType)] // DO NOT use on #[table] structs! +#[table(name = my_table)] +pub struct MyTable { ... } + +// WRONG — mutable context +pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext + +// WRONG — table access without parentheses +ctx.db.player // Should be ctx.db.player() +ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id) +``` + +### CORRECT PATTERNS: + +```rust +// CORRECT IMPORTS +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +use spacetimedb::SpacetimeType; // Only for custom types, NOT tables + +// CORRECT TABLE — no SpacetimeType derive! +#[table(name = player, public)] +pub struct Player { + #[primary_key] + pub id: u64, + pub name: String, +} + +// CORRECT REDUCER — immutable context reference +#[reducer] +pub fn create_player(ctx: &ReducerContext, name: String) { + ctx.db.player().insert(Player { id: 0, name }); +} + +// CORRECT TABLE ACCESS — methods with parentheses +let player = ctx.db.player().id().find(&player_id); +``` + +### DO NOT: +- **Derive `SpacetimeType` on `#[table]` structs** — the macro handles this +- **Use mutable context** — `&ReducerContext`, not `&mut ReducerContext` +- **Forget `Table` trait import** — required for table operations +- **Use field access for tables** — `ctx.db.player()` not `ctx.db.player` + +--- + +## Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros | +| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" | +| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index | +| `&mut ReducerContext` | `&ReducerContext` | Wrong context type | +| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" | +| `#[table(name = "my_table")]` | `#[table(name = my_table)]` | String literals not allowed | +| Missing `public` on table | Add `public` flag | Clients can't subscribe | +| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path | +| Network/filesystem in reducer | Use procedures instead | Sandbox violation | +| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed | + +### Client-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| Wrong crate name | `spacetimedb-sdk` | Dependency not found | +| Manual event loop | Use `tokio` runtime | Async issues | + +--- + +## Hard Requirements + +1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this +2. **Import `Table` trait** — required for all table operations +3. **Use `&ReducerContext`** — not `&mut ReducerContext` +4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table` +5. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG +6. **Use `ctx.random()` or `ctx.rng`** — not `rand` crate for random numbers +7. **Add `public` flag** — if clients need to subscribe to a table + +--- + +## Project Setup + +### Cargo.toml Requirements + +```toml +[package] +name = "my-module" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = "1.0" +log = "0.4" +``` + +The `crate-type = ["cdylib"]` is required for WebAssembly compilation. + +### Essential Imports + +```rust +use spacetimedb::{ReducerContext, Table}; +``` + +Additional imports as needed: +```rust +use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt}; +use spacetimedb::sats::{i256, u256}; // For 256-bit integers +``` + +## Table Definitions + +Tables store data in SpacetimeDB. Define tables using the `#[spacetimedb::table]` macro on a struct. + +### Basic Table + +```rust +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + #[auto_inc] + id: u64, + name: String, + score: u32, +} +``` + +### Table Attributes + +| Attribute | Description | +|-----------|-------------| +| `name = identifier` | Required. The table name used in `ctx.db.{name}()` | +| `public` | Makes table visible to clients via subscriptions | +| `scheduled(reducer_name)` | Creates a schedule table that triggers the named reducer | +| `index(name = idx, btree(columns = [a, b]))` | Creates a multi-column index | + +### Column Attributes + +| Attribute | Description | +|-----------|-------------| +| `#[primary_key]` | Unique identifier for the row (one per table max) | +| `#[unique]` | Enforces uniqueness, enables `find()` method | +| `#[auto_inc]` | Auto-generates unique integer values when inserting 0 | +| `#[index(btree)]` | Creates a B-tree index for efficient lookups | +| `#[default(value)]` | Default value for migrations (must be const-evaluable) | + +### Supported Column Types + +**Primitives**: `u8`, `u16`, `u32`, `u64`, `u128`, `u256`, `i8`, `i16`, `i32`, `i64`, `i128`, `i256`, `f32`, `f64`, `bool`, `String` + +**SpacetimeDB Types**: `Identity`, `ConnectionId`, `Timestamp`, `Uuid`, `ScheduleAt` + +**Collections**: `Vec`, `Option`, `Result` where inner types are also supported + +**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]` + +### Insert Returns the Row + +```rust +// Insert and get the auto-generated ID +let row = ctx.db.task().insert(Task { + id: 0, // Placeholder for auto_inc + owner_id: ctx.sender, + title: "New task".to_string(), + created_at: ctx.timestamp, +}); +let new_id = row.id; // Get the actual ID +``` + +--- + +## Data Visibility and Row-Level Security + +**`public` flag exposes ALL rows to ALL clients.** + +| Scenario | Pattern | +|----------|---------| +| Everyone sees all rows | `#[table(name = x, public)]` | +| Users see only their data | Private table + row-level security | + +### Private Table (default) + +```rust +// No public flag — only server can read +#[table(name = secret_data)] +pub struct SecretData { ... } +``` + +### Row-Level Security (RLS) + +Use RLS to filter which rows each client can see: + +```rust +// Use row-level security for per-user visibility +#[table(name = player_data, public)] +#[rls(filter = |ctx, row| row.owner_id == ctx.sender)] +pub struct PlayerData { + #[primary_key] + pub id: u64, + pub owner_id: Identity, + pub data: String, +} +``` + +With RLS, clients can subscribe to the table but only see rows where the filter returns `true` for their identity. + +--- + +## Reducers + +Reducers are transactional functions that modify database state. They run inside the database and are the only way to mutate tables. + +### Basic Reducer + +```rust +#[spacetimedb::reducer] +pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> { + if name.is_empty() { + return Err("Name cannot be empty".to_string()); + } + + ctx.db.player().insert(Player { + id: 0, // auto_inc assigns the value + name, + score: 0, + }); + + Ok(()) +} +``` + +### Reducer Rules + +1. First parameter must be `&ReducerContext` +2. Additional parameters must implement `SpacetimeType` +3. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display` +4. All changes roll back on panic or `Err` return +5. Reducers run in isolation from concurrent reducers +6. Cannot make network requests or access filesystem +7. Must import `Table` trait for table operations: `use spacetimedb::Table;` + +## ReducerContext + +The `ReducerContext` provides access to the database and caller information. + +### Properties + +```rust +#[spacetimedb::reducer] +pub fn example(ctx: &ReducerContext) { + // Database access + let _table = ctx.db.player(); + + // Caller identity (always present) + let caller: Identity = ctx.sender; + + // Connection ID (None for scheduled/system reducers) + let conn: Option = ctx.connection_id; + + // Invocation timestamp + let when: Timestamp = ctx.timestamp; + + // Module's own identity + let module_id: Identity = ctx.identity(); + + // Random number generation (deterministic) + let random_val: u32 = ctx.random(); + + // UUID generation + let uuid = ctx.new_uuid_v4().unwrap(); // Random UUID + let uuid = ctx.new_uuid_v7().unwrap(); // Timestamp-based UUID + + // Check if caller is internal (scheduled reducer) + if ctx.sender_auth().is_internal() { + // Called by scheduler, not external client + } +} +``` + +## Table Operations + +### Insert + +```rust +// Insert returns the row with auto_inc values populated +let player = ctx.db.player().insert(Player { + id: 0, // auto_inc fills this + name: "Alice".to_string(), + score: 100, +}); +log::info!("Created player with id: {}", player.id); +``` + +### Find by Unique/Primary Key + +```rust +// find() returns Option +if let Some(player) = ctx.db.player().id().find(123) { + log::info!("Found: {}", player.name); +} +``` + +### Filter by Indexed Column + +```rust +// filter() returns an iterator +for player in ctx.db.player().name().filter("Alice") { + log::info!("Player {}: score {}", player.id, player.score); +} + +// Range queries (Rust range syntax) +for player in ctx.db.player().score().filter(50..=100) { + log::info!("{} has score {}", player.name, player.score); +} +``` + +### Update + +Updates require a unique column. Find the row, modify it, then call `update()`: + +```rust +if let Some(mut player) = ctx.db.player().id().find(123) { + player.score += 10; + ctx.db.player().id().update(player); +} +``` + +### Delete + +```rust +// Delete by unique key +ctx.db.player().id().delete(&123); + +// Delete by indexed column (returns count) +let deleted = ctx.db.player().name().delete("Alice"); +log::info!("Deleted {} rows", deleted); + +// Delete by range +ctx.db.player().score().delete(..50); // Delete all with score < 50 +``` + +### Iterate All Rows + +```rust +for player in ctx.db.player().iter() { + log::info!("{}: {}", player.name, player.score); +} + +// Count rows +let total = ctx.db.player().count(); +``` + +## Indexes + +### Single-Column Index + +```rust +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + id: u64, + #[index(btree)] + level: u32, + name: String, +} +``` + +### Multi-Column Index + +```rust +#[spacetimedb::table( + name = score, + public, + index(name = by_player_level, btree(columns = [player_id, level])) +)] +pub struct Score { + player_id: u32, + level: u32, + points: i64, +} +``` + +### Querying Multi-Column Indexes + +```rust +// Prefix match (first column only) +for score in ctx.db.score().by_player_level().filter(&123u32) { + log::info!("Level {}: {} points", score.level, score.points); +} + +// Full match +for score in ctx.db.score().by_player_level().filter((123u32, 5u32)) { + log::info!("Points: {}", score.points); +} + +// Prefix with range on second column +for score in ctx.db.score().by_player_level().filter((123u32, 1u32..=10u32)) { + log::info!("Level {}: {} points", score.level, score.points); +} +``` + +## Identity and Authentication + +### Storing User Identity + +```rust +#[spacetimedb::table(name = user_profile, public)] +pub struct UserProfile { + #[primary_key] + identity: Identity, + display_name: String, + created_at: Timestamp, +} + +#[spacetimedb::reducer] +pub fn create_profile(ctx: &ReducerContext, display_name: String) -> Result<(), String> { + // Check if profile already exists + if ctx.db.user_profile().identity().find(ctx.sender).is_some() { + return Err("Profile already exists".to_string()); + } + + ctx.db.user_profile().insert(UserProfile { + identity: ctx.sender, + display_name, + created_at: ctx.timestamp, + }); + + Ok(()) +} +``` + +### Verifying Caller Identity + +```rust +#[spacetimedb::reducer] +pub fn update_my_profile(ctx: &ReducerContext, new_name: String) -> Result<(), String> { + // Only allow users to update their own profile + if let Some(mut profile) = ctx.db.user_profile().identity().find(ctx.sender) { + profile.display_name = new_name; + ctx.db.user_profile().identity().update(profile); + Ok(()) + } else { + Err("Profile not found".to_string()) + } +} +``` + +## Lifecycle Reducers + +### Init Reducer + +Runs once when the module is first published or database is cleared: + +```rust +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Database initializing..."); + + // Set up default data + if ctx.db.config().count() == 0 { + ctx.db.config().insert(Config { + key: "version".to_string(), + value: "1.0.0".to_string(), + }); + } + + Ok(()) +} +``` + +### Client Connected + +Runs when a client establishes a connection: + +```rust +#[spacetimedb::reducer(client_connected)] +pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Client connected: {}", ctx.sender); + + // connection_id is guaranteed to be Some + let conn_id = ctx.connection_id.unwrap(); + + // Create or update user session + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: true, ..user }); + } else { + ctx.db.user().insert(User { + identity: ctx.sender, + online: true, + name: None, + }); + } + + Ok(()) +} +``` + +### Client Disconnected + +Runs when a client connection terminates: + +```rust +#[spacetimedb::reducer(client_disconnected)] +pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Client disconnected: {}", ctx.sender); + + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); + } + + Ok(()) +} +``` + +## Scheduled Reducers + +Schedule reducers to run at specific times or intervals. + +### Define a Schedule Table + +```rust +use spacetimedb::ScheduleAt; +use std::time::Duration; + +#[spacetimedb::table(name = game_tick_schedule, scheduled(game_tick))] +pub struct GameTickSchedule { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: ScheduleAt, +} + +#[spacetimedb::reducer] +fn game_tick(ctx: &ReducerContext, schedule: GameTickSchedule) { + // Verify this is an internal call (from scheduler) + if !ctx.sender_auth().is_internal() { + log::warn!("External call to scheduled reducer rejected"); + return; + } + + // Game logic here + log::info!("Game tick at {:?}", ctx.timestamp); +} +``` + +### Scheduling at Intervals + +```rust +#[spacetimedb::reducer] +fn start_game_loop(ctx: &ReducerContext) { + // Schedule game tick every 100ms + ctx.db.game_tick_schedule().insert(GameTickSchedule { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()), + }); +} +``` + +### Scheduling at Specific Times + +```rust +#[spacetimedb::reducer] +fn schedule_reminder(ctx: &ReducerContext, delay_secs: u64) { + let run_at = ctx.timestamp + Duration::from_secs(delay_secs); + + ctx.db.reminder_schedule().insert(ReminderSchedule { + scheduled_id: 0, + scheduled_at: ScheduleAt::Time(run_at), + message: "Time's up!".to_string(), + }); +} +``` + +--- + +## Procedures (Beta) + +**Procedures are for side effects (HTTP, filesystem) that reducers can't do.** + +Procedures are currently unstable. Enable with: + +```toml +# Cargo.toml +[dependencies] +spacetimedb = { version = "1.*", features = ["unstable"] } +``` + +```rust +use spacetimedb::{procedure, ProcedureContext}; + +// Simple procedure +#[procedure] +fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 { + a as u64 + b as u64 +} + +// Procedure with database access +#[procedure] +fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { + // HTTP request (allowed in procedures, not reducers) + let data = fetch_from_url(&url)?; + + // Database access requires explicit transaction + ctx.try_with_tx(|tx| { + tx.db.external_data().insert(ExternalData { + id: 0, + content: data, + }); + Ok(()) + })?; + + Ok(()) +} +``` + +### Key Differences from Reducers + +| Reducers | Procedures | +|----------|------------| +| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | +| Direct `ctx.db` access | Must use `ctx.with_tx()` | +| No HTTP/network | HTTP allowed | +| No return values | Can return data | + +--- + +## Error Handling + +### Sender Errors (Expected) + +Return errors for invalid client input: + +```rust +#[spacetimedb::reducer] +pub fn transfer_credits( + ctx: &ReducerContext, + to_user: Identity, + amount: u32, +) -> Result<(), String> { + let sender = ctx.db.user().identity().find(ctx.sender) + .ok_or("Sender not found")?; + + if sender.credits < amount { + return Err("Insufficient credits".to_string()); + } + + // Perform transfer... + Ok(()) +} +``` + +### Programmer Errors (Bugs) + +Use panic for unexpected states that indicate bugs: + +```rust +#[spacetimedb::reducer] +pub fn process_data(ctx: &ReducerContext, data: Vec) { + // This should never happen - indicates a bug + assert!(!data.is_empty(), "Unexpected empty data"); + + // Use expect for operations that should always succeed + let parsed = parse_data(&data).expect("Failed to parse data"); +} +``` + +## Custom Types + +Define custom types using `#[derive(SpacetimeType)]`: + +```rust +use spacetimedb::SpacetimeType; + +#[derive(SpacetimeType)] +pub enum PlayerStatus { + Active, + Idle, + Away, +} + +#[derive(SpacetimeType)] +pub struct Position { + x: f32, + y: f32, + z: f32, +} + +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + id: u64, + status: PlayerStatus, + position: Position, +} +``` + +## Multiple Tables from Same Type + +Apply multiple `#[spacetimedb::table]` attributes to create separate tables with the same schema: + +```rust +#[spacetimedb::table(name = online_player, public)] +#[spacetimedb::table(name = offline_player)] +pub struct Player { + #[primary_key] + identity: Identity, + name: String, +} + +#[spacetimedb::reducer] +fn player_logout(ctx: &ReducerContext) { + if let Some(player) = ctx.db.online_player().identity().find(ctx.sender) { + ctx.db.offline_player().insert(player.clone()); + ctx.db.online_player().identity().delete(&ctx.sender); + } +} +``` + +## Logging + +Use the `log` crate for debug output. View logs with `spacetime logs `: + +```rust +log::trace!("Detailed trace info"); +log::debug!("Debug information"); +log::info!("General information"); +log::warn!("Warning message"); +log::error!("Error occurred"); +``` + +Never use `println!`, `eprintln!`, or `dbg!` in modules. + +## Common Patterns + +### Player Session Management + +```rust +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + identity: Identity, + name: Option, + online: bool, + last_seen: Timestamp, +} + +#[spacetimedb::reducer(client_connected)] +pub fn on_connect(ctx: &ReducerContext) { + match ctx.db.player().identity().find(ctx.sender) { + Some(player) => { + ctx.db.player().identity().update(Player { + online: true, + last_seen: ctx.timestamp, + ..player + }); + } + None => { + ctx.db.player().insert(Player { + identity: ctx.sender, + name: None, + online: true, + last_seen: ctx.timestamp, + }); + } + } +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn on_disconnect(ctx: &ReducerContext) { + if let Some(player) = ctx.db.player().identity().find(ctx.sender) { + ctx.db.player().identity().update(Player { + online: false, + last_seen: ctx.timestamp, + ..player + }); + } +} +``` + +### Sequential ID Generation (Gap-Free) + +Auto-increment may have gaps after crashes. For strictly sequential IDs: + +```rust +#[spacetimedb::table(name = counter)] +pub struct Counter { + #[primary_key] + name: String, + value: u64, +} + +#[spacetimedb::reducer] +fn create_invoice(ctx: &ReducerContext, amount: u64) -> Result<(), String> { + let mut counter = ctx.db.counter().name().find(&"invoice".to_string()) + .unwrap_or(Counter { name: "invoice".to_string(), value: 0 }); + + counter.value += 1; + ctx.db.counter().name().update(counter.clone()); + + ctx.db.invoice().insert(Invoice { + invoice_number: counter.value, + amount, + }); + + Ok(()) +} +``` + +### Owner-Only Reducers + +```rust +#[spacetimedb::table(name = admin)] +pub struct Admin { + #[primary_key] + identity: Identity, +} + +#[spacetimedb::reducer] +fn admin_action(ctx: &ReducerContext) -> Result<(), String> { + if ctx.db.admin().identity().find(ctx.sender).is_none() { + return Err("Not authorized".to_string()); + } + + // Admin-only logic here + Ok(()) +} +``` + +## Build and Deploy + +```bash +# Build the module +spacetime build + +# Deploy to local instance +spacetime publish my_database + +# Deploy with database clear (DESTROYS DATA) +spacetime publish my_database --delete-data + +# View logs +spacetime logs my_database + +# Call a reducer +spacetime call my_database create_player "Alice" + +# Run SQL query +spacetime sql my_database "SELECT * FROM player" + +# Generate bindings +spacetime generate --lang rust --out-dir /src/module_bindings --project-path +``` + +## Important Constraints + +1. **No Global State**: Static/global variables are undefined behavior across reducer calls +2. **No Side Effects**: Reducers cannot make network requests or file I/O +3. **Deterministic Execution**: Use `ctx.random()` and `ctx.new_uuid_*()` for randomness +4. **Transactional**: All reducer changes roll back on failure +5. **Isolated**: Reducers don't see concurrent changes until commit diff --git a/skills/spacetimedb-typescript/SKILL.md b/skills/spacetimedb-typescript/SKILL.md new file mode 100644 index 00000000000..8a52b871182 --- /dev/null +++ b/skills/spacetimedb-typescript/SKILL.md @@ -0,0 +1,1003 @@ +--- +name: spacetimedb-typescript +description: Build TypeScript clients for SpacetimeDB. Use when connecting to SpacetimeDB from web apps, Node.js, Deno, Bun, or other JavaScript runtimes. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "1.0" +--- + +# SpacetimeDB TypeScript SDK + +Build real-time TypeScript clients that connect directly to SpacetimeDB modules. The SDK provides type-safe database access, automatic synchronization, and reactive updates for web apps, Node.js, Deno, Bun, and other JavaScript runtimes. + +--- + +## HALLUCINATED APIs — DO NOT USE + +**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** + +```typescript +// WRONG PACKAGE — does not exist +import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk"; + +// WRONG — these methods don't exist +SpacetimeDBClient.connect(...); +SpacetimeDBClient.call("reducer_name", [...]); +connection.call("reducer_name", [arg1, arg2]); + +// WRONG — positional reducer arguments +conn.reducers.doSomething("value"); // WRONG! +``` + +### CORRECT PATTERNS: + +```typescript +// CORRECT IMPORTS +import { DbConnection, tables } from './module_bindings'; // Generated! +import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react'; + +// CORRECT REDUCER CALLS — object syntax, not positional! +conn.reducers.doSomething({ value: 'test' }); +conn.reducers.updateItem({ itemId: 1n, newValue: 42 }); + +// CORRECT DATA ACCESS — useTable returns [rows, isLoading] +const [items, isLoading] = useTable(tables.item); +``` + +### DO NOT: +- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)` +- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings` + +--- + +## Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| Missing `package.json` | Create `package.json` | "could not detect language" | +| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" | +| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle | +| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error | +| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error | +| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" | +| `.filter()` on unique column | `.find()` on unique column | TypeError | +| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" | +| `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID | +| `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" | +| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" | +| Multi-column index `.filter()` | Use single-column index | PANIC or silent empty results | +| `.iter()` in views | Use index lookups only | Views can't scan tables | +| `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions | +| `ctx.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable | + +### Client-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath | +| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax | +| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render | +| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring | +| Optimistic UI updates | Let subscriptions drive state | Desync issues | +| `` | `connectionBuilder={...}` | Wrong prop name | + +--- + +## Hard Requirements + +1. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +2. **Reducers are transactional** — they do not return data +3. **Reducers must be deterministic** — no filesystem, network, timers, random +4. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args +5. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb` +6. **useTable returns a tuple** — `const [rows, isLoading] = useTable(tables.myTable)` +7. **Memoize connectionBuilder** — wrap in `useMemo(() => ..., [])` to prevent reconnects +8. **Views can only use index lookups** — `.iter()` is not allowed in views + +--- + +## Installation + +```bash +npm install spacetimedb +# or +pnpm add spacetimedb +# or +yarn add spacetimedb +``` + +For Node.js 18-21, install the `undici` peer dependency: + +```bash +npm install spacetimedb undici +``` + +Node.js 22+ and browser environments work out of the box. + +## Generating Type Bindings + +Before using the SDK, generate TypeScript bindings from your SpacetimeDB module: + +```bash +spacetime generate --lang typescript --out-dir ./src/module_bindings --project-path ./server +``` + +This creates a `module_bindings` directory with: +- `index.ts` - Main exports including `DbConnection`, `tables`, `reducers`, `query` +- Type definitions for each table (e.g., `player_table.ts`, `user_table.ts`) +- Type definitions for each reducer (e.g., `create_player_reducer.ts`) +- Custom type definitions (e.g., `point_type.ts`) + +## Basic Connection Setup + +```typescript +import { DbConnection } from './module_bindings'; + +const connection = DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_database') + .onConnect((conn, identity, token) => { + console.log('Connected with identity:', identity.toHexString()); + + // Store token for reconnection + localStorage.setItem('spacetimedb_token', token); + + // Subscribe to tables after connection + conn.subscriptionBuilder().subscribe('SELECT * FROM player'); + }) + .onDisconnect((ctx) => { + console.log('Disconnected'); + }) + .onConnectError((ctx, error) => { + console.error('Connection error:', error); + }) + .build(); +``` + +## Connection Builder Options + +```typescript +DbConnection.builder() + // Required: SpacetimeDB server URI + .withUri('ws://localhost:3000') + + // Required: Database module name or address + .withModuleName('my_database') + + // Optional: Authentication token for reconnection + .withToken(localStorage.getItem('spacetimedb_token') ?? undefined) + + // Optional: Enable compression (default: 'gzip') + .withCompression('gzip') // or 'none' + + // Optional: Light mode reduces network traffic + .withLightMode(true) + + // Optional: Wait for durable writes before receiving updates + .withConfirmedReads(true) + + // Connection lifecycle callbacks + .onConnect((conn, identity, token) => { /* ... */ }) + .onDisconnect((ctx, error) => { /* ... */ }) + .onConnectError((ctx, error) => { /* ... */ }) + + .build(); +``` + +## Subscribing to Tables + +Subscriptions sync table data to the client cache. Use SQL queries to filter what data you receive. + +### Basic Subscription + +```typescript +connection.subscriptionBuilder() + .onApplied((ctx) => { + console.log('Subscription applied, cache is ready'); + }) + .onError((ctx, error) => { + console.error('Subscription error:', error); + }) + .subscribe('SELECT * FROM player'); +``` + +### Multiple Queries + +```typescript +connection.subscriptionBuilder() + .subscribe([ + 'SELECT * FROM player', + 'SELECT * FROM game_state', + 'SELECT * FROM message WHERE room_id = 1' + ]); +``` + +### Typed Query Builder + +Use the generated `query` object for type-safe queries: + +```typescript +import { query } from './module_bindings'; + +// Simple query - selects all rows +connection.subscriptionBuilder() + .subscribe(query.player.build()); + +// Query with WHERE clause +connection.subscriptionBuilder() + .subscribe( + query.player + .where(row => row.name.eq('Alice')) + .build() + ); + +// Complex conditions +connection.subscriptionBuilder() + .subscribe( + query.player + .where(row => row.score.gte(100)) + .where(row => row.isActive.eq(true)) + .build() + ); +``` + +### Subscribe to All Tables + +For development or small datasets: + +```typescript +connection.subscriptionBuilder().subscribeToAllTables(); +``` + +### Unsubscribing + +```typescript +const handle = connection.subscriptionBuilder() + .onApplied(() => console.log('Subscribed')) + .subscribe('SELECT * FROM player'); + +// Later, unsubscribe +handle.unsubscribe(); + +// Or with callback when complete +handle.unsubscribeThen((ctx) => { + console.log('Unsubscribed successfully'); +}); +``` + +## Accessing Table Data + +After subscription, access cached data through `connection.db`: + +```typescript +// Iterate all rows +for (const player of connection.db.player.iter()) { + console.log(player.name, player.score); +} + +// Convert to array +const players = Array.from(connection.db.player.iter()); + +// Count rows +const count = connection.db.player.count(); + +// Find by primary key (if table has one) +const player = connection.db.player.id.find(42); + +// Find by indexed column +const alice = connection.db.player.name.find('Alice'); +``` + +## Table Event Callbacks + +Listen for real-time changes to table data: + +```typescript +// Row inserted +connection.db.player.onInsert((ctx, player) => { + console.log('New player:', player.name); +}); + +// Row deleted +connection.db.player.onDelete((ctx, player) => { + console.log('Player left:', player.name); +}); + +// Row updated (requires primary key on table) +connection.db.player.onUpdate((ctx, oldPlayer, newPlayer) => { + console.log(`${oldPlayer.name} score: ${oldPlayer.score} -> ${newPlayer.score}`); +}); + +// Remove callbacks +const onInsertCb = (ctx, player) => console.log(player); +connection.db.player.onInsert(onInsertCb); +connection.db.player.removeOnInsert(onInsertCb); +``` + +### Event Context + +Callbacks receive an `EventContext` with information about the event: + +```typescript +connection.db.player.onInsert((ctx, player) => { + // Access to database + const allPlayers = Array.from(ctx.db.player.iter()); + + // Check event type + if (ctx.event.tag === 'Reducer') { + const { callerIdentity, reducer, status } = ctx.event.value; + console.log(`Triggered by reducer: ${reducer.name}`); + } + + // Call other reducers + ctx.reducers.sendMessage({ playerId: player.id, text: 'Welcome!' }); +}); +``` + +## Calling Reducers + +Reducers are server-side functions that modify the database. **CRITICAL: Use object syntax, not positional arguments.** + +```typescript +// CORRECT: Object syntax +connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } }); + +// WRONG: Positional arguments +// connection.reducers.createPlayer('Alice', { x: 0, y: 0 }); // DO NOT DO THIS + +// Listen for reducer results +connection.reducers.onCreatePlayer((ctx, args) => { + const { callerIdentity, status, timestamp, energyConsumed } = ctx.event; + + if (status.tag === 'Committed') { + console.log('Player created successfully'); + } else if (status.tag === 'Failed') { + console.error('Failed:', status.value); + } +}); + +// Remove reducer callback +connection.reducers.removeOnCreatePlayer(callback); +``` + +### Snake_case to camelCase conversion +- Server: `spacetimedb.reducer('do_something', ...)` +- Client: `conn.reducers.doSomething({ ... })` + +### Reducer Flags + +Control how the server handles reducer calls: + +```typescript +// NoSuccessNotify: Don't send TransactionUpdate on success (reduces traffic) +connection.setReducerFlags.movePlayer('NoSuccessNotify'); + +// FullUpdate: Always send full TransactionUpdate (default) +connection.setReducerFlags.movePlayer('FullUpdate'); +``` + +## Views + +Views provide filtered access to private table data based on the connected user. + +### ViewContext vs AnonymousViewContext + +```typescript +// ViewContext — has ctx.sender, result varies per user (computed per-subscriber) +spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => { + return [...ctx.db.item.by_owner.filter(ctx.sender)]; +}); + +// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf) +spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => { + return [...ctx.db.player.by_score.filter(/* top scores */)]; +}); +``` + +### CRITICAL: Views can only use index lookups + +```typescript +// WRONG — views cannot use .iter() +spacetimedb.view( + { name: 'my_data_wrong', public: true }, + t.array(PrivateData.rowType), + (ctx) => [...ctx.db.privateData.iter()] // NOT ALLOWED +); + +// RIGHT — use index lookup +spacetimedb.view( + { name: 'my_data', public: true }, + t.array(PrivateData.rowType), + (ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)] +); +``` + +### Subscribing to Views + +Views require explicit subscription: + +```typescript +conn.subscriptionBuilder().subscribe([ + 'SELECT * FROM public_table', + 'SELECT * FROM my_data', // Views need explicit SQL! +]); +``` + +## Procedures (Beta) + +**Procedures are for side effects (HTTP requests, etc.) that reducers can't do.** + +Procedures are currently in beta. API may change. + +### Defining a procedure + +```typescript +spacetimedb.procedure( + 'fetch_external_data', + { url: t.string() }, + t.string(), // return type + (ctx, { url }) => { + const response = ctx.http.fetch(url); + return response.text(); + } +); +``` + +### CRITICAL: Database access in procedures + +**Procedures don't have `ctx.db`. Use `ctx.withTx()` for database access.** + +```typescript +spacetimedb.procedure('save_fetched_data', { url: t.string() }, t.unit(), (ctx, { url }) => { + // Fetch external data (outside transaction) + const response = ctx.http.fetch(url); + const data = response.text(); + + // WRONG — ctx.db doesn't exist in procedures + // ctx.db.myTable.insert({ ... }); + + // RIGHT — use ctx.withTx() for database access + ctx.withTx(tx => { + tx.db.myTable.insert({ + id: 0n, + content: data, + fetchedAt: tx.timestamp, + fetchedBy: tx.sender, + }); + }); + + return {}; +}); +``` + +### Key differences from reducers + +| Reducers | Procedures | +|----------|------------| +| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` | +| Automatic transaction | Manual transaction management | +| No HTTP/network | `ctx.http.fetch()` available | +| No return values to caller | Can return data to caller | + +## Identity and Authentication + +```typescript +import { Identity } from 'spacetimedb'; + +// Get current identity +const identity = connection.identity; +console.log(identity?.toHexString()); + +// Compare identities +if (identity?.isEqual(otherIdentity)) { + console.log('Same user'); +} + +// Create from hex string +const parsed = Identity.fromString('0x1234...'); + +// Zero identity +const zero = Identity.zero(); + +// Compare identities using toHexString() +const isOwner = row.ownerId.toHexString() === myIdentity.toHexString(); +``` + +### Persisting Authentication + +```typescript +// On connect, save the token +.onConnect((conn, identity, token) => { + localStorage.setItem('auth_token', token); + localStorage.setItem('identity', identity.toHexString()); +}) + +// On reconnect, use saved token +.withToken(localStorage.getItem('auth_token') ?? undefined) +``` + +### Stale token handling + +```typescript +const onConnectError = (_ctx: ErrorContext, err: Error) => { + if (err.message?.includes('Unauthorized') || err.message?.includes('401')) { + localStorage.removeItem('auth_token'); + window.location.reload(); + } +}; +``` + +## React Integration + +The SDK includes React hooks for reactive UI updates. + +### Provider Setup + +```tsx +import React, { useMemo } from 'react'; +import ReactDOM from 'react-dom/client'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; +import { DbConnection, query } from './module_bindings'; +import App from './App'; + +function Root() { + // CRITICAL: Memoize to prevent reconnects on every render + const connectionBuilder = useMemo(() => + DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_game') + .withToken(localStorage.getItem('auth_token') || undefined) + .onConnect((conn, identity, token) => { + console.log('Connected:', identity.toHexString()); + localStorage.setItem('auth_token', token); + conn.subscriptionBuilder().subscribe(query.player.build()); + }) + .onDisconnect(() => console.log('Disconnected')) + .onConnectError((ctx, err) => console.error('Error:', err)), + [] // Empty deps - only create once + ); + + return ( + + + + ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); +``` + +### useSpacetimeDB Hook + +Access connection state: + +```tsx +import { useSpacetimeDB } from 'spacetimedb/react'; + +function ConnectionStatus() { + const { isActive, identity, token, connectionId, connectionError } = useSpacetimeDB(); + + if (connectionError) { + return
Error: {connectionError.message}
; + } + + if (!isActive) { + return
Connecting...
; + } + + return
Connected as {identity?.toHexString()}
; +} +``` + +### useTable Hook + +Subscribe to table data with reactive updates. **CRITICAL: Returns a tuple `[rows, isLoading]`.** + +```tsx +import { useTable, where, eq } from 'spacetimedb/react'; +import { tables } from './module_bindings'; + +function PlayerList() { + // CORRECT: Tuple destructuring + const [players, isLoading] = useTable(tables.player); + + if (isLoading) return
Loading...
; + + return ( +
    + {players.map(player => ( +
  • {player.name}: {player.score}
  • + ))} +
+ ); +} + +function FilteredPlayerList() { + // Filtered players with callbacks + const [activePlayers, isLoading] = useTable( + tables.player, + where(eq('isActive', true)), + { + onInsert: (player) => console.log('Player joined:', player.name), + onDelete: (player) => console.log('Player left:', player.name), + onUpdate: (oldPlayer, newPlayer) => { + console.log(`${oldPlayer.name} updated`); + }, + } + ); + + return ( +
    + {activePlayers.map(player => ( +
  • {player.name}
  • + ))} +
+ ); +} +``` + +### useReducer Hook + +Call reducers from components: + +```tsx +import { useReducer } from 'spacetimedb/react'; +import { reducers } from './module_bindings'; + +function CreatePlayerForm() { + const createPlayer = useReducer(reducers.createPlayer); + const [name, setName] = useState(''); + + const handleSubmit = (e) => { + e.preventDefault(); + // CORRECT: Object syntax + createPlayer({ name, location: { x: 0, y: 0 } }); + setName(''); + }; + + return ( +
+ setName(e.target.value)} /> + +
+ ); +} +``` + +## Vue Integration + +The SDK includes Vue composables: + +```typescript +import { SpacetimeDBProvider, useSpacetimeDB, useTable, useReducer } from 'spacetimedb/vue'; +``` + +Usage is similar to React hooks. + +## Svelte Integration + +The SDK includes Svelte stores: + +```typescript +import { SpacetimeDBProvider, useSpacetimeDB, useTable, useReducer } from 'spacetimedb/svelte'; +``` + +## Server-Side Usage (Node.js, Deno, Bun) + +The SDK works in server-side JavaScript runtimes: + +```typescript +import { DbConnection } from './module_bindings'; + +async function main() { + const connection = DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_database') + .onConnect((conn, identity, token) => { + console.log('Connected:', identity.toHexString()); + + conn.subscriptionBuilder() + .onApplied(() => { + // Process data + for (const player of conn.db.player.iter()) { + console.log(player); + } + }) + .subscribe('SELECT * FROM player'); + }) + .build(); +} + +main(); +``` + +## Timestamps + +### Server-side + +```typescript +import { Timestamp, ScheduleAt } from 'spacetimedb'; + +// Current time +ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp }); + +// Future time (add microseconds) +const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes +``` + +### Client-side (CRITICAL) + +**Timestamps are objects, not numbers:** + +```typescript +// WRONG +const date = new Date(row.createdAt); +const date = new Date(Number(row.createdAt / 1000n)); + +// RIGHT +const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n)); +``` + +### ScheduleAt on client + +```typescript +// ScheduleAt is a tagged union +if (scheduleAt.tag === 'Time') { + const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n)); +} +``` + +## Scheduled Tables + +```typescript +// Scheduled table MUST use scheduledId and scheduledAt columns +export const CleanupJob = table({ + name: 'cleanup_job', + scheduled: 'run_cleanup' // reducer name +}, { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + targetId: t.u64(), // Your custom data +}); + +// Scheduled reducer receives full row as arg +spacetimedb.reducer('run_cleanup', { arg: CleanupJob.rowType }, (ctx, { arg }) => { + // arg.scheduledId, arg.targetId available + // Row is auto-deleted after reducer completes +}); + +// Schedule a job +import { ScheduleAt } from 'spacetimedb'; +const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds +ctx.db.cleanupJob.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.time(futureTime), + targetId: someId +}); + +// Cancel a job by deleting the row +ctx.db.cleanupJob.scheduledId.delete(jobId); +``` + +## Error Handling + +### Connection Errors + +```typescript +DbConnection.builder() + .onConnectError((ctx, error) => { + console.error('Failed to connect:', error.message); + + // Implement retry logic + setTimeout(() => { + // Rebuild connection + }, 5000); + }) + .build(); +``` + +### Subscription Errors + +```typescript +connection.subscriptionBuilder() + .onError((ctx, error) => { + console.error('Subscription failed:', error.message); + }) + .subscribe('SELECT * FROM player'); +``` + +### Reducer Errors + +```typescript +connection.reducers.onCreatePlayer((ctx, args) => { + const { status } = ctx.event; + + switch (status.tag) { + case 'Committed': + console.log('Success'); + break; + case 'Failed': + console.error('Reducer failed:', status.value); + break; + case 'OutOfEnergy': + console.error('Out of energy'); + break; + } +}); +``` + +## Disconnecting + +```typescript +// Gracefully disconnect +connection.disconnect(); +``` + +## Type Reference + +### Core Types + +```typescript +import { + Identity, // User identity (256-bit) + ConnectionId, // Connection identifier + Timestamp, // SpacetimeDB timestamp + TimeDuration, // Duration type + Uuid, // UUID type +} from 'spacetimedb'; +``` + +### Generated Types + +```typescript +// From your module_bindings +import { + DbConnection, // Connection class + DbConnectionBuilder, // Builder class + SubscriptionBuilder, // Subscription builder + SubscriptionHandle, // Subscription handle + EventContext, // Event callback context + ReducerEventContext, // Reducer callback context + ErrorContext, // Error callback context + tables, // Table accessors for useTable + reducers, // Reducer definitions for useReducer + query, // Typed query builder + + // Your custom types + Player, + Point, + // ... etc +} from './module_bindings'; +``` + +## Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --project-path + +# Clear database and republish +spacetime publish --clear-database -y --project-path + +# Generate bindings +spacetime generate --lang typescript --out-dir /src/module_bindings --project-path + +# View logs +spacetime logs +``` + +## Best Practices + +1. **Store auth tokens**: Save the token from `onConnect` for seamless reconnection. + +2. **Subscribe after connect**: Set up subscriptions in the `onConnect` callback. + +3. **Use typed queries**: Prefer the `query` builder over raw SQL strings for type safety. + +4. **Handle all connection states**: Implement `onConnect`, `onDisconnect`, and `onConnectError`. + +5. **Use light mode for high-frequency updates**: Enable `.withLightMode(true)` for games or real-time apps. + +6. **Unsubscribe when done**: Clean up subscriptions when components unmount or data is no longer needed. + +7. **Use primary keys**: Define primary keys on tables to enable `onUpdate` callbacks. + +8. **Memoize connectionBuilder**: Always wrap in `useMemo()` to prevent reconnects. + +9. **Let subscriptions drive state**: Avoid optimistic updates; let the server be the source of truth. + +## Common Patterns + +### Reconnection Logic + +```typescript +function createConnection(token?: string) { + return DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my_database') + .withToken(token) + .onConnect((conn, identity, newToken) => { + localStorage.setItem('token', newToken); + setupSubscriptions(conn); + }) + .onDisconnect(() => { + // Reconnect after delay + setTimeout(() => { + createConnection(localStorage.getItem('token') ?? undefined); + }, 3000); + }) + .build(); +} +``` + +### Optimistic Updates + +```typescript +function PlayerScore({ player }) { + const updateScore = useReducer(reducers.updateScore); + const [optimisticScore, setOptimisticScore] = useState(player.score); + + const handleClick = () => { + setOptimisticScore(prev => prev + 1); + updateScore({ playerId: player.id, delta: 1 }); + }; + + // Sync with actual data + useEffect(() => { + setOptimisticScore(player.score); + }, [player.score]); + + return
Score: {optimisticScore}
; +} +``` + +### Filtering with Multiple Conditions + +```typescript +// Using query builder +query.player + .where(row => row.team.eq('red')) + .where(row => row.score.gte(100)) + .build(); + +// Using React hooks +const [redTeamHighScorers] = useTable( + tables.player, + where(eq('team', 'red')), // Additional filtering in client +); +const filtered = redTeamHighScorers.filter(p => p.score >= 100); +``` + +## Project Structure + +### Server (`backend/spacetimedb/`) +``` +src/schema.ts -> Tables, export spacetimedb +src/index.ts -> Reducers, lifecycle, import schema +package.json -> { "type": "module", "dependencies": { "spacetimedb": "^1.11.0" } } +tsconfig.json -> Standard config +``` + +### Avoiding circular imports +``` +schema.ts -> defines tables AND exports spacetimedb +index.ts -> imports spacetimedb from ./schema, defines reducers +``` + +### Client (`client/`) +``` +src/module_bindings/ -> Generated (spacetime generate) +src/main.tsx -> Provider, connection setup +src/App.tsx -> UI components +src/config.ts -> MODULE_NAME, SPACETIMEDB_URI +``` From 2b7cace27b33a45575b90ecba46ffc2ce09ebb3c Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:36:21 -0500 Subject: [PATCH 4/4] Remove .claude/skills - keep only skills/ for distribution Skills should only be in skills/ directory for agentskills.io distribution. Users install to their own .claude/skills/ via npx skills add. --- .claude/skills/spacetimedb-cli/SKILL.md | 239 --- .claude/skills/spacetimedb-concepts/SKILL.md | 517 ------ .claude/skills/spacetimedb-csharp/SKILL.md | 1462 ----------------- .claude/skills/spacetimedb-rust/SKILL.md | 894 ---------- .../skills/spacetimedb-typescript/SKILL.md | 1003 ----------- 5 files changed, 4115 deletions(-) delete mode 100644 .claude/skills/spacetimedb-cli/SKILL.md delete mode 100644 .claude/skills/spacetimedb-concepts/SKILL.md delete mode 100644 .claude/skills/spacetimedb-csharp/SKILL.md delete mode 100644 .claude/skills/spacetimedb-rust/SKILL.md delete mode 100644 .claude/skills/spacetimedb-typescript/SKILL.md diff --git a/.claude/skills/spacetimedb-cli/SKILL.md b/.claude/skills/spacetimedb-cli/SKILL.md deleted file mode 100644 index 77f3cdc678c..00000000000 --- a/.claude/skills/spacetimedb-cli/SKILL.md +++ /dev/null @@ -1,239 +0,0 @@ ---- -name: spacetimedb-cli -description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers -triggers: - - spacetime init - - spacetime build - - spacetime publish - - spacetime dev - - spacetime sql - - spacetime call - - spacetime logs - - spacetime server - - spacetime login - - spacetime generate - - how do I use the CLI - - CLI command ---- - -# SpacetimeDB CLI - -Use this skill when the user needs help with the `spacetime` CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues. - -## Quick Reference - -### Project Initialization & Development - -```bash -# Initialize new project -spacetime init my-project --lang rust|csharp|typescript -spacetime init my-project --template - -# Build module -spacetime build # release build -spacetime build --debug # faster iteration, slower runtime - -# Dev mode (auto-rebuild, auto-publish, generates bindings) -spacetime dev -spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings - -# Generate client bindings -spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings -``` - -### Publishing & Deployment - -```bash -# Publish to Maincloud (default) -spacetime publish my-database --yes - -# Publish to local server -spacetime publish my-database --server local --yes - -# Publish with data handling -spacetime publish my-database --delete-data always # always clear data -spacetime publish my-database --delete-data on-conflict # clear only if schema conflicts -spacetime publish my-database --delete-data never # never clear (default) - -# Allow breaking client changes -spacetime publish my-database --break-clients -``` - -### Database Interaction - -```bash -# SQL queries -spacetime sql my-database "SELECT * FROM users" -spacetime sql my-database --interactive # REPL mode - -# Call reducers -spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}' - -# Subscribe to changes -spacetime subscribe my-database "SELECT * FROM users" --num-updates 10 - -# View logs -spacetime logs my-database -f # follow logs -spacetime logs my-database -n 100 # last 100 lines - -# Describe schema -spacetime describe my-database --json -spacetime describe my-database table users --json -spacetime describe my-database reducer my_reducer --json -``` - -### Database Management - -```bash -# List databases -spacetime list - -# Delete database -spacetime delete my-database - -# Rename database -spacetime rename --to new-name -``` - -### Server Management - -```bash -# List configured servers -spacetime server list - -# Add server -spacetime server add local http://localhost:3000 --default -spacetime server add myserver https://my-spacetime.example.com - -# Set default server -spacetime server set-default local - -# Test connectivity -spacetime server ping local - -# Start local instance -spacetime start - -# Clear local data -spacetime server clear -``` - -### Authentication - -```bash -# Login (opens browser) -spacetime login - -# Login with token -spacetime login --token - -# Show login status -spacetime login show - -# Logout -spacetime logout -``` - -### Energy/Billing - -```bash -spacetime energy balance -spacetime energy balance --identity -``` - -## Default Servers - -| Name | URL | Description | -|------|-----|-------------| -| `maincloud` | `https://spacetimedb.com` | Production cloud (default) | -| `local` | `http://127.0.0.1:3000` | Local development server | - -## Common Workflows - -### New Project Setup - -```bash -# 1. Login -spacetime login - -# 2. Create project -spacetime init my-game --lang rust -cd my-game - -# 3. Start dev mode (auto-rebuilds and publishes) -spacetime dev -``` - -### Local Development - -```bash -# Start local server (in separate terminal) -spacetime start - -# Publish to local -spacetime publish my-db --server local --delete-data always --yes - -# Query local database -spacetime sql my-db --server local "SELECT * FROM players" -``` - -### Generate Client Bindings - -```bash -# After building module -spacetime build -spacetime generate --lang typescript --out-dir ./client/src/bindings - -# Or use dev mode which auto-generates -spacetime dev --client-lang typescript --module-bindings-path ./client/src/bindings -``` - -## Common Flags - -| Flag | Short | Description | -|------|-------|-------------| -| `--server` | `-s` | Target server (nickname, hostname, or URL) | -| `--yes` | `-y` | Non-interactive mode (skip confirmations) | -| `--anonymous` | | Use anonymous identity | -| `--identity` | `-i` | Specify identity to use | -| `--project-path` | `-p` | Path to module project | - -## Troubleshooting - -### "Not logged in" -```bash -spacetime login -# Or use --anonymous for public operations -``` - -### "Server not responding" -```bash -spacetime server ping -# For local: ensure spacetime start is running -``` - -### "Schema conflict" -```bash -# Clear data and republish -spacetime publish my-db --delete-data always --yes -``` - -### "Build failed" -```bash -# Check Rust/C# toolchain -rustup show -# For Rust modules, ensure wasm32-unknown-unknown target -rustup target add wasm32-unknown-unknown -``` - -## Module Languages - -**Server-side (modules):** Rust, C#, TypeScript -**Client SDKs:** TypeScript, C#, Rust, Python, Unreal Engine - -## Notes - -- Many commands are marked UNSTABLE and may change -- Default server is `maincloud` unless configured otherwise -- Use `--yes` flag in scripts to avoid interactive prompts -- Dev mode watches files and auto-rebuilds on changes diff --git a/.claude/skills/spacetimedb-concepts/SKILL.md b/.claude/skills/spacetimedb-concepts/SKILL.md deleted file mode 100644 index a0e1997f1f6..00000000000 --- a/.claude/skills/spacetimedb-concepts/SKILL.md +++ /dev/null @@ -1,517 +0,0 @@ ---- -name: spacetimedb-concepts -description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions. -license: Apache-2.0 -metadata: - author: clockworklabs - version: "1.1" ---- - -# SpacetimeDB Core Concepts - -SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely. - ---- - -## Critical Rules (Read First) - -These five rules prevent the most common SpacetimeDB mistakes: - -1. **Reducers are transactional** — they do not return data to callers. Use subscriptions to read data. -2. **Reducers must be deterministic** — no filesystem, network, timers, or random. All state must come from tables. -3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries. -4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns. -5. **`ctx.sender` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender` for authorization. - ---- - -## Feature Implementation Checklist - -When implementing a feature that spans backend and client: - -1. **Backend:** Define table(s) to store the data -2. **Backend:** Define reducer(s) to mutate the data -3. **Client:** Subscribe to the table(s) -4. **Client:** Call the reducer(s) from UI — **do not skip this step** -5. **Client:** Render the data from the table(s) - -**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. - ---- - -## Debugging Checklist - -When things are not working: - -1. Is SpacetimeDB server running? (`spacetime start`) -2. Is the module published? (`spacetime publish`) -3. Are client bindings generated? (`spacetime generate`) -4. Check server logs for errors (`spacetime logs `) -5. **Is the reducer actually being called from the client?** - ---- - -## CLI Commands - -```bash -# Start local SpacetimeDB -spacetime start - -# Publish module -spacetime publish --project-path - -# Clear and republish -spacetime publish --clear-database -y --project-path - -# Generate client bindings -spacetime generate --lang --out-dir --project-path - -# View logs -spacetime logs -``` - ---- - -## What SpacetimeDB Is - -SpacetimeDB combines a database and application server into a single deployable unit. Clients connect directly to the database and execute application logic inside it. The system is optimized for real-time applications requiring maximum speed and minimum latency. - -Key characteristics: - -- **In-memory execution**: All application state lives in memory for sub-millisecond access -- **Persistent storage**: Data is automatically persisted to a write-ahead log (WAL) for durability -- **Real-time synchronization**: Changes are automatically pushed to subscribed clients -- **Single deployment**: No separate servers, containers, or infrastructure to manage - -SpacetimeDB powers BitCraft Online, an MMORPG where the entire game backend (chat, items, resources, terrain, player positions) runs as a single SpacetimeDB module. - -## The Five Zen Principles - -SpacetimeDB is built on five core principles that guide both development and usage: - -1. **Everything is a Table**: Your entire application state lives in tables. No separate cache layer, no Redis, no in-memory state to synchronize. The database IS your state. - -2. **Everything is Persistent**: SpacetimeDB persists everything by default, including full history. Persistence only increases latency, never decreases throughput. Modern SSDs can write 15+ GB/s. - -3. **Everything is Real-Time**: Clients are replicas of server state. Subscribe to data and it flows automatically. No polling, no fetching. - -4. **Everything is Transactional**: Every reducer runs atomically. Either all changes succeed or all roll back. No partial updates, no corrupted state. - -5. **Everything is Programmable**: Modules are real code (Rust, C#, TypeScript) running inside the database. Full Turing-complete power for any logic. - -## Tables - -Tables store all data in SpacetimeDB. They use the relational model and support SQL queries for subscriptions. - -### Defining Tables - -Tables are defined using language-specific attributes: - -**Rust:** -```rust -#[spacetimedb::table(name = player, public)] -pub struct Player { - #[primary_key] - #[auto_inc] - id: u32, - #[index(btree)] - name: String, - #[unique] - email: String, -} -``` - -**C#:** -```csharp -[SpacetimeDB.Table(Name = "Player", Public = true)] -public partial struct Player -{ - [SpacetimeDB.PrimaryKey] - [SpacetimeDB.AutoInc] - public uint Id; - [SpacetimeDB.Index.BTree] - public string Name; - [SpacetimeDB.Unique] - public string Email; -} -``` - -**TypeScript:** -```typescript -const players = table( - { name: 'players', public: true }, - { - id: t.u32().primaryKey().autoInc(), - name: t.string().index('btree'), - email: t.string().unique(), - } -); -``` - -### Table Visibility - -- **Private tables** (default): Only accessible by reducers and the database owner -- **Public tables**: Exposed for client read access through subscriptions. Writes still require reducers. - -### Table Design Principles - -Organize data by access pattern, not by entity: - -**Decomposed approach (recommended):** -``` -Player PlayerState PlayerStats -id <-- player_id player_id -name position_x total_kills - position_y total_deaths - velocity_x play_time - velocity_y -``` - -Benefits: -- Reduced bandwidth (clients subscribing to positions do not receive settings updates) -- Cache efficiency (similar update frequencies in contiguous memory) -- Schema evolution (add columns without affecting other tables) -- Semantic clarity (each table has single responsibility) - -## Reducers - -Reducers are transactional functions that modify database state. They are the ONLY way to mutate tables in SpacetimeDB. - -### Key Properties - -- **Transactional**: Run in isolated database transactions -- **Atomic**: Either all changes succeed or all roll back -- **Isolated**: Cannot interact with the outside world (no network, no filesystem) -- **Callable**: Clients invoke reducers as remote procedure calls - -### Critical Reducer Rules - -1. **No global state**: Relying on static variables is undefined behavior -2. **No side effects**: Reducers cannot make network requests or access files -3. **Store state in tables**: All persistent state must be in tables -4. **No return data**: Reducers do not return data to callers — use subscriptions -5. **Must be deterministic**: No random, no timers, no external I/O - -### Defining Reducers - -**Rust:** -```rust -#[spacetimedb::reducer] -pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String> { - if name.is_empty() { - return Err("Name cannot be empty".to_string()); - } - ctx.db.user().insert(User { id: 0, name, email }); - Ok(()) -} -``` - -**C#:** -```csharp -[SpacetimeDB.Reducer] -public static void CreateUser(ReducerContext ctx, string name, string email) -{ - if (string.IsNullOrEmpty(name)) - throw new ArgumentException("Name cannot be empty"); - ctx.Db.User.Insert(new User { Id = 0, Name = name, Email = email }); -} -``` - -### ReducerContext - -Every reducer receives a `ReducerContext` providing: -- `ctx.db`: Access to all tables (read and write) -- `ctx.sender`: The Identity of the caller (use this for authorization, never trust args) -- `ctx.connection_id`: The connection ID of the caller -- `ctx.timestamp`: The current timestamp - -## Subscriptions - -Subscriptions replicate database rows to clients in real-time. When you subscribe to a query, SpacetimeDB sends matching rows immediately and pushes updates whenever those rows change. - -### How Subscriptions Work - -1. **Subscribe**: Register SQL queries describing needed data -2. **Receive initial data**: All matching rows are sent immediately -3. **Receive updates**: Real-time updates when subscribed rows change -4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`) to handle changes - -### Client-Side Usage - -**TypeScript:** -```typescript -const conn = DbConnection.builder() - .withUri('wss://maincloud.spacetimedb.com') - .withModuleName('my_module') - .onConnect((ctx) => { - ctx.subscriptionBuilder() - .onApplied(() => console.log('Subscription ready!')) - .subscribe(['SELECT * FROM user', 'SELECT * FROM message']); - }) - .build(); - -// React to changes -conn.db.user.onInsert((ctx, user) => console.log(`New user: ${user.name}`)); -conn.db.user.onDelete((ctx, user) => console.log(`User left: ${user.name}`)); -conn.db.user.onUpdate((ctx, old, new_) => console.log(`${old.name} -> ${new_.name}`)); -``` - -### Subscription Best Practices - -1. **Group subscriptions by lifetime**: Keep always-needed data separate from temporary subscriptions -2. **Subscribe before unsubscribing**: When updating subscriptions, subscribe to new data first -3. **Avoid overlapping queries**: Distinct queries returning overlapping data cause redundant processing -4. **Use indexes**: Queries on indexed columns are efficient; full table scans are expensive - -## Modules - -Modules are WebAssembly bundles containing application logic that runs inside the database. - -### Module Components - -- **Tables**: Define the data schema -- **Reducers**: Define callable functions that modify state -- **Views**: Define read-only computed queries -- **Procedures**: (Beta) Functions that can have side effects (HTTP requests) - -### Module Languages - -Server-side modules can be written in: -- Rust -- C# -- TypeScript (beta) - -### Module Lifecycle - -1. **Write**: Define tables and reducers in your chosen language -2. **Compile**: Build to WebAssembly using the SpacetimeDB CLI -3. **Publish**: Upload to a SpacetimeDB host with `spacetime publish` -4. **Hot-swap**: Republish to update code without disconnecting clients - -## Identity - -Identity is SpacetimeDB's authentication system based on OpenID Connect (OIDC). - -### Identity Concepts - -- **Identity**: A long-lived, globally unique identifier for a user. Derived from OIDC issuer and subject claims. -- **ConnectionId**: Identifies a specific client connection. A user may have multiple connections. - -### Identity in Reducers - -```rust -#[spacetimedb::reducer] -pub fn do_something(ctx: &ReducerContext) { - let caller_identity = ctx.sender; // Who is calling this reducer? - // Use identity for authorization checks - // NEVER trust identity passed as a reducer argument -} -``` - -### Authentication Providers - -SpacetimeDB works with any OIDC provider: -- **SpacetimeAuth**: Built-in managed provider (simple, production-ready) -- **Third-party**: Auth0, Clerk, Keycloak, Google, GitHub, etc. - -## SATS (SpacetimeDB Algebraic Type System) - -SATS is the type system and serialization format used throughout SpacetimeDB. - -### Core Types - -| Category | Types | -|----------|-------| -| Primitives | `Bool`, `U8`-`U256`, `I8`-`I256`, `F32`, `F64`, `String` | -| Composite | `ProductType` (structs), `SumType` (enums/tagged unions) | -| Collections | `Array`, `Map` | -| Special | `Identity`, `ConnectionId`, `ScheduleAt` | - -### Serialization Formats - -- **BSATN**: Binary format for module-host communication and row storage -- **SATS-JSON**: JSON format for HTTP API and WebSocket text protocol - -### Type Compatibility - -Types must implement `SpacetimeType` to be used in tables and reducers. This is automatic for primitive types and structs using the appropriate attributes. - -## Client-Server Data Flow - -### Write Path (Client to Database) - -1. Client calls reducer (e.g., `ctx.reducers.createUser("Alice")`) -2. Request sent over WebSocket to SpacetimeDB host -3. Host validates identity and executes reducer in transaction -4. On success, changes are committed; on error, all changes roll back -5. Subscribed clients receive updates for affected rows - -### Read Path (Database to Client) - -1. Client subscribes with SQL queries (e.g., `SELECT * FROM user`) -2. Server evaluates query and sends matching rows -3. Client maintains local cache of subscribed data -4. When subscribed data changes, server pushes delta updates -5. Client cache is automatically updated; callbacks fire - -### Data Flow Diagram - -``` -┌─────────────────────────────────────────────────────────┐ -│ CLIENT │ -│ ┌─────────────┐ ┌─────────────────────────────┐ │ -│ │ Reducers │────>│ Local Cache (Read) │ │ -│ │ (Write) │ │ - Tables from subscriptions│ │ -│ └─────────────┘ │ - Automatically synced │ │ -│ │ └─────────────────────────────┘ │ -└─────────│──────────────────────────│───────────────────┘ - │ WebSocket │ Updates pushed - v │ -┌─────────────────────────────────────────────────────────┐ -│ SpacetimeDB │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Module │ │ -│ │ - Reducers (transactional logic) │ │ -│ │ - Tables (in-memory + persisted) │ │ -│ │ - Subscriptions (real-time queries) │ │ -│ └─────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -## When to Use SpacetimeDB - -### Ideal Use Cases - -- **Real-time games**: MMOs, multiplayer games, turn-based games -- **Collaborative applications**: Document editing, whiteboards, design tools -- **Chat and messaging**: Real-time communication with presence -- **Live dashboards**: Streaming analytics and monitoring -- **IoT applications**: Sensor data with real-time updates - -### Key Decision Factors - -Choose SpacetimeDB when you need: -- Sub-10ms latency for reads and writes -- Automatic real-time synchronization -- Transactional guarantees for all operations -- Simplified architecture (no separate cache, queue, or server) - -### Less Suitable For - -- **Batch analytics**: SpacetimeDB is optimized for OLTP, not OLAP -- **Large blob storage**: Better suited for structured relational data -- **Stateless APIs**: Traditional REST APIs do not need real-time sync - -## Comparison to Traditional Architectures - -### Traditional Stack - -``` -Client - │ - v -Load Balancer - │ - v -Web/Game Servers (stateless or stateful) - │ - ├──> Cache (Redis) - │ - v -Database (PostgreSQL, MySQL) - │ - v -Message Queue (for real-time) -``` - -**Pain points:** -- Multiple systems to deploy and manage -- Cache invalidation complexity -- State synchronization between servers -- Manual real-time implementation -- Horizontal scaling complexity - -### SpacetimeDB Stack - -``` -Client - │ - v -SpacetimeDB Host - │ - v -Module (your logic + tables) -``` - -**Benefits:** -- Single deployment target -- No cache layer needed (in-memory by design) -- Automatic real-time synchronization -- Built-in horizontal scaling (future) -- Transactional guarantees everywhere - -### Smart Contract Comparison - -SpacetimeDB modules are conceptually similar to smart contracts: -- Application logic runs inside the data layer -- Transactions are atomic and verified -- State changes are deterministic - -Key differences: -- SpacetimeDB is orders of magnitude faster (no consensus overhead) -- Full relational database capabilities -- No blockchain or cryptocurrency involved -- Designed for real-time, not eventual consistency - -## Common Patterns - -**Authentication check in reducer:** -```rust -#[spacetimedb::reducer] -fn admin_action(ctx: &ReducerContext) -> Result<(), String> { - let admin = ctx.db.admin().identity().find(&ctx.sender) - .ok_or("Not an admin")?; - // ... perform admin action - Ok(()) -} -``` - -**Moving between tables (state machine):** -```rust -#[spacetimedb::reducer] -fn login(ctx: &ReducerContext) -> Result<(), String> { - let player = ctx.db.logged_out_player().identity().find(&ctx.sender) - .ok_or("Not found")?; - ctx.db.player().insert(player.clone()); - ctx.db.logged_out_player().identity().delete(&ctx.sender); - Ok(()) -} -``` - -**Scheduled reducer:** -```rust -#[spacetimedb::table(name = reminder, scheduled(send_reminder))] -pub struct Reminder { - #[primary_key] - #[auto_inc] - id: u64, - scheduled_at: ScheduleAt, - message: String, -} - -#[spacetimedb::reducer] -fn send_reminder(ctx: &ReducerContext, reminder: Reminder) { - // This runs at the scheduled time - log::info!("Reminder: {}", reminder.message); -} -``` - ---- - -## Editing Behavior - -When modifying SpacetimeDB code: - -- Make the smallest change necessary -- Do NOT touch unrelated files, configs, or dependencies -- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo diff --git a/.claude/skills/spacetimedb-csharp/SKILL.md b/.claude/skills/spacetimedb-csharp/SKILL.md deleted file mode 100644 index 099dc0db446..00000000000 --- a/.claude/skills/spacetimedb-csharp/SKILL.md +++ /dev/null @@ -1,1462 +0,0 @@ ---- -name: spacetimedb-csharp -description: Build C# modules and Unity clients for SpacetimeDB. Covers server-side module development and client SDK integration. -license: Apache-2.0 -metadata: - author: clockworklabs - version: "1.1" - tested_with: "SpacetimeDB runtime 1.11.x, .NET 8 SDK" ---- - -# SpacetimeDB C# SDK - -This skill provides comprehensive guidance for building C# server-side modules and Unity/C# clients that connect to SpacetimeDB. - ---- - -## HALLUCINATED APIs — DO NOT USE - -**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** - -```csharp -// WRONG — these do not exist -[SpacetimeDB.Procedure] // C# does NOT support procedures yet! -ctx.db.tableName // Wrong casing, should be PascalCase -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 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(Name = "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(Name = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index! -[Index.BTree(Name = "idx", Columns = ["Col"])] // Collection expressions don't work in attributes! -``` - -### CORRECT PATTERNS - -```csharp -// CORRECT IMPORTS -using SpacetimeDB; - -// CORRECT TABLE — must be partial struct -[SpacetimeDB.Table(Name = "player", Public = true)] -public partial struct Player -{ - [SpacetimeDB.PrimaryKey] - [SpacetimeDB.AutoInc] - public ulong Id; - - 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); -var player = ctx.Db.Player.OwnerId.Find(ctx.Sender); - -// CORRECT SUM TYPE — partial record with named tuple elements -[SpacetimeDB.Type] -public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } -``` - -### DO NOT - -- **Forget `partial` keyword** — required on all tables and Module class -- **Use lowercase table access** — `ctx.Db.Player` not `ctx.Db.player` -- **Try to use procedures** — C# does not support procedures yet -- **Use `Optional`** — use C# nullable syntax `T?` instead -- **Use struct for sum types** — must be `partial record` - ---- - -## Common Mistakes Table - -### Server-side errors - -| Wrong | Right | Error | -|-------|-------|-------| -| Missing `partial` keyword | `public partial struct Table` | Generated code won't compile | -| `ctx.Db.player` (lowercase) | `ctx.Db.Player` (PascalCase) | Property not found | -| `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 : TaggedEnum` | `partial record : TaggedEnum` | Sum types must be record | -| `TaggedEnum<(A, B)>` | `TaggedEnum<(A A, B B)>` | Tuple must include variant names | - -### Client-side errors - -| Wrong | Right | Error | -|-------|-------|-------| -| Wrong namespace | `using SpacetimeDB.ClientApi;` | Types not found | -| Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire | -| Accessing `conn.Db` from background thread | Copy data in callback, process elsewhere | Data races | - ---- - -## Hard Requirements - -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. **Sum types must be `partial record`** — not struct or class -12. **Fully qualify Index attribute** — `[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity - ---- - -## Server-Side Module Development - -### Table Definition (CRITICAL) - -**Tables MUST use `partial struct` or `partial class` for code generation.** - -```csharp -using SpacetimeDB; - -// WRONG — missing partial! -[SpacetimeDB.Table(Name = "player")] -public struct Player { } // Will not generate properly! - -// RIGHT — with partial keyword -[SpacetimeDB.Table(Name = "player", Public = true)] -public partial struct Player -{ - [SpacetimeDB.PrimaryKey] - [SpacetimeDB.AutoInc] - public ulong Id; - - public Identity OwnerId; - public string Name; - public Timestamp CreatedAt; -} - -// With indexes -[SpacetimeDB.Table(Name = "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 -[SpacetimeDB.Table(Name = "score", Public = true)] -[SpacetimeDB.Index.BTree(Name = "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 -``` - -### 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) - { - var task = ctx.Db.Task.Id.Find(taskId); - if (task is null) - { - throw new Exception("Task not found"); - } - - if (task.Value.OwnerId != ctx.Sender) - { - throw new Exception("Not authorized"); - } - - ctx.Db.Task.Id.Update(task.Value 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) - { - // Called once when module is first published - Log.Info("Module initialized"); - } - - [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] - public static void OnConnect(ReducerContext ctx) - { - // ctx.Sender is the connecting client - Log.Info($"Client connected: {ctx.Sender}"); - } - - [SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)] - public static void OnDisconnect(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) -``` - -### Database Access - -#### Naming Convention - -- **Tables**: Use PascalCase singular names in the `Name` attribute - - `[Table(Name = "User")]` → `ctx.Db.User` - - `[Table(Name = "PlayerStats")]` → `ctx.Db.PlayerStats` -- **Indexes**: PascalCase, match field name - - Field `OwnerId` with `[Index.BTree]` → `ctx.Db.User.OwnerId` - -#### Primary Key Operations - -```csharp -// Find by primary key — returns nullable -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 -} -``` - -### 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(Name = "player", Public = true)] -public partial struct Player -{ - [SpacetimeDB.PrimaryKey] - public Identity Id; - - public string Name; - public PlayerStats Stats; -} -``` - -### Sum Types / Tagged Enums (CRITICAL) - -**Sum types MUST use `partial record` 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!) 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(Name = "drawings", 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 }); -``` - -#### COMMON SUM TYPE MISTAKES - -| Wrong | Right | Why | -|-------|-------|-----| -| `partial struct Shape : TaggedEnum<...>` | `partial record Shape : TaggedEnum<...>` | Must be `record`, not `struct` | -| `TaggedEnum<(Circle, Rectangle)>` | `TaggedEnum<(Circle Circle, Rectangle Rectangle)>` | Tuple must have names | -| `new Shape { ... }` | `new Shape.Circle(new Circle { ... })` | Use nested variant constructor | - -### Scheduled Tables - -```csharp -using SpacetimeDB; - -[SpacetimeDB.Table(Name = "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 -{ - // 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 = ScheduleAt.Time(futureTime) - }); - } - - [SpacetimeDB.Reducer] - public static void CancelReminder(ReducerContext ctx, ulong reminderId) - { - ctx.Db.Reminder.Id.Delete(reminderId); - } -} -``` - -### Logging - -```csharp -using SpacetimeDB; - -Log.Debug("Debug message"); -Log.Info("Information"); -Log.Warn("Warning"); -Log.Error("Error occurred"); -Log.Panic("Critical failure"); // Terminates execution -``` - -### Data Visibility - -**`Public = true` exposes ALL rows to ALL clients.** - -| Scenario | Pattern | -|----------|---------| -| Everyone sees all rows | `[Table(Name = "x", Public = true)]` | -| Server-only data | `[Table(Name = "x")]` (private by default) | - -### 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 -``` - -### Commands - -```bash -# Start local server -spacetime start - -# Publish module -spacetime publish --project-path - -# Clear database and republish -spacetime publish --clear-database -y --project-path - -# Generate bindings -spacetime generate --lang csharp --out-dir /SpacetimeDB --project-path - -# View logs -spacetime logs -``` - ---- - -## Client-Side SDK - -### Overview - -The SpacetimeDB C# SDK enables .NET applications and Unity games to: -- Connect to SpacetimeDB databases over WebSocket -- Subscribe to real-time table updates -- Invoke reducers (server-side functions) -- Maintain a local cache of subscribed data -- Handle authentication via Identity tokens - -**Critical Requirement**: The C# SDK requires manual connection advancement. You must call `FrameTick()` regularly to process messages. - -### Installation - -#### .NET Console/Library Applications - -Add the NuGet package: - -```bash -dotnet add package SpacetimeDB.ClientSDK -``` - -#### Unity Applications - -Add via Unity Package Manager using the git URL: - -``` -https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git -``` - -Steps: -1. Open Window > Package Manager -2. Click the + button in top-left -3. Select "Add package from git URL" -4. Paste the URL above and click Add - -### Generate Module Bindings - -Before using the SDK, generate type-safe bindings from your module: - -```bash -mkdir -p module_bindings -spacetime generate --lang cs --out-dir module_bindings --project-path PATH_TO_MODULE -``` - -This creates: -- `SpacetimeDBClient.g.cs` - Main client types (DbConnection, contexts, builders) -- `Tables/*.g.cs` - Table handle classes with typed access -- `Reducers/*.g.cs` - Reducer invocation methods -- `Types/*.g.cs` - Row types and custom types from the module - -### Connection Setup - -#### Basic Connection Pattern - -```csharp -using SpacetimeDB; -using SpacetimeDB.Types; - -DbConnection? conn = null; - -conn = DbConnection.Builder() - .WithUri("http://localhost:3000") // SpacetimeDB server URL - .WithModuleName("my-database") // Database name or Identity - .OnConnect(OnConnected) // Connection success callback - .OnConnectError((err) => { // Connection failure callback - Console.Error.WriteLine($"Connection failed: {err}"); - }) - .OnDisconnect((conn, err) => { // Disconnection callback - if (err != null) { - Console.Error.WriteLine($"Disconnected with error: {err}"); - } - }) - .Build(); - -void OnConnected(DbConnection conn, Identity identity, string authToken) -{ - Console.WriteLine($"Connected with Identity: {identity}"); - // Save authToken for reconnection - // Set up subscriptions here -} -``` - -#### Connection Builder Methods - -| Method | Description | -|--------|-------------| -| `WithUri(string uri)` | SpacetimeDB server URI (required) | -| `WithModuleName(string name)` | Database name or Identity (required) | -| `WithToken(string token)` | Auth token for reconnection | -| `WithConfirmedReads(bool)` | Wait for durable writes before returning | -| `OnConnect(callback)` | Called on successful connection | -| `OnConnectError(callback)` | Called if connection fails | -| `OnDisconnect(callback)` | Called when disconnected | -| `Build()` | Create and open the connection | - -### Critical: Advancing the Connection - -**The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly. - -#### Console Application Loop - -```csharp -while (true) -{ - conn.FrameTick(); - Thread.Sleep(16); // ~60 FPS -} -``` - -#### Unity MonoBehaviour Pattern - -```csharp -public class SpacetimeManager : MonoBehaviour -{ - private DbConnection conn; - - void Update() - { - conn?.FrameTick(); - } -} -``` - -**Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races with main thread access. - -### Subscribing to Tables - -#### Using SQL Queries - -```csharp -void OnConnected(DbConnection conn, Identity identity, string authToken) -{ - conn.SubscriptionBuilder() - .OnApplied(OnSubscriptionApplied) - .OnError((ctx, err) => { - Console.Error.WriteLine($"Subscription failed: {err}"); - }) - .Subscribe(new[] { - "SELECT * FROM player", - "SELECT * FROM message WHERE sender = :sender" - }); -} - -void OnSubscriptionApplied(SubscriptionEventContext ctx) -{ - Console.WriteLine("Subscription ready - data available"); - // Access ctx.Db to read subscribed rows -} -``` - -#### Using Typed Query Builder - -```csharp -conn.SubscriptionBuilder() - .OnApplied(OnSubscriptionApplied) - .OnError((ctx, err) => Console.Error.WriteLine(err)) - .AddQuery(qb => qb.From.Player().Build()) - .AddQuery(qb => qb.From.Message().Where(c => c.Sender.Eq(identity)).Build()) - .Subscribe(); -``` - -#### Subscribe to All Tables (Development Only) - -```csharp -conn.SubscriptionBuilder() - .OnApplied(OnSubscriptionApplied) - .SubscribeToAllTables(); -``` - -**Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection. - -#### Subscription Handle - -```csharp -SubscriptionHandle handle = conn.SubscriptionBuilder() - .OnApplied(ctx => Console.WriteLine("Applied")) - .Subscribe(new[] { "SELECT * FROM player" }); - -// Later: unsubscribe -handle.UnsubscribeThen(ctx => { - Console.WriteLine("Unsubscribed"); -}); - -// Check status -bool isActive = handle.IsActive; -bool isEnded = handle.IsEnded; -``` - -### Accessing the Client Cache - -Subscribed data is stored in `conn.Db` (or `ctx.Db` in callbacks). - -#### Iterating All Rows - -```csharp -foreach (var player in ctx.Db.Player.Iter()) -{ - Console.WriteLine($"Player: {player.Name}"); -} -``` - -#### Count Rows - -```csharp -int playerCount = ctx.Db.Player.Count; -``` - -#### Find by Unique/Primary Key - -For columns marked `[Unique]` or `[PrimaryKey]` on the server: - -```csharp -// Find by unique column -Player? player = ctx.Db.Player.Identity.Find(someIdentity); - -// Returns null if not found -if (player != null) -{ - Console.WriteLine($"Found: {player.Name}"); -} -``` - -#### Filter by BTree Index - -For columns with `[Index.BTree]` on the server: - -```csharp -// Filter returns IEnumerable -IEnumerable levelOnePlayers = ctx.Db.Player.Level.Filter(1); - -int count = levelOnePlayers.Count(); -``` - -#### Remote Query (Ad-hoc SQL) - -```csharp -var result = ctx.Db.Player.RemoteQuery("WHERE level > 10"); -Player[] highLevelPlayers = result.Result; -``` - -### Row Event Callbacks - -Register callbacks to react to table changes: - -#### OnInsert - -```csharp -ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { - Console.WriteLine($"Player joined: {player.Name}"); -}; -``` - -#### OnDelete - -```csharp -ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => { - Console.WriteLine($"Player left: {player.Name}"); -}; -``` - -#### OnUpdate - -Fires when a row with a primary key is replaced: - -```csharp -ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => { - Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}"); -}; -``` - -#### Checking Event Source - -```csharp -ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { - switch (ctx.Event) - { - case Event.SubscribeApplied: - // Initial subscription data - break; - case Event.Reducer(var reducerEvent): - // Change from a reducer - Console.WriteLine($"Reducer: {reducerEvent.Reducer}"); - break; - } -}; -``` - -### Calling Reducers - -Reducers are server-side functions that modify the database. - -#### Invoke a Reducer - -```csharp -// Reducers are methods on ctx.Reducers or conn.Reducers -ctx.Reducers.SendMessage("Hello, world!"); -ctx.Reducers.CreatePlayer("NewPlayer"); -ctx.Reducers.UpdateScore(playerId, 100); -``` - -#### Reducer Callbacks - -React when a reducer completes (success or failure): - -```csharp -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 - -Catch reducer errors without specific handlers: - -```csharp -conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => { - Console.Error.WriteLine($"Reducer error: {ex.Message}"); -}; -``` - -#### Reducer Event Properties - -```csharp -conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => { - ReducerEvent evt = ctx.Event; - - Timestamp when = evt.Timestamp; - Status status = evt.Status; - Identity caller = evt.CallerIdentity; - ConnectionId? callerId = evt.CallerConnectionId; - U128? energy = evt.EnergyConsumed; -}; -``` - -### Identity and Authentication - -#### Getting Current Identity - -```csharp -// In OnConnect callback -void OnConnected(DbConnection conn, Identity identity, string authToken) -{ - // identity - your unique identifier - // authToken - save this for reconnection - PlayerPrefs.SetString("SpacetimeToken", authToken); -} - -// From any context -Identity? myIdentity = ctx.Identity; -ConnectionId myConnectionId = ctx.ConnectionId; -``` - -#### Reconnecting with Token - -```csharp -string savedToken = PlayerPrefs.GetString("SpacetimeToken", null); - -DbConnection.Builder() - .WithUri("http://localhost:3000") - .WithModuleName("my-database") - .WithToken(savedToken) // Reconnect as same identity - .OnConnect(OnConnected) - .Build(); -``` - -#### Anonymous Connection - -Pass `null` to `WithToken` or omit it entirely for a new anonymous identity. - -### BSATN Serialization - -SpacetimeDB uses BSATN (Binary SpacetimeDB Algebraic Type Notation) for serialization. The SDK handles this automatically for generated types. - -#### Supported Types - -| C# Type | SpacetimeDB Type | -|---------|------------------| -| `bool` | Bool | -| `byte`, `sbyte` | U8, I8 | -| `ushort`, `short` | U16, I16 | -| `uint`, `int` | U32, I32 | -| `ulong`, `long` | U64, I64 | -| `U128`, `I128` | U128, I128 | -| `U256`, `I256` | U256, I256 | -| `float`, `double` | F32, F64 | -| `string` | String | -| `List` | Array | -| `T?` (nullable) | Option | -| `Identity` | Identity | -| `ConnectionId` | ConnectionId | -| `Timestamp` | Timestamp | -| `Uuid` | Uuid | - -#### Custom Types - -Types marked with `[SpacetimeDB.Type]` on the server are generated as C# types: - -```csharp -// Server-side (Rust or C#) -[SpacetimeDB.Type] -public partial struct Vector3 -{ - public float X; - public float Y; - public float Z; -} - -// Client-side (auto-generated) -public partial struct Vector3 : IEquatable -{ - public float X; - public float Y; - public float Z; - // BSATN serialization methods included -} -``` - -#### TaggedEnum (Sum Types) on Client - -```csharp -// Server -[SpacetimeDB.Type] -public partial record GameEvent : TaggedEnum<( - string PlayerJoined, - string PlayerLeft, - (string player, int score) ScoreUpdate -)>; - -// Client usage -switch (gameEvent) -{ - case GameEvent.PlayerJoined(var name): - Console.WriteLine($"{name} joined"); - break; - case GameEvent.ScoreUpdate((var player, var score)): - Console.WriteLine($"{player} scored {score}"); - break; -} -``` - -#### Result Type - -```csharp -// Result for success/error handling -Result result = ...; - -if (result is Result.Ok(var player)) -{ - Console.WriteLine($"Success: {player.Name}"); -} -else if (result is Result.Err(var error)) -{ - Console.Error.WriteLine($"Error: {error}"); -} -``` - -### Unity Integration - -#### Project Setup - -1. Add the SpacetimeDB package via Package Manager -2. Generate bindings and add to your Unity project -3. Create a manager MonoBehaviour - -#### SpacetimeManager Pattern - -```csharp -using UnityEngine; -using SpacetimeDB; -using SpacetimeDB.Types; - -public class SpacetimeManager : MonoBehaviour -{ - public static SpacetimeManager Instance { get; private set; } - - [SerializeField] private string serverUri = "http://localhost:3000"; - [SerializeField] private string moduleName = "my-game"; - - private DbConnection conn; - public DbConnection Connection => conn; - - void Awake() - { - if (Instance != null) - { - Destroy(gameObject); - return; - } - Instance = this; - DontDestroyOnLoad(gameObject); - } - - void Start() - { - Connect(); - } - - void Update() - { - // CRITICAL: Must call every frame - conn?.FrameTick(); - } - - void OnDestroy() - { - conn?.Disconnect(); - } - - public void Connect() - { - string token = PlayerPrefs.GetString("SpacetimeToken", null); - - conn = DbConnection.Builder() - .WithUri(serverUri) - .WithModuleName(moduleName) - .WithToken(token) - .OnConnect(OnConnected) - .OnConnectError(OnConnectError) - .OnDisconnect(OnDisconnect) - .Build(); - } - - private void OnConnected(DbConnection conn, Identity identity, string authToken) - { - Debug.Log($"Connected as {identity}"); - PlayerPrefs.SetString("SpacetimeToken", authToken); - - conn.SubscriptionBuilder() - .OnApplied(OnSubscriptionApplied) - .OnError((ctx, err) => Debug.LogError($"Subscription error: {err}")) - .SubscribeToAllTables(); - } - - private void OnConnectError(Exception err) - { - Debug.LogError($"Connection failed: {err}"); - } - - private void OnDisconnect(DbConnection conn, Exception err) - { - if (err != null) - { - Debug.LogError($"Disconnected: {err}"); - } - } - - private void OnSubscriptionApplied(SubscriptionEventContext ctx) - { - Debug.Log("Subscription ready"); - // Initialize game state from ctx.Db - } -} -``` - -#### Unity-Specific Considerations - -1. **Main Thread Only**: All SpacetimeDB callbacks run on the main thread (during `FrameTick()`) - -2. **Scene Loading**: Use `DontDestroyOnLoad` for the connection manager - -3. **Reconnection**: Handle disconnects gracefully for mobile/poor connectivity - -4. **PlayerPrefs**: Use for token persistence (or use a more secure method for production) - -#### Spawning GameObjects from Table Data - -```csharp -public class PlayerSpawner : MonoBehaviour -{ - [SerializeField] private GameObject playerPrefab; - private Dictionary playerObjects = new(); - - void Start() - { - var conn = SpacetimeManager.Instance.Connection; - - conn.Db.Player.OnInsert += OnPlayerInsert; - conn.Db.Player.OnDelete += OnPlayerDelete; - conn.Db.Player.OnUpdate += OnPlayerUpdate; - - // Spawn existing players - foreach (var player in conn.Db.Player.Iter()) - { - SpawnPlayer(player); - } - } - - void OnPlayerInsert(EventContext ctx, Player player) - { - // Skip if this is initial subscription data we already handled - if (ctx.Event is Event.SubscribeApplied) return; - - SpawnPlayer(player); - } - - void OnPlayerDelete(EventContext ctx, Player player) - { - if (playerObjects.TryGetValue(player.Identity, out var go)) - { - Destroy(go); - playerObjects.Remove(player.Identity); - } - } - - void OnPlayerUpdate(EventContext ctx, Player oldPlayer, Player newPlayer) - { - if (playerObjects.TryGetValue(newPlayer.Identity, out var go)) - { - // Update position, etc. - go.transform.position = new Vector3(newPlayer.X, newPlayer.Y, newPlayer.Z); - } - } - - void SpawnPlayer(Player player) - { - var go = Instantiate(playerPrefab); - go.transform.position = new Vector3(player.X, player.Y, player.Z); - playerObjects[player.Identity] = go; - } -} -``` - -### Thread Safety - -The C# SDK is NOT thread-safe. Follow these rules: - -1. **Call `FrameTick()` from ONE thread only** (main thread recommended) - -2. **All callbacks run during `FrameTick()`** on the calling thread - -3. **Do NOT access `conn.Db` from other threads** while `FrameTick()` may be running - -4. **Background work**: Copy data out of callbacks, process on background threads - -```csharp -// Safe pattern for background processing -conn.Db.Player.OnInsert += (ctx, player) => { - // Copy the data - var playerData = new PlayerDTO { - Id = player.Id, - Name = player.Name - }; - - // Process on background thread - Task.Run(() => ProcessPlayerAsync(playerData)); -}; -``` - -### Error Handling - -#### Connection Errors - -```csharp -.OnConnectError((err) => { - // Network errors, invalid module name, etc. - Debug.LogError($"Connect error: {err}"); -}) -``` - -#### Subscription Errors - -```csharp -.OnError((ctx, err) => { - // Invalid SQL, schema changes, etc. - Debug.LogError($"Subscription error: {err}"); -}) -``` - -#### Reducer Errors - -```csharp -conn.Reducers.OnMyReducer += (ctx, args) => { - if (ctx.Event.Status is Status.Failed(var reason)) - { - Debug.LogError($"Reducer failed: {reason}"); - } -}; - -// Catch-all for unhandled reducer errors -conn.OnUnhandledReducerError += (ctx, ex) => { - Debug.LogError($"Unhandled: {ex}"); -}; -``` - -### Complete Console Example - -```csharp -using System; -using SpacetimeDB; -using SpacetimeDB.Types; - -class Program -{ - static DbConnection? conn; - static bool running = true; - - static void Main() - { - conn = DbConnection.Builder() - .WithUri("http://localhost:3000") - .WithModuleName("chat") - .OnConnect(OnConnect) - .OnConnectError(err => Console.Error.WriteLine($"Failed: {err}")) - .OnDisconnect((c, err) => running = false) - .Build(); - - while (running) - { - conn.FrameTick(); - Thread.Sleep(16); - } - } - - static void OnConnect(DbConnection conn, Identity id, string token) - { - Console.WriteLine($"Connected as {id}"); - - // Set up callbacks - conn.Db.Message.OnInsert += (ctx, msg) => { - Console.WriteLine($"[{msg.Sender}]: {msg.Text}"); - }; - - conn.Reducers.OnSendMessage += (ctx, text) => { - if (ctx.Event.Status is Status.Failed(var reason)) - { - Console.Error.WriteLine($"Send failed: {reason}"); - } - }; - - // Subscribe - conn.SubscriptionBuilder() - .OnApplied(ctx => { - Console.WriteLine("Ready! Type messages:"); - StartInputLoop(ctx); - }) - .Subscribe(new[] { "SELECT * FROM message" }); - } - - static void StartInputLoop(SubscriptionEventContext ctx) - { - Task.Run(() => { - while (running) - { - var input = Console.ReadLine(); - if (!string.IsNullOrEmpty(input)) - { - ctx.Reducers.SendMessage(input); - } - } - }); - } -} -``` - -### Common Patterns - -#### Optimistic Updates - -```csharp -// Show immediate feedback, correct on server response -void SendMessage(string text) -{ - // Optimistic: show immediately - AddMessageToUI(myIdentity, text, isPending: true); - - // Send to server - conn.Reducers.SendMessage(text); -} - -conn.Reducers.OnSendMessage += (ctx, text) => { - if (ctx.Event.CallerIdentity == conn.Identity) - { - if (ctx.Event.Status is Status.Committed) - { - // Confirm the pending message - ConfirmPendingMessage(text); - } - else - { - // Remove failed message - RemovePendingMessage(text); - } - } -}; -``` - -#### Local Player Detection - -```csharp -conn.Db.Player.OnInsert += (ctx, player) => { - bool isLocalPlayer = player.Identity == ctx.Identity; - - if (isLocalPlayer) - { - // This is our player - SetupLocalPlayerController(player); - } - else - { - // Remote player - SpawnRemotePlayer(player); - } -}; -``` - -#### Waiting for Specific Data - -```csharp -async Task WaitForPlayerAsync(Identity playerId) -{ - var tcs = new TaskCompletionSource(); - - void Handler(EventContext ctx, Player player) - { - if (player.Identity == playerId) - { - tcs.TrySetResult(player); - conn.Db.Player.OnInsert -= Handler; - } - } - - // Check if already exists - var existing = conn.Db.Player.Identity.Find(playerId); - if (existing != null) return existing; - - conn.Db.Player.OnInsert += Handler; - return await tcs.Task; -} -``` - ---- - -## Troubleshooting - -### Connection Issues - -- **"Connection refused"**: Check server is running at the specified URI -- **"Module not found"**: Verify module name matches published database -- **Timeout**: Check firewall/network, ensure `FrameTick()` is being called - -### No Callbacks Firing - -- **Check `FrameTick()`**: Must be called regularly -- **Check subscription**: Ensure `OnApplied` fired successfully -- **Check callback registration**: Register before subscribing - -### Data Not Appearing - -- **Check SQL syntax**: Invalid queries fail silently -- **Check table visibility**: Tables must be `Public = true` in the module -- **Check subscription scope**: Only subscribed rows are cached - -### Unity-Specific - -- **NullReferenceException in Update**: Guard with `conn?.FrameTick()` -- **Missing types**: Regenerate bindings after module changes -- **Assembly errors**: Ensure SpacetimeDB assemblies are in correct folder - -### Build Issues - -- **WASI compilation fails**: Ensure .NET 8 SDK (not 9+), install WASI workload -- **Publish fails silently**: Ensure project is named `StdbModule.csproj` -- **Generated code errors**: Ensure all tables/types have `partial` keyword - ---- - -## References - -- [C# SDK Reference](https://spacetimedb.com/docs/sdks/c-sharp) -- [Unity Tutorial](https://spacetimedb.com/docs/unity/part-1) -- [SpacetimeDB SQL Reference](https://spacetimedb.com/docs/sql) -- [GitHub: Unity Demo (Blackholio)](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) diff --git a/.claude/skills/spacetimedb-rust/SKILL.md b/.claude/skills/spacetimedb-rust/SKILL.md deleted file mode 100644 index 0a5b149911e..00000000000 --- a/.claude/skills/spacetimedb-rust/SKILL.md +++ /dev/null @@ -1,894 +0,0 @@ ---- -name: spacetimedb-rust -description: Develop SpacetimeDB server modules in Rust. Use when writing reducers, tables, or module logic. -license: Apache-2.0 -metadata: - author: clockworklabs - version: "1.1" ---- - -# SpacetimeDB Rust Module Development - -SpacetimeDB modules are WebAssembly applications that run inside the database. They define tables to store data and reducers to modify data. Clients connect directly to the database and execute application logic inside it. - -> **Tested with:** SpacetimeDB runtime 1.11.x, `spacetimedb` crate 1.1.x - ---- - -## HALLUCINATED APIs — DO NOT USE - -**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** - -```rust -// WRONG — these macros/attributes don't exist -#[spacetimedb::table] // Use #[table] after importing -#[spacetimedb::reducer] // Use #[reducer] after importing -#[derive(Table)] // Tables use #[table] attribute, not derive -#[derive(Reducer)] // Reducers use #[reducer] attribute - -// WRONG — SpacetimeType on tables -#[derive(SpacetimeType)] // DO NOT use on #[table] structs! -#[table(name = my_table)] -pub struct MyTable { ... } - -// WRONG — mutable context -pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext - -// WRONG — table access without parentheses -ctx.db.player // Should be ctx.db.player() -ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id) -``` - -### CORRECT PATTERNS: - -```rust -// CORRECT IMPORTS -use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; -use spacetimedb::SpacetimeType; // Only for custom types, NOT tables - -// CORRECT TABLE — no SpacetimeType derive! -#[table(name = player, public)] -pub struct Player { - #[primary_key] - pub id: u64, - pub name: String, -} - -// CORRECT REDUCER — immutable context reference -#[reducer] -pub fn create_player(ctx: &ReducerContext, name: String) { - ctx.db.player().insert(Player { id: 0, name }); -} - -// CORRECT TABLE ACCESS — methods with parentheses -let player = ctx.db.player().id().find(&player_id); -``` - -### DO NOT: -- **Derive `SpacetimeType` on `#[table]` structs** — the macro handles this -- **Use mutable context** — `&ReducerContext`, not `&mut ReducerContext` -- **Forget `Table` trait import** — required for table operations -- **Use field access for tables** — `ctx.db.player()` not `ctx.db.player` - ---- - -## Common Mistakes Table - -### Server-side errors - -| Wrong | Right | Error | -|-------|-------|-------| -| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros | -| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" | -| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index | -| `&mut ReducerContext` | `&ReducerContext` | Wrong context type | -| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" | -| `#[table(name = "my_table")]` | `#[table(name = my_table)]` | String literals not allowed | -| Missing `public` on table | Add `public` flag | Clients can't subscribe | -| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path | -| Network/filesystem in reducer | Use procedures instead | Sandbox violation | -| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed | - -### Client-side errors - -| Wrong | Right | Error | -|-------|-------|-------| -| Wrong crate name | `spacetimedb-sdk` | Dependency not found | -| Manual event loop | Use `tokio` runtime | Async issues | - ---- - -## Hard Requirements - -1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this -2. **Import `Table` trait** — required for all table operations -3. **Use `&ReducerContext`** — not `&mut ReducerContext` -4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table` -5. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG -6. **Use `ctx.random()` or `ctx.rng`** — not `rand` crate for random numbers -7. **Add `public` flag** — if clients need to subscribe to a table - ---- - -## Project Setup - -### Cargo.toml Requirements - -```toml -[package] -name = "my-module" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -spacetimedb = "1.0" -log = "0.4" -``` - -The `crate-type = ["cdylib"]` is required for WebAssembly compilation. - -### Essential Imports - -```rust -use spacetimedb::{ReducerContext, Table}; -``` - -Additional imports as needed: -```rust -use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt}; -use spacetimedb::sats::{i256, u256}; // For 256-bit integers -``` - -## Table Definitions - -Tables store data in SpacetimeDB. Define tables using the `#[spacetimedb::table]` macro on a struct. - -### Basic Table - -```rust -#[spacetimedb::table(name = player, public)] -pub struct Player { - #[primary_key] - #[auto_inc] - id: u64, - name: String, - score: u32, -} -``` - -### Table Attributes - -| Attribute | Description | -|-----------|-------------| -| `name = identifier` | Required. The table name used in `ctx.db.{name}()` | -| `public` | Makes table visible to clients via subscriptions | -| `scheduled(reducer_name)` | Creates a schedule table that triggers the named reducer | -| `index(name = idx, btree(columns = [a, b]))` | Creates a multi-column index | - -### Column Attributes - -| Attribute | Description | -|-----------|-------------| -| `#[primary_key]` | Unique identifier for the row (one per table max) | -| `#[unique]` | Enforces uniqueness, enables `find()` method | -| `#[auto_inc]` | Auto-generates unique integer values when inserting 0 | -| `#[index(btree)]` | Creates a B-tree index for efficient lookups | -| `#[default(value)]` | Default value for migrations (must be const-evaluable) | - -### Supported Column Types - -**Primitives**: `u8`, `u16`, `u32`, `u64`, `u128`, `u256`, `i8`, `i16`, `i32`, `i64`, `i128`, `i256`, `f32`, `f64`, `bool`, `String` - -**SpacetimeDB Types**: `Identity`, `ConnectionId`, `Timestamp`, `Uuid`, `ScheduleAt` - -**Collections**: `Vec`, `Option`, `Result` where inner types are also supported - -**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]` - -### Insert Returns the Row - -```rust -// Insert and get the auto-generated ID -let row = ctx.db.task().insert(Task { - id: 0, // Placeholder for auto_inc - owner_id: ctx.sender, - title: "New task".to_string(), - created_at: ctx.timestamp, -}); -let new_id = row.id; // Get the actual ID -``` - ---- - -## Data Visibility and Row-Level Security - -**`public` flag exposes ALL rows to ALL clients.** - -| Scenario | Pattern | -|----------|---------| -| Everyone sees all rows | `#[table(name = x, public)]` | -| Users see only their data | Private table + row-level security | - -### Private Table (default) - -```rust -// No public flag — only server can read -#[table(name = secret_data)] -pub struct SecretData { ... } -``` - -### Row-Level Security (RLS) - -Use RLS to filter which rows each client can see: - -```rust -// Use row-level security for per-user visibility -#[table(name = player_data, public)] -#[rls(filter = |ctx, row| row.owner_id == ctx.sender)] -pub struct PlayerData { - #[primary_key] - pub id: u64, - pub owner_id: Identity, - pub data: String, -} -``` - -With RLS, clients can subscribe to the table but only see rows where the filter returns `true` for their identity. - ---- - -## Reducers - -Reducers are transactional functions that modify database state. They run inside the database and are the only way to mutate tables. - -### Basic Reducer - -```rust -#[spacetimedb::reducer] -pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> { - if name.is_empty() { - return Err("Name cannot be empty".to_string()); - } - - ctx.db.player().insert(Player { - id: 0, // auto_inc assigns the value - name, - score: 0, - }); - - Ok(()) -} -``` - -### Reducer Rules - -1. First parameter must be `&ReducerContext` -2. Additional parameters must implement `SpacetimeType` -3. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display` -4. All changes roll back on panic or `Err` return -5. Reducers run in isolation from concurrent reducers -6. Cannot make network requests or access filesystem -7. Must import `Table` trait for table operations: `use spacetimedb::Table;` - -## ReducerContext - -The `ReducerContext` provides access to the database and caller information. - -### Properties - -```rust -#[spacetimedb::reducer] -pub fn example(ctx: &ReducerContext) { - // Database access - let _table = ctx.db.player(); - - // Caller identity (always present) - let caller: Identity = ctx.sender; - - // Connection ID (None for scheduled/system reducers) - let conn: Option = ctx.connection_id; - - // Invocation timestamp - let when: Timestamp = ctx.timestamp; - - // Module's own identity - let module_id: Identity = ctx.identity(); - - // Random number generation (deterministic) - let random_val: u32 = ctx.random(); - - // UUID generation - let uuid = ctx.new_uuid_v4().unwrap(); // Random UUID - let uuid = ctx.new_uuid_v7().unwrap(); // Timestamp-based UUID - - // Check if caller is internal (scheduled reducer) - if ctx.sender_auth().is_internal() { - // Called by scheduler, not external client - } -} -``` - -## Table Operations - -### Insert - -```rust -// Insert returns the row with auto_inc values populated -let player = ctx.db.player().insert(Player { - id: 0, // auto_inc fills this - name: "Alice".to_string(), - score: 100, -}); -log::info!("Created player with id: {}", player.id); -``` - -### Find by Unique/Primary Key - -```rust -// find() returns Option -if let Some(player) = ctx.db.player().id().find(123) { - log::info!("Found: {}", player.name); -} -``` - -### Filter by Indexed Column - -```rust -// filter() returns an iterator -for player in ctx.db.player().name().filter("Alice") { - log::info!("Player {}: score {}", player.id, player.score); -} - -// Range queries (Rust range syntax) -for player in ctx.db.player().score().filter(50..=100) { - log::info!("{} has score {}", player.name, player.score); -} -``` - -### Update - -Updates require a unique column. Find the row, modify it, then call `update()`: - -```rust -if let Some(mut player) = ctx.db.player().id().find(123) { - player.score += 10; - ctx.db.player().id().update(player); -} -``` - -### Delete - -```rust -// Delete by unique key -ctx.db.player().id().delete(&123); - -// Delete by indexed column (returns count) -let deleted = ctx.db.player().name().delete("Alice"); -log::info!("Deleted {} rows", deleted); - -// Delete by range -ctx.db.player().score().delete(..50); // Delete all with score < 50 -``` - -### Iterate All Rows - -```rust -for player in ctx.db.player().iter() { - log::info!("{}: {}", player.name, player.score); -} - -// Count rows -let total = ctx.db.player().count(); -``` - -## Indexes - -### Single-Column Index - -```rust -#[spacetimedb::table(name = player, public)] -pub struct Player { - #[primary_key] - id: u64, - #[index(btree)] - level: u32, - name: String, -} -``` - -### Multi-Column Index - -```rust -#[spacetimedb::table( - name = score, - public, - index(name = by_player_level, btree(columns = [player_id, level])) -)] -pub struct Score { - player_id: u32, - level: u32, - points: i64, -} -``` - -### Querying Multi-Column Indexes - -```rust -// Prefix match (first column only) -for score in ctx.db.score().by_player_level().filter(&123u32) { - log::info!("Level {}: {} points", score.level, score.points); -} - -// Full match -for score in ctx.db.score().by_player_level().filter((123u32, 5u32)) { - log::info!("Points: {}", score.points); -} - -// Prefix with range on second column -for score in ctx.db.score().by_player_level().filter((123u32, 1u32..=10u32)) { - log::info!("Level {}: {} points", score.level, score.points); -} -``` - -## Identity and Authentication - -### Storing User Identity - -```rust -#[spacetimedb::table(name = user_profile, public)] -pub struct UserProfile { - #[primary_key] - identity: Identity, - display_name: String, - created_at: Timestamp, -} - -#[spacetimedb::reducer] -pub fn create_profile(ctx: &ReducerContext, display_name: String) -> Result<(), String> { - // Check if profile already exists - if ctx.db.user_profile().identity().find(ctx.sender).is_some() { - return Err("Profile already exists".to_string()); - } - - ctx.db.user_profile().insert(UserProfile { - identity: ctx.sender, - display_name, - created_at: ctx.timestamp, - }); - - Ok(()) -} -``` - -### Verifying Caller Identity - -```rust -#[spacetimedb::reducer] -pub fn update_my_profile(ctx: &ReducerContext, new_name: String) -> Result<(), String> { - // Only allow users to update their own profile - if let Some(mut profile) = ctx.db.user_profile().identity().find(ctx.sender) { - profile.display_name = new_name; - ctx.db.user_profile().identity().update(profile); - Ok(()) - } else { - Err("Profile not found".to_string()) - } -} -``` - -## Lifecycle Reducers - -### Init Reducer - -Runs once when the module is first published or database is cleared: - -```rust -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) -> Result<(), String> { - log::info!("Database initializing..."); - - // Set up default data - if ctx.db.config().count() == 0 { - ctx.db.config().insert(Config { - key: "version".to_string(), - value: "1.0.0".to_string(), - }); - } - - Ok(()) -} -``` - -### Client Connected - -Runs when a client establishes a connection: - -```rust -#[spacetimedb::reducer(client_connected)] -pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { - log::info!("Client connected: {}", ctx.sender); - - // connection_id is guaranteed to be Some - let conn_id = ctx.connection_id.unwrap(); - - // Create or update user session - if let Some(user) = ctx.db.user().identity().find(ctx.sender) { - ctx.db.user().identity().update(User { online: true, ..user }); - } else { - ctx.db.user().insert(User { - identity: ctx.sender, - online: true, - name: None, - }); - } - - Ok(()) -} -``` - -### Client Disconnected - -Runs when a client connection terminates: - -```rust -#[spacetimedb::reducer(client_disconnected)] -pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { - log::info!("Client disconnected: {}", ctx.sender); - - if let Some(user) = ctx.db.user().identity().find(ctx.sender) { - ctx.db.user().identity().update(User { online: false, ..user }); - } - - Ok(()) -} -``` - -## Scheduled Reducers - -Schedule reducers to run at specific times or intervals. - -### Define a Schedule Table - -```rust -use spacetimedb::ScheduleAt; -use std::time::Duration; - -#[spacetimedb::table(name = game_tick_schedule, scheduled(game_tick))] -pub struct GameTickSchedule { - #[primary_key] - #[auto_inc] - scheduled_id: u64, - scheduled_at: ScheduleAt, -} - -#[spacetimedb::reducer] -fn game_tick(ctx: &ReducerContext, schedule: GameTickSchedule) { - // Verify this is an internal call (from scheduler) - if !ctx.sender_auth().is_internal() { - log::warn!("External call to scheduled reducer rejected"); - return; - } - - // Game logic here - log::info!("Game tick at {:?}", ctx.timestamp); -} -``` - -### Scheduling at Intervals - -```rust -#[spacetimedb::reducer] -fn start_game_loop(ctx: &ReducerContext) { - // Schedule game tick every 100ms - ctx.db.game_tick_schedule().insert(GameTickSchedule { - scheduled_id: 0, - scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()), - }); -} -``` - -### Scheduling at Specific Times - -```rust -#[spacetimedb::reducer] -fn schedule_reminder(ctx: &ReducerContext, delay_secs: u64) { - let run_at = ctx.timestamp + Duration::from_secs(delay_secs); - - ctx.db.reminder_schedule().insert(ReminderSchedule { - scheduled_id: 0, - scheduled_at: ScheduleAt::Time(run_at), - message: "Time's up!".to_string(), - }); -} -``` - ---- - -## Procedures (Beta) - -**Procedures are for side effects (HTTP, filesystem) that reducers can't do.** - -Procedures are currently unstable. Enable with: - -```toml -# Cargo.toml -[dependencies] -spacetimedb = { version = "1.*", features = ["unstable"] } -``` - -```rust -use spacetimedb::{procedure, ProcedureContext}; - -// Simple procedure -#[procedure] -fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 { - a as u64 + b as u64 -} - -// Procedure with database access -#[procedure] -fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { - // HTTP request (allowed in procedures, not reducers) - let data = fetch_from_url(&url)?; - - // Database access requires explicit transaction - ctx.try_with_tx(|tx| { - tx.db.external_data().insert(ExternalData { - id: 0, - content: data, - }); - Ok(()) - })?; - - Ok(()) -} -``` - -### Key Differences from Reducers - -| Reducers | Procedures | -|----------|------------| -| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | -| Direct `ctx.db` access | Must use `ctx.with_tx()` | -| No HTTP/network | HTTP allowed | -| No return values | Can return data | - ---- - -## Error Handling - -### Sender Errors (Expected) - -Return errors for invalid client input: - -```rust -#[spacetimedb::reducer] -pub fn transfer_credits( - ctx: &ReducerContext, - to_user: Identity, - amount: u32, -) -> Result<(), String> { - let sender = ctx.db.user().identity().find(ctx.sender) - .ok_or("Sender not found")?; - - if sender.credits < amount { - return Err("Insufficient credits".to_string()); - } - - // Perform transfer... - Ok(()) -} -``` - -### Programmer Errors (Bugs) - -Use panic for unexpected states that indicate bugs: - -```rust -#[spacetimedb::reducer] -pub fn process_data(ctx: &ReducerContext, data: Vec) { - // This should never happen - indicates a bug - assert!(!data.is_empty(), "Unexpected empty data"); - - // Use expect for operations that should always succeed - let parsed = parse_data(&data).expect("Failed to parse data"); -} -``` - -## Custom Types - -Define custom types using `#[derive(SpacetimeType)]`: - -```rust -use spacetimedb::SpacetimeType; - -#[derive(SpacetimeType)] -pub enum PlayerStatus { - Active, - Idle, - Away, -} - -#[derive(SpacetimeType)] -pub struct Position { - x: f32, - y: f32, - z: f32, -} - -#[spacetimedb::table(name = player, public)] -pub struct Player { - #[primary_key] - id: u64, - status: PlayerStatus, - position: Position, -} -``` - -## Multiple Tables from Same Type - -Apply multiple `#[spacetimedb::table]` attributes to create separate tables with the same schema: - -```rust -#[spacetimedb::table(name = online_player, public)] -#[spacetimedb::table(name = offline_player)] -pub struct Player { - #[primary_key] - identity: Identity, - name: String, -} - -#[spacetimedb::reducer] -fn player_logout(ctx: &ReducerContext) { - if let Some(player) = ctx.db.online_player().identity().find(ctx.sender) { - ctx.db.offline_player().insert(player.clone()); - ctx.db.online_player().identity().delete(&ctx.sender); - } -} -``` - -## Logging - -Use the `log` crate for debug output. View logs with `spacetime logs `: - -```rust -log::trace!("Detailed trace info"); -log::debug!("Debug information"); -log::info!("General information"); -log::warn!("Warning message"); -log::error!("Error occurred"); -``` - -Never use `println!`, `eprintln!`, or `dbg!` in modules. - -## Common Patterns - -### Player Session Management - -```rust -#[spacetimedb::table(name = player, public)] -pub struct Player { - #[primary_key] - identity: Identity, - name: Option, - online: bool, - last_seen: Timestamp, -} - -#[spacetimedb::reducer(client_connected)] -pub fn on_connect(ctx: &ReducerContext) { - match ctx.db.player().identity().find(ctx.sender) { - Some(player) => { - ctx.db.player().identity().update(Player { - online: true, - last_seen: ctx.timestamp, - ..player - }); - } - None => { - ctx.db.player().insert(Player { - identity: ctx.sender, - name: None, - online: true, - last_seen: ctx.timestamp, - }); - } - } -} - -#[spacetimedb::reducer(client_disconnected)] -pub fn on_disconnect(ctx: &ReducerContext) { - if let Some(player) = ctx.db.player().identity().find(ctx.sender) { - ctx.db.player().identity().update(Player { - online: false, - last_seen: ctx.timestamp, - ..player - }); - } -} -``` - -### Sequential ID Generation (Gap-Free) - -Auto-increment may have gaps after crashes. For strictly sequential IDs: - -```rust -#[spacetimedb::table(name = counter)] -pub struct Counter { - #[primary_key] - name: String, - value: u64, -} - -#[spacetimedb::reducer] -fn create_invoice(ctx: &ReducerContext, amount: u64) -> Result<(), String> { - let mut counter = ctx.db.counter().name().find(&"invoice".to_string()) - .unwrap_or(Counter { name: "invoice".to_string(), value: 0 }); - - counter.value += 1; - ctx.db.counter().name().update(counter.clone()); - - ctx.db.invoice().insert(Invoice { - invoice_number: counter.value, - amount, - }); - - Ok(()) -} -``` - -### Owner-Only Reducers - -```rust -#[spacetimedb::table(name = admin)] -pub struct Admin { - #[primary_key] - identity: Identity, -} - -#[spacetimedb::reducer] -fn admin_action(ctx: &ReducerContext) -> Result<(), String> { - if ctx.db.admin().identity().find(ctx.sender).is_none() { - return Err("Not authorized".to_string()); - } - - // Admin-only logic here - Ok(()) -} -``` - -## Build and Deploy - -```bash -# Build the module -spacetime build - -# Deploy to local instance -spacetime publish my_database - -# Deploy with database clear (DESTROYS DATA) -spacetime publish my_database --delete-data - -# View logs -spacetime logs my_database - -# Call a reducer -spacetime call my_database create_player "Alice" - -# Run SQL query -spacetime sql my_database "SELECT * FROM player" - -# Generate bindings -spacetime generate --lang rust --out-dir /src/module_bindings --project-path -``` - -## Important Constraints - -1. **No Global State**: Static/global variables are undefined behavior across reducer calls -2. **No Side Effects**: Reducers cannot make network requests or file I/O -3. **Deterministic Execution**: Use `ctx.random()` and `ctx.new_uuid_*()` for randomness -4. **Transactional**: All reducer changes roll back on failure -5. **Isolated**: Reducers don't see concurrent changes until commit diff --git a/.claude/skills/spacetimedb-typescript/SKILL.md b/.claude/skills/spacetimedb-typescript/SKILL.md deleted file mode 100644 index 8a52b871182..00000000000 --- a/.claude/skills/spacetimedb-typescript/SKILL.md +++ /dev/null @@ -1,1003 +0,0 @@ ---- -name: spacetimedb-typescript -description: Build TypeScript clients for SpacetimeDB. Use when connecting to SpacetimeDB from web apps, Node.js, Deno, Bun, or other JavaScript runtimes. -license: Apache-2.0 -metadata: - author: clockworklabs - version: "1.0" ---- - -# SpacetimeDB TypeScript SDK - -Build real-time TypeScript clients that connect directly to SpacetimeDB modules. The SDK provides type-safe database access, automatic synchronization, and reactive updates for web apps, Node.js, Deno, Bun, and other JavaScript runtimes. - ---- - -## HALLUCINATED APIs — DO NOT USE - -**These APIs DO NOT EXIST. LLMs frequently hallucinate them.** - -```typescript -// WRONG PACKAGE — does not exist -import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk"; - -// WRONG — these methods don't exist -SpacetimeDBClient.connect(...); -SpacetimeDBClient.call("reducer_name", [...]); -connection.call("reducer_name", [arg1, arg2]); - -// WRONG — positional reducer arguments -conn.reducers.doSomething("value"); // WRONG! -``` - -### CORRECT PATTERNS: - -```typescript -// CORRECT IMPORTS -import { DbConnection, tables } from './module_bindings'; // Generated! -import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react'; - -// CORRECT REDUCER CALLS — object syntax, not positional! -conn.reducers.doSomething({ value: 'test' }); -conn.reducers.updateItem({ itemId: 1n, newValue: 42 }); - -// CORRECT DATA ACCESS — useTable returns [rows, isLoading] -const [items, isLoading] = useTable(tables.item); -``` - -### DO NOT: -- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)` -- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings` - ---- - -## Common Mistakes Table - -### Server-side errors - -| Wrong | Right | Error | -|-------|-------|-------| -| Missing `package.json` | Create `package.json` | "could not detect language" | -| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" | -| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle | -| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error | -| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error | -| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" | -| `.filter()` on unique column | `.find()` on unique column | TypeError | -| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" | -| `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID | -| `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" | -| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" | -| Multi-column index `.filter()` | Use single-column index | PANIC or silent empty results | -| `.iter()` in views | Use index lookups only | Views can't scan tables | -| `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions | -| `ctx.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable | - -### Client-side errors - -| Wrong | Right | Error | -|-------|-------|-------| -| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath | -| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax | -| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render | -| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring | -| Optimistic UI updates | Let subscriptions drive state | Desync issues | -| `` | `connectionBuilder={...}` | Wrong prop name | - ---- - -## Hard Requirements - -1. **DO NOT edit generated bindings** — regenerate with `spacetime generate` -2. **Reducers are transactional** — they do not return data -3. **Reducers must be deterministic** — no filesystem, network, timers, random -4. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args -5. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb` -6. **useTable returns a tuple** — `const [rows, isLoading] = useTable(tables.myTable)` -7. **Memoize connectionBuilder** — wrap in `useMemo(() => ..., [])` to prevent reconnects -8. **Views can only use index lookups** — `.iter()` is not allowed in views - ---- - -## Installation - -```bash -npm install spacetimedb -# or -pnpm add spacetimedb -# or -yarn add spacetimedb -``` - -For Node.js 18-21, install the `undici` peer dependency: - -```bash -npm install spacetimedb undici -``` - -Node.js 22+ and browser environments work out of the box. - -## Generating Type Bindings - -Before using the SDK, generate TypeScript bindings from your SpacetimeDB module: - -```bash -spacetime generate --lang typescript --out-dir ./src/module_bindings --project-path ./server -``` - -This creates a `module_bindings` directory with: -- `index.ts` - Main exports including `DbConnection`, `tables`, `reducers`, `query` -- Type definitions for each table (e.g., `player_table.ts`, `user_table.ts`) -- Type definitions for each reducer (e.g., `create_player_reducer.ts`) -- Custom type definitions (e.g., `point_type.ts`) - -## Basic Connection Setup - -```typescript -import { DbConnection } from './module_bindings'; - -const connection = DbConnection.builder() - .withUri('ws://localhost:3000') - .withModuleName('my_database') - .onConnect((conn, identity, token) => { - console.log('Connected with identity:', identity.toHexString()); - - // Store token for reconnection - localStorage.setItem('spacetimedb_token', token); - - // Subscribe to tables after connection - conn.subscriptionBuilder().subscribe('SELECT * FROM player'); - }) - .onDisconnect((ctx) => { - console.log('Disconnected'); - }) - .onConnectError((ctx, error) => { - console.error('Connection error:', error); - }) - .build(); -``` - -## Connection Builder Options - -```typescript -DbConnection.builder() - // Required: SpacetimeDB server URI - .withUri('ws://localhost:3000') - - // Required: Database module name or address - .withModuleName('my_database') - - // Optional: Authentication token for reconnection - .withToken(localStorage.getItem('spacetimedb_token') ?? undefined) - - // Optional: Enable compression (default: 'gzip') - .withCompression('gzip') // or 'none' - - // Optional: Light mode reduces network traffic - .withLightMode(true) - - // Optional: Wait for durable writes before receiving updates - .withConfirmedReads(true) - - // Connection lifecycle callbacks - .onConnect((conn, identity, token) => { /* ... */ }) - .onDisconnect((ctx, error) => { /* ... */ }) - .onConnectError((ctx, error) => { /* ... */ }) - - .build(); -``` - -## Subscribing to Tables - -Subscriptions sync table data to the client cache. Use SQL queries to filter what data you receive. - -### Basic Subscription - -```typescript -connection.subscriptionBuilder() - .onApplied((ctx) => { - console.log('Subscription applied, cache is ready'); - }) - .onError((ctx, error) => { - console.error('Subscription error:', error); - }) - .subscribe('SELECT * FROM player'); -``` - -### Multiple Queries - -```typescript -connection.subscriptionBuilder() - .subscribe([ - 'SELECT * FROM player', - 'SELECT * FROM game_state', - 'SELECT * FROM message WHERE room_id = 1' - ]); -``` - -### Typed Query Builder - -Use the generated `query` object for type-safe queries: - -```typescript -import { query } from './module_bindings'; - -// Simple query - selects all rows -connection.subscriptionBuilder() - .subscribe(query.player.build()); - -// Query with WHERE clause -connection.subscriptionBuilder() - .subscribe( - query.player - .where(row => row.name.eq('Alice')) - .build() - ); - -// Complex conditions -connection.subscriptionBuilder() - .subscribe( - query.player - .where(row => row.score.gte(100)) - .where(row => row.isActive.eq(true)) - .build() - ); -``` - -### Subscribe to All Tables - -For development or small datasets: - -```typescript -connection.subscriptionBuilder().subscribeToAllTables(); -``` - -### Unsubscribing - -```typescript -const handle = connection.subscriptionBuilder() - .onApplied(() => console.log('Subscribed')) - .subscribe('SELECT * FROM player'); - -// Later, unsubscribe -handle.unsubscribe(); - -// Or with callback when complete -handle.unsubscribeThen((ctx) => { - console.log('Unsubscribed successfully'); -}); -``` - -## Accessing Table Data - -After subscription, access cached data through `connection.db`: - -```typescript -// Iterate all rows -for (const player of connection.db.player.iter()) { - console.log(player.name, player.score); -} - -// Convert to array -const players = Array.from(connection.db.player.iter()); - -// Count rows -const count = connection.db.player.count(); - -// Find by primary key (if table has one) -const player = connection.db.player.id.find(42); - -// Find by indexed column -const alice = connection.db.player.name.find('Alice'); -``` - -## Table Event Callbacks - -Listen for real-time changes to table data: - -```typescript -// Row inserted -connection.db.player.onInsert((ctx, player) => { - console.log('New player:', player.name); -}); - -// Row deleted -connection.db.player.onDelete((ctx, player) => { - console.log('Player left:', player.name); -}); - -// Row updated (requires primary key on table) -connection.db.player.onUpdate((ctx, oldPlayer, newPlayer) => { - console.log(`${oldPlayer.name} score: ${oldPlayer.score} -> ${newPlayer.score}`); -}); - -// Remove callbacks -const onInsertCb = (ctx, player) => console.log(player); -connection.db.player.onInsert(onInsertCb); -connection.db.player.removeOnInsert(onInsertCb); -``` - -### Event Context - -Callbacks receive an `EventContext` with information about the event: - -```typescript -connection.db.player.onInsert((ctx, player) => { - // Access to database - const allPlayers = Array.from(ctx.db.player.iter()); - - // Check event type - if (ctx.event.tag === 'Reducer') { - const { callerIdentity, reducer, status } = ctx.event.value; - console.log(`Triggered by reducer: ${reducer.name}`); - } - - // Call other reducers - ctx.reducers.sendMessage({ playerId: player.id, text: 'Welcome!' }); -}); -``` - -## Calling Reducers - -Reducers are server-side functions that modify the database. **CRITICAL: Use object syntax, not positional arguments.** - -```typescript -// CORRECT: Object syntax -connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } }); - -// WRONG: Positional arguments -// connection.reducers.createPlayer('Alice', { x: 0, y: 0 }); // DO NOT DO THIS - -// Listen for reducer results -connection.reducers.onCreatePlayer((ctx, args) => { - const { callerIdentity, status, timestamp, energyConsumed } = ctx.event; - - if (status.tag === 'Committed') { - console.log('Player created successfully'); - } else if (status.tag === 'Failed') { - console.error('Failed:', status.value); - } -}); - -// Remove reducer callback -connection.reducers.removeOnCreatePlayer(callback); -``` - -### Snake_case to camelCase conversion -- Server: `spacetimedb.reducer('do_something', ...)` -- Client: `conn.reducers.doSomething({ ... })` - -### Reducer Flags - -Control how the server handles reducer calls: - -```typescript -// NoSuccessNotify: Don't send TransactionUpdate on success (reduces traffic) -connection.setReducerFlags.movePlayer('NoSuccessNotify'); - -// FullUpdate: Always send full TransactionUpdate (default) -connection.setReducerFlags.movePlayer('FullUpdate'); -``` - -## Views - -Views provide filtered access to private table data based on the connected user. - -### ViewContext vs AnonymousViewContext - -```typescript -// ViewContext — has ctx.sender, result varies per user (computed per-subscriber) -spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => { - return [...ctx.db.item.by_owner.filter(ctx.sender)]; -}); - -// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf) -spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => { - return [...ctx.db.player.by_score.filter(/* top scores */)]; -}); -``` - -### CRITICAL: Views can only use index lookups - -```typescript -// WRONG — views cannot use .iter() -spacetimedb.view( - { name: 'my_data_wrong', public: true }, - t.array(PrivateData.rowType), - (ctx) => [...ctx.db.privateData.iter()] // NOT ALLOWED -); - -// RIGHT — use index lookup -spacetimedb.view( - { name: 'my_data', public: true }, - t.array(PrivateData.rowType), - (ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)] -); -``` - -### Subscribing to Views - -Views require explicit subscription: - -```typescript -conn.subscriptionBuilder().subscribe([ - 'SELECT * FROM public_table', - 'SELECT * FROM my_data', // Views need explicit SQL! -]); -``` - -## Procedures (Beta) - -**Procedures are for side effects (HTTP requests, etc.) that reducers can't do.** - -Procedures are currently in beta. API may change. - -### Defining a procedure - -```typescript -spacetimedb.procedure( - 'fetch_external_data', - { url: t.string() }, - t.string(), // return type - (ctx, { url }) => { - const response = ctx.http.fetch(url); - return response.text(); - } -); -``` - -### CRITICAL: Database access in procedures - -**Procedures don't have `ctx.db`. Use `ctx.withTx()` for database access.** - -```typescript -spacetimedb.procedure('save_fetched_data', { url: t.string() }, t.unit(), (ctx, { url }) => { - // Fetch external data (outside transaction) - const response = ctx.http.fetch(url); - const data = response.text(); - - // WRONG — ctx.db doesn't exist in procedures - // ctx.db.myTable.insert({ ... }); - - // RIGHT — use ctx.withTx() for database access - ctx.withTx(tx => { - tx.db.myTable.insert({ - id: 0n, - content: data, - fetchedAt: tx.timestamp, - fetchedBy: tx.sender, - }); - }); - - return {}; -}); -``` - -### Key differences from reducers - -| Reducers | Procedures | -|----------|------------| -| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` | -| Automatic transaction | Manual transaction management | -| No HTTP/network | `ctx.http.fetch()` available | -| No return values to caller | Can return data to caller | - -## Identity and Authentication - -```typescript -import { Identity } from 'spacetimedb'; - -// Get current identity -const identity = connection.identity; -console.log(identity?.toHexString()); - -// Compare identities -if (identity?.isEqual(otherIdentity)) { - console.log('Same user'); -} - -// Create from hex string -const parsed = Identity.fromString('0x1234...'); - -// Zero identity -const zero = Identity.zero(); - -// Compare identities using toHexString() -const isOwner = row.ownerId.toHexString() === myIdentity.toHexString(); -``` - -### Persisting Authentication - -```typescript -// On connect, save the token -.onConnect((conn, identity, token) => { - localStorage.setItem('auth_token', token); - localStorage.setItem('identity', identity.toHexString()); -}) - -// On reconnect, use saved token -.withToken(localStorage.getItem('auth_token') ?? undefined) -``` - -### Stale token handling - -```typescript -const onConnectError = (_ctx: ErrorContext, err: Error) => { - if (err.message?.includes('Unauthorized') || err.message?.includes('401')) { - localStorage.removeItem('auth_token'); - window.location.reload(); - } -}; -``` - -## React Integration - -The SDK includes React hooks for reactive UI updates. - -### Provider Setup - -```tsx -import React, { useMemo } from 'react'; -import ReactDOM from 'react-dom/client'; -import { SpacetimeDBProvider } from 'spacetimedb/react'; -import { DbConnection, query } from './module_bindings'; -import App from './App'; - -function Root() { - // CRITICAL: Memoize to prevent reconnects on every render - const connectionBuilder = useMemo(() => - DbConnection.builder() - .withUri('ws://localhost:3000') - .withModuleName('my_game') - .withToken(localStorage.getItem('auth_token') || undefined) - .onConnect((conn, identity, token) => { - console.log('Connected:', identity.toHexString()); - localStorage.setItem('auth_token', token); - conn.subscriptionBuilder().subscribe(query.player.build()); - }) - .onDisconnect(() => console.log('Disconnected')) - .onConnectError((ctx, err) => console.error('Error:', err)), - [] // Empty deps - only create once - ); - - return ( - - - - ); -} - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -); -``` - -### useSpacetimeDB Hook - -Access connection state: - -```tsx -import { useSpacetimeDB } from 'spacetimedb/react'; - -function ConnectionStatus() { - const { isActive, identity, token, connectionId, connectionError } = useSpacetimeDB(); - - if (connectionError) { - return
Error: {connectionError.message}
; - } - - if (!isActive) { - return
Connecting...
; - } - - return
Connected as {identity?.toHexString()}
; -} -``` - -### useTable Hook - -Subscribe to table data with reactive updates. **CRITICAL: Returns a tuple `[rows, isLoading]`.** - -```tsx -import { useTable, where, eq } from 'spacetimedb/react'; -import { tables } from './module_bindings'; - -function PlayerList() { - // CORRECT: Tuple destructuring - const [players, isLoading] = useTable(tables.player); - - if (isLoading) return
Loading...
; - - return ( -
    - {players.map(player => ( -
  • {player.name}: {player.score}
  • - ))} -
- ); -} - -function FilteredPlayerList() { - // Filtered players with callbacks - const [activePlayers, isLoading] = useTable( - tables.player, - where(eq('isActive', true)), - { - onInsert: (player) => console.log('Player joined:', player.name), - onDelete: (player) => console.log('Player left:', player.name), - onUpdate: (oldPlayer, newPlayer) => { - console.log(`${oldPlayer.name} updated`); - }, - } - ); - - return ( -
    - {activePlayers.map(player => ( -
  • {player.name}
  • - ))} -
- ); -} -``` - -### useReducer Hook - -Call reducers from components: - -```tsx -import { useReducer } from 'spacetimedb/react'; -import { reducers } from './module_bindings'; - -function CreatePlayerForm() { - const createPlayer = useReducer(reducers.createPlayer); - const [name, setName] = useState(''); - - const handleSubmit = (e) => { - e.preventDefault(); - // CORRECT: Object syntax - createPlayer({ name, location: { x: 0, y: 0 } }); - setName(''); - }; - - return ( -
- setName(e.target.value)} /> - -
- ); -} -``` - -## Vue Integration - -The SDK includes Vue composables: - -```typescript -import { SpacetimeDBProvider, useSpacetimeDB, useTable, useReducer } from 'spacetimedb/vue'; -``` - -Usage is similar to React hooks. - -## Svelte Integration - -The SDK includes Svelte stores: - -```typescript -import { SpacetimeDBProvider, useSpacetimeDB, useTable, useReducer } from 'spacetimedb/svelte'; -``` - -## Server-Side Usage (Node.js, Deno, Bun) - -The SDK works in server-side JavaScript runtimes: - -```typescript -import { DbConnection } from './module_bindings'; - -async function main() { - const connection = DbConnection.builder() - .withUri('ws://localhost:3000') - .withModuleName('my_database') - .onConnect((conn, identity, token) => { - console.log('Connected:', identity.toHexString()); - - conn.subscriptionBuilder() - .onApplied(() => { - // Process data - for (const player of conn.db.player.iter()) { - console.log(player); - } - }) - .subscribe('SELECT * FROM player'); - }) - .build(); -} - -main(); -``` - -## Timestamps - -### Server-side - -```typescript -import { Timestamp, ScheduleAt } from 'spacetimedb'; - -// Current time -ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp }); - -// Future time (add microseconds) -const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes -``` - -### Client-side (CRITICAL) - -**Timestamps are objects, not numbers:** - -```typescript -// WRONG -const date = new Date(row.createdAt); -const date = new Date(Number(row.createdAt / 1000n)); - -// RIGHT -const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n)); -``` - -### ScheduleAt on client - -```typescript -// ScheduleAt is a tagged union -if (scheduleAt.tag === 'Time') { - const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n)); -} -``` - -## Scheduled Tables - -```typescript -// Scheduled table MUST use scheduledId and scheduledAt columns -export const CleanupJob = table({ - name: 'cleanup_job', - scheduled: 'run_cleanup' // reducer name -}, { - scheduledId: t.u64().primaryKey().autoInc(), - scheduledAt: t.scheduleAt(), - targetId: t.u64(), // Your custom data -}); - -// Scheduled reducer receives full row as arg -spacetimedb.reducer('run_cleanup', { arg: CleanupJob.rowType }, (ctx, { arg }) => { - // arg.scheduledId, arg.targetId available - // Row is auto-deleted after reducer completes -}); - -// Schedule a job -import { ScheduleAt } from 'spacetimedb'; -const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds -ctx.db.cleanupJob.insert({ - scheduledId: 0n, - scheduledAt: ScheduleAt.time(futureTime), - targetId: someId -}); - -// Cancel a job by deleting the row -ctx.db.cleanupJob.scheduledId.delete(jobId); -``` - -## Error Handling - -### Connection Errors - -```typescript -DbConnection.builder() - .onConnectError((ctx, error) => { - console.error('Failed to connect:', error.message); - - // Implement retry logic - setTimeout(() => { - // Rebuild connection - }, 5000); - }) - .build(); -``` - -### Subscription Errors - -```typescript -connection.subscriptionBuilder() - .onError((ctx, error) => { - console.error('Subscription failed:', error.message); - }) - .subscribe('SELECT * FROM player'); -``` - -### Reducer Errors - -```typescript -connection.reducers.onCreatePlayer((ctx, args) => { - const { status } = ctx.event; - - switch (status.tag) { - case 'Committed': - console.log('Success'); - break; - case 'Failed': - console.error('Reducer failed:', status.value); - break; - case 'OutOfEnergy': - console.error('Out of energy'); - break; - } -}); -``` - -## Disconnecting - -```typescript -// Gracefully disconnect -connection.disconnect(); -``` - -## Type Reference - -### Core Types - -```typescript -import { - Identity, // User identity (256-bit) - ConnectionId, // Connection identifier - Timestamp, // SpacetimeDB timestamp - TimeDuration, // Duration type - Uuid, // UUID type -} from 'spacetimedb'; -``` - -### Generated Types - -```typescript -// From your module_bindings -import { - DbConnection, // Connection class - DbConnectionBuilder, // Builder class - SubscriptionBuilder, // Subscription builder - SubscriptionHandle, // Subscription handle - EventContext, // Event callback context - ReducerEventContext, // Reducer callback context - ErrorContext, // Error callback context - tables, // Table accessors for useTable - reducers, // Reducer definitions for useReducer - query, // Typed query builder - - // Your custom types - Player, - Point, - // ... etc -} from './module_bindings'; -``` - -## Commands - -```bash -# Start local server -spacetime start - -# Publish module -spacetime publish --project-path - -# Clear database and republish -spacetime publish --clear-database -y --project-path - -# Generate bindings -spacetime generate --lang typescript --out-dir /src/module_bindings --project-path - -# View logs -spacetime logs -``` - -## Best Practices - -1. **Store auth tokens**: Save the token from `onConnect` for seamless reconnection. - -2. **Subscribe after connect**: Set up subscriptions in the `onConnect` callback. - -3. **Use typed queries**: Prefer the `query` builder over raw SQL strings for type safety. - -4. **Handle all connection states**: Implement `onConnect`, `onDisconnect`, and `onConnectError`. - -5. **Use light mode for high-frequency updates**: Enable `.withLightMode(true)` for games or real-time apps. - -6. **Unsubscribe when done**: Clean up subscriptions when components unmount or data is no longer needed. - -7. **Use primary keys**: Define primary keys on tables to enable `onUpdate` callbacks. - -8. **Memoize connectionBuilder**: Always wrap in `useMemo()` to prevent reconnects. - -9. **Let subscriptions drive state**: Avoid optimistic updates; let the server be the source of truth. - -## Common Patterns - -### Reconnection Logic - -```typescript -function createConnection(token?: string) { - return DbConnection.builder() - .withUri('ws://localhost:3000') - .withModuleName('my_database') - .withToken(token) - .onConnect((conn, identity, newToken) => { - localStorage.setItem('token', newToken); - setupSubscriptions(conn); - }) - .onDisconnect(() => { - // Reconnect after delay - setTimeout(() => { - createConnection(localStorage.getItem('token') ?? undefined); - }, 3000); - }) - .build(); -} -``` - -### Optimistic Updates - -```typescript -function PlayerScore({ player }) { - const updateScore = useReducer(reducers.updateScore); - const [optimisticScore, setOptimisticScore] = useState(player.score); - - const handleClick = () => { - setOptimisticScore(prev => prev + 1); - updateScore({ playerId: player.id, delta: 1 }); - }; - - // Sync with actual data - useEffect(() => { - setOptimisticScore(player.score); - }, [player.score]); - - return
Score: {optimisticScore}
; -} -``` - -### Filtering with Multiple Conditions - -```typescript -// Using query builder -query.player - .where(row => row.team.eq('red')) - .where(row => row.score.gte(100)) - .build(); - -// Using React hooks -const [redTeamHighScorers] = useTable( - tables.player, - where(eq('team', 'red')), // Additional filtering in client -); -const filtered = redTeamHighScorers.filter(p => p.score >= 100); -``` - -## Project Structure - -### Server (`backend/spacetimedb/`) -``` -src/schema.ts -> Tables, export spacetimedb -src/index.ts -> Reducers, lifecycle, import schema -package.json -> { "type": "module", "dependencies": { "spacetimedb": "^1.11.0" } } -tsconfig.json -> Standard config -``` - -### Avoiding circular imports -``` -schema.ts -> defines tables AND exports spacetimedb -index.ts -> imports spacetimedb from ./schema, defines reducers -``` - -### Client (`client/`) -``` -src/module_bindings/ -> Generated (spacetime generate) -src/main.tsx -> Provider, connection setup -src/App.tsx -> UI components -src/config.ts -> MODULE_NAME, SPACETIMEDB_URI -```