更新 SpacetimeDB 本地技能
更新 SpacetimeDB CLI、概念和 Rust 模块 skill 到 2.5 口径 删除 TypeScript、C# 和 Unity SpacetimeDB 本地 skill 同步 AGENTS 与 Hermes 决策记录中的 skill 维护范围 补充 2.2.0 到 2.5.0 项目相关差异和 event table 规则
This commit is contained in:
@@ -1,229 +1,151 @@
|
|||||||
---
|
---
|
||||||
name: spacetimedb-cli
|
name: spacetimedb-cli
|
||||||
description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers
|
description: SpacetimeDB 2.5 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
|
||||||
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
|
# 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.
|
Use this skill when working with the `spacetime` CLI in Genarrative. Prefer repository scripts when they exist, and keep every operation pinned to an explicit target server or local process.
|
||||||
|
|
||||||
## Quick Reference
|
## Genarrative Rules
|
||||||
|
|
||||||
### Project Initialization & Development
|
- Do not rely on the default SpacetimeDB cloud target. Pass `--server` or `--server-url` explicitly in scripts, docs, smoke tests, and manual troubleshooting.
|
||||||
|
- Do not introduce `maincloud` / `MAINCLOUD` commands, env vars, or docs. Treat old references as historical residue.
|
||||||
|
- Do not use `spacetime --root-dir` in manual commands or docs. Use project scripts, `--data-dir`, explicit `--server`, or the configured running service.
|
||||||
|
- For repository version upgrades, update `server-rs/Cargo.toml` exact pins, regenerate bindings, and verify the actual CLI/runtime version. Do not treat a local CLI reinstall as a repo upgrade.
|
||||||
|
- For host upgrades, verify the running service binary, not just shell PATH: `systemctl show ... MainPID` -> `/proc/$pid/exe --version` -> `/v1/ping`.
|
||||||
|
|
||||||
|
## Core Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Initialize new project
|
|
||||||
spacetime init my-project --lang rust|csharp|typescript|cpp
|
|
||||||
spacetime init my-project --template <template-id>
|
|
||||||
|
|
||||||
# Build module
|
# Build module
|
||||||
spacetime build # release build
|
spacetime build
|
||||||
spacetime build --debug # faster iteration, slower runtime
|
spacetime build --debug
|
||||||
|
|
||||||
# Dev mode (auto-rebuild, auto-publish, generates bindings)
|
# Publish to an explicit server
|
||||||
spacetime dev
|
spacetime publish my-database --server http://127.0.0.1:3101 --yes=migrate,break-clients
|
||||||
spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings
|
|
||||||
|
|
||||||
# Generate client bindings
|
# Destructive publish only when explicitly intended
|
||||||
|
spacetime publish my-database --server http://127.0.0.1:3101 --delete-data=always --yes=delete-data,migrate
|
||||||
|
|
||||||
|
# Delete data only for breaking schema conflicts
|
||||||
|
spacetime publish my-database --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate
|
||||||
|
|
||||||
|
# Generate bindings
|
||||||
spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings --module-path ./server
|
spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings --module-path ./server
|
||||||
```
|
```
|
||||||
|
|
||||||
### Publishing & Deployment
|
## Genarrative Local Workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Publish to an explicit server
|
# Prefer project wrappers
|
||||||
spacetime publish my-database --server http://127.0.0.1:3101 --yes
|
npm run dev:spacetime
|
||||||
|
npm run dev:api-server
|
||||||
|
npm run spacetime:generate
|
||||||
|
|
||||||
# Publish to local server
|
# Query local database
|
||||||
spacetime publish my-database --server local --yes
|
spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM players"
|
||||||
|
|
||||||
# Clear database and republish
|
# Logs
|
||||||
spacetime publish my-database --clear-database --yes
|
spacetime logs my-db --server http://127.0.0.1:3101 -f
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Interaction
|
## Database Interaction
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# SQL queries
|
# SQL / describe
|
||||||
spacetime sql my-database "SELECT * FROM users"
|
spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM users"
|
||||||
spacetime sql my-database --interactive # REPL mode
|
spacetime describe my-db --server http://127.0.0.1:3101 --json
|
||||||
|
spacetime describe my-db table users --server http://127.0.0.1:3101 --json
|
||||||
|
|
||||||
# Call reducers
|
# Reducer/procedure calls. Arguments are positional JSON values.
|
||||||
spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}'
|
spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123'
|
||||||
|
|
||||||
# Subscribe to changes
|
# 2.5 accepts hex strings for Identity arguments without full JSON tuple syntax.
|
||||||
spacetime subscribe my-database "SELECT * FROM users" --num-updates 10
|
spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123...
|
||||||
|
|
||||||
# View logs
|
# Subscribe from CLI
|
||||||
spacetime logs my-database -f # follow logs
|
spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101
|
||||||
spacetime logs my-database -n 100 # up to 100 log 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
|
## Server & Auth
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List databases
|
|
||||||
spacetime list
|
|
||||||
|
|
||||||
# Delete database
|
|
||||||
spacetime delete my-database
|
|
||||||
|
|
||||||
# Rename database
|
|
||||||
spacetime rename <database-identity> --to new-name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List configured servers
|
|
||||||
spacetime server list
|
spacetime server list
|
||||||
|
|
||||||
# Add server
|
|
||||||
spacetime server add local --url http://localhost:3000 --default
|
spacetime server add local --url http://localhost:3000 --default
|
||||||
spacetime server add myserver --url https://my-spacetime.example.com
|
spacetime server add genarrative-dev --url http://127.0.0.1:3101
|
||||||
|
spacetime server ping genarrative-dev
|
||||||
|
|
||||||
# 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
|
spacetime login
|
||||||
|
|
||||||
# Login with token
|
|
||||||
spacetime login --token <token>
|
spacetime login --token <token>
|
||||||
|
|
||||||
# Show login status
|
|
||||||
spacetime login show
|
spacetime login show
|
||||||
|
|
||||||
# Logout
|
|
||||||
spacetime logout
|
spacetime logout
|
||||||
```
|
```
|
||||||
|
|
||||||
## Default Servers
|
## Version & Runtime Verification
|
||||||
|
|
||||||
| Name | URL | Description |
|
|
||||||
|------|-----|-------------|
|
|
||||||
| `local` | `http://127.0.0.1:3000` | Local development server |
|
|
||||||
| `dev` | `http://127.0.0.1:3101` | Genarrative local development server |
|
|
||||||
|
|
||||||
## Common Workflows
|
|
||||||
|
|
||||||
### New Project Setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Login
|
# CLI resolution can be misleading; compare all candidates when diagnosing.
|
||||||
spacetime login
|
type -a spacetime
|
||||||
|
spacetime --version
|
||||||
|
spacetime version list
|
||||||
|
|
||||||
# 2. Create project
|
# Verify a systemd service binary actually changed.
|
||||||
spacetime init my-game --lang rust
|
pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
||||||
cd my-game
|
readlink -f "/proc/${pid}/exe"
|
||||||
|
"/proc/${pid}/exe" --version
|
||||||
# 3. Start dev mode (auto-rebuilds and publishes)
|
curl -fsS http://127.0.0.1:3101/v1/ping
|
||||||
spacetime dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local Development
|
## Flags
|
||||||
|
|
||||||
```bash
|
| Flag | Description |
|
||||||
# Start local server (in separate terminal)
|
|------|-------------|
|
||||||
spacetime start
|
| `--server`, `-s` | Target server nickname, host, or URL |
|
||||||
|
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.5 prefer scoped values |
|
||||||
# Publish to local
|
| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` |
|
||||||
spacetime publish my-db --server local --clear-database --yes
|
| `--module-path`, `-p` | Module project path |
|
||||||
|
| `--bin-path`, `-b` | Publish/generate from compiled wasm |
|
||||||
# Query local database
|
| `--no-config` | Ignore `spacetime.json` |
|
||||||
spacetime sql my-db --server local "SELECT * FROM players"
|
| `--env` | Select config file layering environment |
|
||||||
```
|
|
||||||
|
|
||||||
### Generate Client Bindings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# After building module
|
|
||||||
spacetime build
|
|
||||||
spacetime generate --lang typescript --out-dir ./client/src/bindings --module-path .
|
|
||||||
|
|
||||||
# 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 |
|
|
||||||
| `--module-path` | `-p` | Path to module project |
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "Not logged in"
|
### Not Logged In
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spacetime login
|
spacetime login
|
||||||
# Or use --anonymous for public operations
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### "Server not responding"
|
### Server Not Responding
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spacetime server ping <server>
|
spacetime server ping <server>
|
||||||
# For local: ensure spacetime start is running
|
curl -fsS http://127.0.0.1:3101/v1/ping
|
||||||
```
|
```
|
||||||
|
|
||||||
### "Schema conflict"
|
For local Genarrative work, start SpacetimeDB first with `npm run dev:spacetime`, then start `npm run dev:api-server`.
|
||||||
|
|
||||||
|
### Schema Conflict
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clear data and republish
|
spacetime publish my-db --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate
|
||||||
spacetime publish my-db --clear-database --yes
|
|
||||||
# Clear data and republish only when conflict
|
|
||||||
spacetime publish my-db --clear-database=on-conflict --yes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### "Build failed"
|
Use `--delete-data=always` only with explicit approval.
|
||||||
|
|
||||||
|
### Version Mismatch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check Rust/C# toolchain
|
rg -n 'spacetimedb' server-rs/Cargo.toml
|
||||||
rustup show
|
spacetime --version
|
||||||
# For Rust modules, ensure wasm32-unknown-unknown target
|
spacetime version list
|
||||||
rustup target add wasm32-unknown-unknown
|
pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
||||||
|
"/proc/${pid}/exe" --version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Module Languages
|
|
||||||
|
|
||||||
**Server-side (modules):** Rust, C#, TypeScript, C++
|
|
||||||
**Client SDKs:** TypeScript, C#, Rust, Python, Unreal Engine
|
|
||||||
**CLI `generate` targets:** TypeScript, C#, Rust, Unreal C++
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Many commands are marked UNSTABLE and may change
|
- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes.
|
||||||
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on the CLI default
|
- 2.5 fixes `publish --delete-data` config fallback so `spacetime.json` can provide the database name.
|
||||||
- Use `--yes` flag in scripts to avoid interactive prompts
|
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults.
|
||||||
- Dev mode watches files and auto-rebuilds on changes
|
|
||||||
|
|||||||
@@ -1,345 +1,105 @@
|
|||||||
---
|
---
|
||||||
name: spacetimedb-concepts
|
name: spacetimedb-concepts
|
||||||
description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions.
|
description: Understand SpacetimeDB 2.5 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: clockworklabs
|
|
||||||
version: "2.0"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# SpacetimeDB Core Concepts
|
# 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.
|
SpacetimeDB is a relational database that also executes application logic in uploaded modules. In Genarrative, it is the data and transaction layer behind `server-rs + Axum + SpacetimeDB`, not a replacement for the `api-server` BFF or external platform adapters.
|
||||||
|
|
||||||
---
|
## Genarrative Boundaries
|
||||||
|
|
||||||
## Critical Rules (Read First)
|
- Domain rules live in `module-*`.
|
||||||
|
- SpacetimeDB tables, reducers, procedures, migrations, row mappers, and read models live in `spacetime-module`.
|
||||||
|
- Backend access goes through `spacetime-client` facades.
|
||||||
|
- HTTP/SSE/BFF and external orchestration stay in `api-server`.
|
||||||
|
- External side effects stay in `platform-*`.
|
||||||
|
- Frontend renders backend truth and must not bypass BFF/projections to invent formal business state.
|
||||||
|
|
||||||
These five rules prevent the most common SpacetimeDB mistakes:
|
## Critical Rules
|
||||||
|
|
||||||
1. **Reducers are transactional** — they do not return data to callers. Use subscriptions to read data.
|
1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints.
|
||||||
2. **Reducers must be deterministic** — no filesystem, network, timers, or random. All state must come from tables.
|
2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables.
|
||||||
3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries.
|
3. **Procedures are stable in 2.5**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
|
||||||
4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns.
|
4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument.
|
||||||
5. **`ctx.sender()` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender()` for authorization.
|
5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering.
|
||||||
|
6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`.
|
||||||
---
|
|
||||||
|
|
||||||
## 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 <db-name>`)
|
|
||||||
5. **Is the reducer actually being called from the client?**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CLI Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
spacetime start
|
|
||||||
spacetime publish <db-name> --module-path <module-path>
|
|
||||||
spacetime publish <db-name> --clear-database -y --module-path <module-path>
|
|
||||||
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
|
|
||||||
spacetime logs <db-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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**: Application state is served from memory for very low-latency 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
|
|
||||||
|
|
||||||
## The Five Zen Principles
|
|
||||||
|
|
||||||
1. **Everything is a Table**: Your entire application state lives in tables. No separate cache layer, no Redis, no in-memory state to synchronize.
|
|
||||||
2. **Everything is Persistent**: SpacetimeDB persists state by default (for example via WAL-backed durability).
|
|
||||||
3. **Everything is Real-Time**: Clients are replicas of server state. Subscribe to data and it flows automatically.
|
|
||||||
4. **Everything is Transactional**: Every reducer runs atomically. Either all changes succeed or all roll back.
|
|
||||||
5. **Everything is Programmable**: Modules are real code (Rust, C#, TypeScript) running inside the database.
|
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
|
|
||||||
Tables store all data in SpacetimeDB. They use the relational model and support SQL queries for subscriptions.
|
- Private tables are the default; only reducers/procedures and database owners can access them.
|
||||||
|
- Public tables are exposed to clients through subscriptions. Writes still go through reducers/procedures.
|
||||||
### Defining Tables
|
- Organize data by access pattern when bandwidth or update frequency differs.
|
||||||
|
- Existing persistent tables in Genarrative are conservative: no rename, delete, reorder, or type changes without a user-approved migration plan.
|
||||||
Tables are defined using language-specific attributes. In 2.0, use `accessor` (not `name`) for the API name:
|
|
||||||
|
|
||||||
**Rust:**
|
|
||||||
```rust
|
|
||||||
#[spacetimedb::table(accessor = player, public)]
|
|
||||||
pub struct Player {
|
|
||||||
#[primary_key]
|
|
||||||
#[auto_inc]
|
|
||||||
id: u32,
|
|
||||||
#[index(btree)]
|
|
||||||
name: String,
|
|
||||||
#[unique]
|
|
||||||
email: String,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**C#:**
|
|
||||||
```csharp
|
|
||||||
[SpacetimeDB.Table(Accessor = "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
|
|
||||||
```
|
|
||||||
|
|
||||||
Benefits: reduced bandwidth, cache efficiency, schema evolution, semantic clarity.
|
|
||||||
|
|
||||||
## Reducers
|
## Reducers
|
||||||
|
|
||||||
Reducers are transactional functions that modify database state. They are the primary client-invoked mutation path; procedures can also mutate tables by running explicit transactions.
|
Reducers are deterministic transactional functions. They are the primary client-invoked mutation path.
|
||||||
|
|
||||||
### Key Properties
|
- No global mutable state.
|
||||||
|
- No filesystem, network, timers, or non-deterministic RNG.
|
||||||
|
- Return `Result<(), String>` for expected sender-visible errors.
|
||||||
|
- Use `ctx.sender()` for authorization.
|
||||||
|
- Store persistent state in tables.
|
||||||
|
|
||||||
- **Transactional**: Run in isolated database transactions
|
## Procedures
|
||||||
- **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
|
Procedures are stable in 2.5. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
|
||||||
|
|
||||||
1. **No global state**: Relying on static variables is undefined behavior
|
Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure.
|
||||||
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
|
Module HTTP handlers/webhooks, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes.
|
||||||
|
|
||||||
**Rust:**
|
## Views
|
||||||
```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#:**
|
Views expose computed read-only data. In 2.4.1 Rust and TypeScript gained primary key support for procedural views; in 2.5 C# gained the same. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
|
||||||
```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
|
## Event Tables
|
||||||
|
|
||||||
Every reducer receives a `ReducerContext` providing:
|
Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`.
|
||||||
- **Database**: `ctx.db` (Rust field, TS property) / `ctx.Db` (C# property)
|
|
||||||
- **Sender**: `ctx.sender()` (Rust method) / `ctx.Sender` (C# property) / `ctx.sender` (TS property)
|
|
||||||
- **Connection ID**: `ctx.connection_id()` (Rust method) / `ctx.ConnectionId` (C# property) / `ctx.connectionId` (TS property)
|
|
||||||
- **Timestamp**: `ctx.timestamp` (Rust field, TS property) / `ctx.Timestamp` (C# property)
|
|
||||||
|
|
||||||
## Event Tables (2.0)
|
2.5 adds broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
|
||||||
|
|
||||||
Event tables are the preferred way to broadcast reducer-specific data to clients.
|
Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
||||||
|
|
||||||
```rust
|
Official 2.4.1/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
||||||
#[table(accessor = damage_event, public, event)]
|
|
||||||
pub struct DamageEvent {
|
|
||||||
pub target: Identity,
|
|
||||||
pub amount: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[reducer]
|
|
||||||
fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
|
|
||||||
ctx.db.damage_event().insert(DamageEvent { target, amount });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Clients subscribe to event tables and use `on_insert` callbacks. Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
|
|
||||||
|
|
||||||
## Subscriptions
|
## Subscriptions
|
||||||
|
|
||||||
Subscriptions replicate database rows to clients in real-time.
|
1. Subscribe to SQL queries or generated table/query builders.
|
||||||
|
2. Receive initial matching rows.
|
||||||
|
3. Receive updates when subscribed rows change.
|
||||||
|
4. Render from subscribed data, not reducer return values.
|
||||||
|
|
||||||
### How Subscriptions Work
|
Best practices:
|
||||||
|
|
||||||
1. **Subscribe**: Register SQL queries describing needed data
|
- Group subscriptions by lifetime.
|
||||||
2. **Receive initial data**: All matching rows are sent immediately
|
- Subscribe to new data before unsubscribing old data during transitions.
|
||||||
3. **Receive updates**: Real-time updates when subscribed rows change
|
- Avoid overlapping queries that duplicate row delivery.
|
||||||
4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`)
|
- Use indexes for subscribed filters.
|
||||||
|
|
||||||
### Subscription Best Practices
|
## 2.2.0 to 2.5.0 Delta
|
||||||
|
|
||||||
1. **Group subscriptions by lifetime**: Keep always-needed data separate from temporary subscriptions
|
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||||
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
|
- **2.2.0**: v3 WebSocket transport and TS SDK default, safer production operations (`lock`/`unlock`, safer `delete`, better `publish --yes`), TS React `useProcedure`, table clearing APIs, empty-table drop automigration, primary-key migration fixes, bytes-key B-tree support, durability hardening.
|
||||||
|
- **2.3.0**: first-party Godot SDK, more WebSocket pipelining/batching, HTTP/2 backend support, Vue `useProcedure`, Unity 6 WebGL support, commitlog compression/throughput improvements, Rust `DbContext` generics, `ReducerContext::identity` deprecated in favor of `database_identity`, connection lifecycle and unsubscribe fixes.
|
||||||
|
- **2.4.0**: unstable module HTTP handlers/webhooks, faster synchronous WASM reducer runtime, commitlog resume truncation fix for silent data loss risk, better commitlog decode context, V8 heap metrics for procedure workers, JS execution-time billing regression reverted.
|
||||||
|
- **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables.
|
||||||
|
- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments.
|
||||||
|
|
||||||
Modules are WebAssembly bundles containing application logic that runs inside the database.
|
## Debugging Checklist
|
||||||
|
|
||||||
### Module Components
|
1. Is the Genarrative SpacetimeDB server running? Use `npm run dev:spacetime` locally or host-local `systemctl`.
|
||||||
|
2. Is the module published to the same server the API uses?
|
||||||
- **Tables**: Define the data schema
|
3. Are generated bindings current? Use `npm run spacetime:generate`.
|
||||||
- **Reducers**: Define callable functions that modify state
|
4. Is `api-server` using the same database and token?
|
||||||
- **Views**: Define read-only computed queries
|
5. Is the reducer/procedure actually called?
|
||||||
- **Event Tables**: Broadcast reducer-specific data to clients (2.0)
|
6. Did `/healthz` / `/readyz` pass while business SpacetimeDB calls still timeout? Inspect API logs and public route behavior.
|
||||||
- **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**: A long-lived, globally unique identifier for a user.
|
|
||||||
- **ConnectionId**: Identifies a specific client connection.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[spacetimedb::reducer]
|
|
||||||
pub fn do_something(ctx: &ReducerContext) {
|
|
||||||
let caller_identity = ctx.sender(); // Who is calling?
|
|
||||||
// NEVER trust identity passed as a reducer argument
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Authentication Providers
|
|
||||||
|
|
||||||
SpacetimeDB works with many OIDC providers, including SpacetimeAuth (built-in), Auth0, Clerk, Keycloak, Google, and GitHub.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
### 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**: 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
|
|
||||||
|
|
||||||
## 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")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Scheduled reducer:**
|
|
||||||
```rust
|
|
||||||
#[spacetimedb::table(accessor = 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) {
|
|
||||||
log::info!("Reminder: {}", reminder.message);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Editing Behavior
|
## Editing Behavior
|
||||||
|
|
||||||
When modifying SpacetimeDB code:
|
- Make the smallest change necessary.
|
||||||
|
- Do not invent SpacetimeDB APIs; verify against current docs, generated bindings, or source.
|
||||||
- Make the smallest change necessary
|
- For Genarrative schema edits, update migration code, table catalog/docs, generated bindings, and relevant tests.
|
||||||
- Do NOT touch unrelated files, configs, or dependencies
|
- After schema edits, run `npm run spacetime:generate` and `npm run check:spacetime-schema`.
|
||||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
|
||||||
|
|||||||
@@ -1,646 +0,0 @@
|
|||||||
---
|
|
||||||
name: spacetimedb-csharp
|
|
||||||
description: Build C# modules and clients for SpacetimeDB. Covers server-side module development and client SDK integration.
|
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: clockworklabs
|
|
||||||
version: "2.0"
|
|
||||||
tested_with: "SpacetimeDB 2.0, .NET 8 SDK"
|
|
||||||
---
|
|
||||||
|
|
||||||
# SpacetimeDB C# SDK
|
|
||||||
|
|
||||||
This skill provides guidance for building C# server-side modules and C# clients that connect to SpacetimeDB 2.0.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## HALLUCINATED APIs — DO NOT USE
|
|
||||||
|
|
||||||
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// WRONG — these table access patterns do not exist
|
|
||||||
ctx.db.tableName // Wrong casing — use ctx.Db
|
|
||||||
ctx.Db.tableName // Wrong casing — accessor must match exactly
|
|
||||||
ctx.Db.TableName.Get(id) // Use Find, not Get
|
|
||||||
ctx.Db.TableName.FindById(id) // Use index accessor: ctx.Db.TableName.Id.Find(id)
|
|
||||||
ctx.Db.table.field_name.Find(x) // Wrong! Use PascalCase: ctx.Db.Table.FieldName.Find(x)
|
|
||||||
Optional<string> field; // Use C# nullable: string? field
|
|
||||||
|
|
||||||
// WRONG — missing partial keyword
|
|
||||||
public struct MyTable { } // Must be "partial struct"
|
|
||||||
public class Module { } // Must be "static partial class"
|
|
||||||
|
|
||||||
// WRONG — non-partial types
|
|
||||||
[SpacetimeDB.Table(Accessor = "Player")]
|
|
||||||
public struct Player { } // WRONG — missing partial!
|
|
||||||
|
|
||||||
// WRONG — sum type syntax (VERY COMMON MISTAKE)
|
|
||||||
public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: struct, missing names
|
|
||||||
public partial record Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: missing variant names
|
|
||||||
public partial class Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // WRONG: class
|
|
||||||
|
|
||||||
// WRONG — Index attribute without full qualification
|
|
||||||
[Index.BTree(Accessor = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index!
|
|
||||||
[SpacetimeDB.Index.BTree(Accessor = "idx", Columns = ["Col"])] // Valid with modern C# collection expressions
|
|
||||||
|
|
||||||
// WRONG — old 1.0 patterns
|
|
||||||
[SpacetimeDB.Table(Name = "Player")] // Use Accessor, not Name (2.0)
|
|
||||||
<PackageReference Include="SpacetimeDB.ServerSdk" /> // Use SpacetimeDB.Runtime
|
|
||||||
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
|
|
||||||
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
|
|
||||||
|
|
||||||
// WRONG — lifecycle hooks starting with "On"
|
|
||||||
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
|
|
||||||
public static void OnClientConnected(ReducerContext ctx) { } // STDB0010 error!
|
|
||||||
|
|
||||||
// WRONG — non-deterministic code in reducers
|
|
||||||
var random = new Random(); // Use ctx.Rng
|
|
||||||
var guid = Guid.NewGuid(); // Not allowed
|
|
||||||
var now = DateTime.Now; // Use ctx.Timestamp
|
|
||||||
|
|
||||||
// WRONG — collection parameters
|
|
||||||
int[] itemIds = { 1, 2, 3 };
|
|
||||||
_conn.Reducers.ProcessItems(itemIds); // Generated code expects List<T>!
|
|
||||||
```
|
|
||||||
|
|
||||||
### CORRECT PATTERNS
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using SpacetimeDB;
|
|
||||||
|
|
||||||
// CORRECT TABLE — must be partial struct, use Accessor
|
|
||||||
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
|
|
||||||
public partial struct Player
|
|
||||||
{
|
|
||||||
[SpacetimeDB.PrimaryKey]
|
|
||||||
[SpacetimeDB.AutoInc]
|
|
||||||
public ulong Id;
|
|
||||||
[SpacetimeDB.Index.BTree]
|
|
||||||
public Identity OwnerId;
|
|
||||||
public string Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORRECT MODULE — must be static partial class
|
|
||||||
public static partial class Module
|
|
||||||
{
|
|
||||||
[SpacetimeDB.Reducer]
|
|
||||||
public static void CreatePlayer(ReducerContext ctx, string name)
|
|
||||||
{
|
|
||||||
ctx.Db.Player.Insert(new Player { Id = 0, OwnerId = ctx.Sender, Name = name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORRECT DATABASE ACCESS — PascalCase, index-based lookups
|
|
||||||
var player = ctx.Db.Player.Id.Find(playerId); // Unique/PK: returns nullable
|
|
||||||
foreach (var p in ctx.Db.Player.OwnerId.Filter(ctx.Sender)) { } // BTree: returns IEnumerable
|
|
||||||
|
|
||||||
// CORRECT SUM TYPE — partial record with named tuple elements
|
|
||||||
[SpacetimeDB.Type]
|
|
||||||
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
|
|
||||||
|
|
||||||
// CORRECT — collection parameters use List<T>
|
|
||||||
_conn.Reducers.ProcessItems(new List<int> { 1, 2, 3 });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Mistakes Table
|
|
||||||
|
|
||||||
| Wrong | Right | Error |
|
|
||||||
|-------|-------|-------|
|
|
||||||
| 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 |
|
|
||||||
| async/await in reducers | Synchronous only | Not supported |
|
|
||||||
| `table.Name.Update(...)` | `table.Id.Update(...)` | Update only via primary key (2.0) |
|
|
||||||
| Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire |
|
|
||||||
| Accessing `conn.Db` from background thread | Copy data in callback | Data races |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hard Requirements
|
|
||||||
|
|
||||||
1. **Tables and Module MUST be `partial`** — required for code generation
|
|
||||||
2. **Use `Accessor =` in table attributes** — `Name =` is only for SQL compatibility (2.0)
|
|
||||||
3. **Project file MUST be named `StdbModule.csproj`** — CLI requirement
|
|
||||||
4. **Requires .NET 8 SDK** — .NET 9 and newer not yet supported
|
|
||||||
5. **Install WASI workload** — `dotnet workload install wasi-experimental`
|
|
||||||
6. **Procedures are supported** — use `[SpacetimeDB.Procedure]` with `ProcedureContext` when needed
|
|
||||||
7. **Reducers must be deterministic** — no filesystem, network, timers, or `Random`
|
|
||||||
8. **Add `Public = true`** — if clients need to subscribe to a table
|
|
||||||
9. **Use `T?` for nullable fields** — not `Optional<T>`
|
|
||||||
10. **Pass `0` for auto-increment** — to trigger ID generation on insert
|
|
||||||
11. **Sum types must be `partial record`** — not struct or class
|
|
||||||
12. **Fully qualify Index attribute** — `[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity
|
|
||||||
13. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
|
|
||||||
14. **Use `SpacetimeDB.Runtime` package** — not `ServerSdk` (2.0)
|
|
||||||
15. **Use `List<T>` for collection parameters** — not arrays
|
|
||||||
16. **`Identity` is in `SpacetimeDB` namespace** — not `SpacetimeDB.Types`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Server-Side Module Development
|
|
||||||
|
|
||||||
### Table Definition
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using SpacetimeDB;
|
|
||||||
|
|
||||||
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
|
|
||||||
public partial struct Player
|
|
||||||
{
|
|
||||||
[SpacetimeDB.PrimaryKey]
|
|
||||||
[SpacetimeDB.AutoInc]
|
|
||||||
public ulong Id;
|
|
||||||
|
|
||||||
[SpacetimeDB.Index.BTree]
|
|
||||||
public Identity OwnerId;
|
|
||||||
|
|
||||||
public string Name;
|
|
||||||
public Timestamp CreatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-column index (use fully-qualified attribute!)
|
|
||||||
[SpacetimeDB.Table(Accessor = "Score", Public = true)]
|
|
||||||
[SpacetimeDB.Index.BTree(Accessor = "by_player_game", Columns = new[] { "PlayerId", "GameId" })]
|
|
||||||
public partial struct Score
|
|
||||||
{
|
|
||||||
[SpacetimeDB.PrimaryKey]
|
|
||||||
[SpacetimeDB.AutoInc]
|
|
||||||
public ulong Id;
|
|
||||||
public Identity PlayerId;
|
|
||||||
public string GameId;
|
|
||||||
public int Points;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### SpacetimeDB Column Types
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
Identity // User identity (SpacetimeDB namespace, not SpacetimeDB.Types)
|
|
||||||
Timestamp // Timestamp (use ctx.Timestamp server-side, never DateTime.Now)
|
|
||||||
ScheduleAt // For scheduled tables
|
|
||||||
T? // Nullable (e.g., string?)
|
|
||||||
List<T> // Collections (use List, not arrays)
|
|
||||||
```
|
|
||||||
|
|
||||||
Standard C# primitives (`bool`, `byte`..`ulong`, `float`, `double`, `string`) are all supported.
|
|
||||||
|
|
||||||
### Insert with Auto-Increment
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var player = ctx.Db.Player.Insert(new Player
|
|
||||||
{
|
|
||||||
Id = 0, // Pass 0 to trigger auto-increment
|
|
||||||
OwnerId = ctx.Sender,
|
|
||||||
Name = name,
|
|
||||||
CreatedAt = ctx.Timestamp
|
|
||||||
});
|
|
||||||
ulong newId = player.Id; // Insert returns the row with generated ID
|
|
||||||
```
|
|
||||||
|
|
||||||
### Module and Reducers
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using SpacetimeDB;
|
|
||||||
|
|
||||||
public static partial class Module
|
|
||||||
{
|
|
||||||
[SpacetimeDB.Reducer]
|
|
||||||
public static void CreateTask(ReducerContext ctx, string title)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(title))
|
|
||||||
throw new Exception("Title cannot be empty");
|
|
||||||
|
|
||||||
ctx.Db.Task.Insert(new Task
|
|
||||||
{
|
|
||||||
Id = 0,
|
|
||||||
OwnerId = ctx.Sender,
|
|
||||||
Title = title,
|
|
||||||
Completed = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[SpacetimeDB.Reducer]
|
|
||||||
public static void CompleteTask(ReducerContext ctx, ulong taskId)
|
|
||||||
{
|
|
||||||
if (ctx.Db.Task.Id.Find(taskId) is not Task task)
|
|
||||||
throw new Exception("Task not found");
|
|
||||||
if (task.OwnerId != ctx.Sender)
|
|
||||||
throw new Exception("Not authorized");
|
|
||||||
|
|
||||||
ctx.Db.Task.Id.Update(task with { Completed = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
[SpacetimeDB.Reducer]
|
|
||||||
public static void DeleteTask(ReducerContext ctx, ulong taskId)
|
|
||||||
{
|
|
||||||
ctx.Db.Task.Id.Delete(taskId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lifecycle Reducers
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public static partial class Module
|
|
||||||
{
|
|
||||||
[SpacetimeDB.Reducer(ReducerKind.Init)]
|
|
||||||
public static void Init(ReducerContext ctx)
|
|
||||||
{
|
|
||||||
Log.Info("Module initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRITICAL: no "On" prefix!
|
|
||||||
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
|
|
||||||
public static void ClientConnected(ReducerContext ctx)
|
|
||||||
{
|
|
||||||
Log.Info($"Client connected: {ctx.Sender}");
|
|
||||||
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
|
|
||||||
{
|
|
||||||
ctx.Db.User.Identity.Update(user with { Online = true });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ctx.Db.User.Insert(new User { Identity = ctx.Sender, Online = true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]
|
|
||||||
public static void ClientDisconnected(ReducerContext ctx)
|
|
||||||
{
|
|
||||||
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
|
|
||||||
{
|
|
||||||
ctx.Db.User.Identity.Update(user with { Online = false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Tables (2.0)
|
|
||||||
|
|
||||||
Reducer callbacks are removed in 2.0. Use event tables + `OnInsert` instead.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[SpacetimeDB.Table(Accessor = "DamageEvent", Public = true, Event = true)]
|
|
||||||
public partial struct DamageEvent
|
|
||||||
{
|
|
||||||
public Identity Target;
|
|
||||||
public uint Amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SpacetimeDB.Reducer]
|
|
||||||
public static void DealDamage(ReducerContext ctx, Identity target, uint amount)
|
|
||||||
{
|
|
||||||
ctx.Db.DamageEvent.Insert(new DamageEvent { Target = target, Amount = amount });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Client subscribes and uses `OnInsert`:
|
|
||||||
```csharp
|
|
||||||
conn.Db.DamageEvent.OnInsert += (ctx, evt) => {
|
|
||||||
PlayDamageAnimation(evt.Target, evt.Amount);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Event tables must be subscribed explicitly — they are excluded from `SubscribeToAllTables()`.
|
|
||||||
|
|
||||||
### Database Access
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Find by primary key — returns nullable, use pattern matching
|
|
||||||
if (ctx.Db.Task.Id.Find(taskId) is Task task) { /* use task */ }
|
|
||||||
|
|
||||||
// Update by primary key (2.0: only primary key has .Update)
|
|
||||||
ctx.Db.Task.Id.Update(task with { Title = newTitle });
|
|
||||||
|
|
||||||
// Delete by primary key
|
|
||||||
ctx.Db.Task.Id.Delete(taskId);
|
|
||||||
|
|
||||||
// Find by unique index — returns nullable
|
|
||||||
if (ctx.Db.Player.Username.Find("alice") is Player player) { }
|
|
||||||
|
|
||||||
// Filter by B-tree index — returns iterator
|
|
||||||
foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { }
|
|
||||||
|
|
||||||
// Full table scan — avoid for large tables
|
|
||||||
foreach (var task in ctx.Db.Task.Iter()) { }
|
|
||||||
var count = ctx.Db.Task.Count;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Types and Sum Types
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[SpacetimeDB.Type]
|
|
||||||
public partial struct Position { public int X; public int Y; }
|
|
||||||
|
|
||||||
// Sum types MUST be partial record with named tuple
|
|
||||||
[SpacetimeDB.Type]
|
|
||||||
public partial struct Circle { public int Radius; }
|
|
||||||
[SpacetimeDB.Type]
|
|
||||||
public partial struct Rectangle { public int Width; public int Height; }
|
|
||||||
[SpacetimeDB.Type]
|
|
||||||
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
|
|
||||||
|
|
||||||
// Creating sum type values
|
|
||||||
var circle = new Shape.Circle(new Circle { Radius = 10 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scheduled Tables
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[SpacetimeDB.Table(Accessor = "Reminder", Scheduled = nameof(Module.SendReminder))]
|
|
||||||
public partial struct Reminder
|
|
||||||
{
|
|
||||||
[SpacetimeDB.PrimaryKey]
|
|
||||||
[SpacetimeDB.AutoInc]
|
|
||||||
public ulong Id;
|
|
||||||
public string Message;
|
|
||||||
public ScheduleAt ScheduledAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static partial class Module
|
|
||||||
{
|
|
||||||
[SpacetimeDB.Reducer]
|
|
||||||
public static void SendReminder(ReducerContext ctx, Reminder reminder)
|
|
||||||
{
|
|
||||||
Log.Info($"Reminder: {reminder.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[SpacetimeDB.Reducer]
|
|
||||||
public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs)
|
|
||||||
{
|
|
||||||
ctx.Db.Reminder.Insert(new Reminder
|
|
||||||
{
|
|
||||||
Id = 0,
|
|
||||||
Message = message,
|
|
||||||
ScheduledAt = new ScheduleAt.Time(ctx.Timestamp + TimeSpan.FromSeconds(delaySecs))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
Log.Debug("Debug message");
|
|
||||||
Log.Info("Information");
|
|
||||||
Log.Warn("Warning");
|
|
||||||
Log.Error("Error occurred");
|
|
||||||
Log.Exception("Critical failure"); // Logs at error level
|
|
||||||
```
|
|
||||||
|
|
||||||
### ReducerContext API
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
ctx.Sender // Identity of the caller
|
|
||||||
ctx.Timestamp // Current timestamp
|
|
||||||
ctx.Db // Database access
|
|
||||||
ctx.Identity // Module's own identity
|
|
||||||
ctx.ConnectionId // Connection ID (nullable)
|
|
||||||
ctx.SenderAuth // Authorization context (JWT claims, internal call detection)
|
|
||||||
ctx.Rng // Deterministic random number generator
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
Throwing an exception in a reducer rolls back the entire transaction:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[SpacetimeDB.Reducer]
|
|
||||||
public static void TransferCredits(ReducerContext ctx, Identity toUser, uint amount)
|
|
||||||
{
|
|
||||||
if (ctx.Db.User.Identity.Find(ctx.Sender) is not User sender)
|
|
||||||
throw new Exception("Sender not found");
|
|
||||||
|
|
||||||
if (sender.Credits < amount)
|
|
||||||
throw new Exception("Insufficient credits");
|
|
||||||
|
|
||||||
ctx.Db.User.Identity.Update(sender with { Credits = sender.Credits - amount });
|
|
||||||
|
|
||||||
if (ctx.Db.User.Identity.Find(toUser) is User receiver)
|
|
||||||
ctx.Db.User.Identity.Update(receiver with { Credits = receiver.Credits + amount });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Setup
|
|
||||||
|
|
||||||
### Required .csproj (MUST be named `StdbModule.csproj`)
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="SpacetimeDB.Runtime" Version="1.*" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install .NET 8 SDK (required, not .NET 9)
|
|
||||||
# Install WASI workload
|
|
||||||
dotnet workload install wasi-experimental
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Client SDK
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet add package SpacetimeDB.ClientSDK
|
|
||||||
```
|
|
||||||
|
|
||||||
### Generate Module Bindings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
spacetime generate --lang csharp --out-dir module_bindings --module-path PATH_TO_MODULE
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates `SpacetimeDBClient.g.cs`, `Tables/*.g.cs`, `Reducers/*.g.cs`, and `Types/*.g.cs`.
|
|
||||||
|
|
||||||
### Connection Setup
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using SpacetimeDB;
|
|
||||||
using SpacetimeDB.Types;
|
|
||||||
|
|
||||||
var conn = DbConnection.Builder()
|
|
||||||
.WithUri("http://localhost:3000")
|
|
||||||
.WithDatabaseName("my-database")
|
|
||||||
.WithToken(savedToken)
|
|
||||||
.OnConnect(OnConnected)
|
|
||||||
.OnConnectError(err => Console.Error.WriteLine($"Failed: {err}"))
|
|
||||||
.OnDisconnect((conn, err) => { if (err != null) Console.Error.WriteLine(err); })
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
void OnConnected(DbConnection conn, Identity identity, string authToken)
|
|
||||||
{
|
|
||||||
// Save authToken to persistent storage for reconnection
|
|
||||||
Console.WriteLine($"Connected: {identity}");
|
|
||||||
conn.SubscriptionBuilder()
|
|
||||||
.OnApplied(OnSubscriptionApplied)
|
|
||||||
.SubscribeToAllTables();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Critical: FrameTick
|
|
||||||
|
|
||||||
**The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Console application
|
|
||||||
while (running) { conn.FrameTick(); Thread.Sleep(16); }
|
|
||||||
|
|
||||||
// Unity: call conn?.FrameTick() in Update()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races.
|
|
||||||
|
|
||||||
### Subscribing to Tables
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// SQL queries
|
|
||||||
conn.SubscriptionBuilder()
|
|
||||||
.OnApplied(OnSubscriptionApplied)
|
|
||||||
.OnError((ctx, err) => Console.Error.WriteLine($"Subscription failed: {err}"))
|
|
||||||
.Subscribe(new[] {
|
|
||||||
"SELECT * FROM player",
|
|
||||||
"SELECT * FROM message WHERE sender = :sender"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to all tables (development only)
|
|
||||||
conn.SubscriptionBuilder()
|
|
||||||
.OnApplied(OnSubscriptionApplied)
|
|
||||||
.SubscribeToAllTables();
|
|
||||||
|
|
||||||
// Subscription handle for later unsubscribe
|
|
||||||
SubscriptionHandle handle = conn.SubscriptionBuilder()
|
|
||||||
.OnApplied(ctx => Console.WriteLine("Applied"))
|
|
||||||
.Subscribe(new[] { "SELECT * FROM player" });
|
|
||||||
|
|
||||||
handle.UnsubscribeThen(ctx => Console.WriteLine("Unsubscribed"));
|
|
||||||
```
|
|
||||||
|
|
||||||
**Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection.
|
|
||||||
|
|
||||||
### Accessing the Client Cache
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Iterate all rows
|
|
||||||
foreach (var player in ctx.Db.Player.Iter()) { Console.WriteLine(player.Name); }
|
|
||||||
|
|
||||||
// Count rows
|
|
||||||
int playerCount = ctx.Db.Player.Count;
|
|
||||||
|
|
||||||
// Find by unique/primary key — returns nullable
|
|
||||||
Player? player = ctx.Db.Player.Identity.Find(someIdentity);
|
|
||||||
if (player != null) { Console.WriteLine(player.Name); }
|
|
||||||
|
|
||||||
// Filter by BTree index — returns IEnumerable
|
|
||||||
foreach (var p in ctx.Db.Player.Level.Filter(1)) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Row Event Callbacks
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
|
||||||
Console.WriteLine($"Player joined: {player.Name}");
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => {
|
|
||||||
Console.WriteLine($"Player left: {player.Name}");
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => {
|
|
||||||
Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Checking event source
|
|
||||||
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
|
||||||
switch (ctx.Event)
|
|
||||||
{
|
|
||||||
case Event<Reducer>.SubscribeApplied:
|
|
||||||
break; // Initial subscription data
|
|
||||||
case Event<Reducer>.Reducer(var reducerEvent):
|
|
||||||
Console.WriteLine($"Reducer: {reducerEvent.Reducer}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Calling Reducers
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
ctx.Reducers.SendMessage("Hello, world!");
|
|
||||||
ctx.Reducers.CreatePlayer("NewPlayer");
|
|
||||||
|
|
||||||
// Reducer completion callbacks
|
|
||||||
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
|
|
||||||
if (ctx.Event.Status is Status.Committed)
|
|
||||||
Console.WriteLine($"Message sent: {text}");
|
|
||||||
else if (ctx.Event.Status is Status.Failed(var reason))
|
|
||||||
Console.Error.WriteLine($"Send failed: {reason}");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Unhandled reducer errors
|
|
||||||
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => {
|
|
||||||
Console.Error.WriteLine($"Reducer error: {ex.Message}");
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Identity and Authentication
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// In OnConnect callback — save token for reconnection
|
|
||||||
void OnConnected(DbConnection conn, Identity identity, string authToken)
|
|
||||||
{
|
|
||||||
// Save authToken to persistent storage (file, config, PlayerPrefs, etc.)
|
|
||||||
SaveToken(authToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconnect with saved token
|
|
||||||
string savedToken = LoadToken();
|
|
||||||
DbConnection.Builder()
|
|
||||||
.WithUri("http://localhost:3000")
|
|
||||||
.WithDatabaseName("my-database")
|
|
||||||
.WithToken(savedToken)
|
|
||||||
.OnConnect(OnConnected)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// Pass null or omit WithToken for anonymous connection
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
spacetime start
|
|
||||||
spacetime publish <module-name> --module-path <backend-dir>
|
|
||||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
|
||||||
spacetime generate --lang csharp --out-dir <client>/SpacetimeDB --module-path <backend-dir>
|
|
||||||
spacetime logs <module-name>
|
|
||||||
```
|
|
||||||
@@ -1,312 +1,170 @@
|
|||||||
---
|
---
|
||||||
name: spacetimedb-rust
|
name: spacetimedb-rust
|
||||||
description: Develop SpacetimeDB server modules in Rust. Use when writing reducers, tables, or module logic.
|
description: Develop SpacetimeDB 2.5 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: clockworklabs
|
|
||||||
version: "2.0"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# SpacetimeDB Rust Module Development
|
# 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.
|
Use this skill for Rust code in `server-rs/crates/spacetime-module` and related Genarrative schema/migration work.
|
||||||
|
|
||||||
> **Tested with:** SpacetimeDB 2.0+ APIs
|
## Genarrative Rules
|
||||||
|
|
||||||
---
|
- Keep domain rules in `module-*`; keep SpacetimeDB tables, reducers, procedures, views, mappers, and transaction adapters in `spacetime-module`.
|
||||||
|
- Existing table fields must be appended at the end with explicit defaults. Do not rename, remove, reorder, or change field types without a user-confirmed migration plan.
|
||||||
|
- After schema changes, update `migration.rs`, table catalog/docs, generated bindings, and run `npm run spacetime:generate` plus `npm run check:spacetime-schema`.
|
||||||
|
- Private tables are backend facts. Expose user-visible state through BFF endpoints/read models rather than direct client SQL.
|
||||||
|
|
||||||
## HALLUCINATED APIs — DO NOT USE
|
## Hallucinated APIs: Do Not Use
|
||||||
|
|
||||||
**These APIs/patterns are incorrect. LLMs frequently hallucinate them.**
|
|
||||||
|
|
||||||
Both macro forms are valid in 2.0: `#[spacetimedb::table(...)]` / `#[table(...)]` and `#[spacetimedb::reducer]` / `#[reducer]`.
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[derive(Table)] // Tables use #[table] attribute, not derive
|
#[derive(Table)] // Tables use #[table], not derive
|
||||||
#[derive(Reducer)] // Reducers use #[reducer] attribute
|
#[derive(Reducer)] // Reducers use #[reducer], not derive
|
||||||
|
#[derive(SpacetimeType)] // Do not derive this on #[table] structs
|
||||||
|
|
||||||
// WRONG — SpacetimeType on tables
|
pub fn reducer(ctx: &mut ReducerContext) {} // Use &ReducerContext
|
||||||
#[derive(SpacetimeType)] // DO NOT use on #[table] structs!
|
|
||||||
#[table(accessor = my_table)]
|
|
||||||
pub struct MyTable { ... }
|
|
||||||
|
|
||||||
// WRONG — mutable context
|
ctx.db.player // Use ctx.db.player()
|
||||||
pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext
|
ctx.db.player.find(id) // Use ctx.db.player().id().find(&id)
|
||||||
|
ctx.sender // Use ctx.sender()
|
||||||
|
ctx.db.user().name().update(..) // Update by primary key only
|
||||||
|
|
||||||
// WRONG — table access without parentheses
|
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5
|
||||||
ctx.db.player // Should be ctx.db.player()
|
|
||||||
ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id)
|
|
||||||
|
|
||||||
// WRONG — old 1.0 patterns
|
|
||||||
ctx.sender // Use ctx.sender() — method, not field (2.0)
|
|
||||||
.with_module_name("db") // Use .with_database_name() (2.0)
|
|
||||||
ctx.db.user().name().update(..) // Update only via primary key (2.0)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### CORRECT PATTERNS:
|
## Required Patterns
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp};
|
||||||
use spacetimedb::SpacetimeType; // Only for custom types, NOT tables
|
use spacetimedb::SpacetimeType; // Custom types only, not tables
|
||||||
|
|
||||||
// CORRECT TABLE — accessor, not name; no SpacetimeType derive!
|
|
||||||
#[table(accessor = player, public)]
|
#[table(accessor = player, public)]
|
||||||
pub struct Player {
|
|
||||||
#[primary_key]
|
|
||||||
pub id: u64,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORRECT REDUCER — immutable context, sender() is a method
|
|
||||||
#[reducer]
|
|
||||||
pub fn create_player(ctx: &ReducerContext, name: String) {
|
|
||||||
ctx.db.player().insert(Player { id: 0, name });
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORRECT TABLE ACCESS — methods with parentheses, sender() method
|
|
||||||
let player = ctx.db.player().id().find(&player_id);
|
|
||||||
let caller = ctx.sender();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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`
|
|
||||||
- **Use `ctx.sender`** — it's `ctx.sender()` (method) in 2.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Mistakes Table
|
|
||||||
|
|
||||||
| Wrong | Right | Error |
|
|
||||||
|-------|-------|-------|
|
|
||||||
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
|
|
||||||
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
|
|
||||||
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
|
|
||||||
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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. **Use `ctx.sender()`** — method call, not field access (2.0)
|
|
||||||
6. **Use `accessor =` for API handles** — `name = "..."` is optional canonical naming in table/index attributes
|
|
||||||
7. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
|
|
||||||
8. **Use `ctx.rng()`** — not `rand` crate for random numbers
|
|
||||||
9. **Add `public` flag** — if clients need to subscribe to a table
|
|
||||||
10. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Setup
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[package]
|
|
||||||
name = "my-module"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
spacetimedb = { workspace = true }
|
|
||||||
log = "0.4"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Essential Imports
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use spacetimedb::{ReducerContext, Table};
|
|
||||||
use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Table Definitions
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[spacetimedb::table(accessor = player, public)]
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
#[auto_inc]
|
||||||
id: u64,
|
pub id: u64,
|
||||||
name: String,
|
pub owner: Identity,
|
||||||
score: u32,
|
pub name: String,
|
||||||
|
pub created_at: Timestamp,
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
### Table Attributes
|
#[reducer]
|
||||||
|
|
||||||
| Attribute | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `accessor = identifier` | Required. The API name used in `ctx.db.{accessor}()` |
|
|
||||||
| `public` | Makes table visible to clients via subscriptions |
|
|
||||||
| `scheduled(function_name)` | Creates a schedule table that triggers the named reducer or procedure |
|
|
||||||
| `index(accessor = idx, btree(columns = [a, b]))` | 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 |
|
|
||||||
|
|
||||||
### Supported Column Types
|
|
||||||
|
|
||||||
**Primitives**: `u8`-`u256`, `i8`-`i256`, `f32`, `f64`, `bool`, `String`
|
|
||||||
|
|
||||||
**SpacetimeDB Types**: `Identity`, `ConnectionId`, `Timestamp`, `Uuid`, `ScheduleAt`
|
|
||||||
|
|
||||||
**Collections**: `Vec<T>`, `Option<T>`, `Result<T, E>`
|
|
||||||
|
|
||||||
**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reducers
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[spacetimedb::reducer]
|
|
||||||
pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
||||||
if name.is_empty() {
|
if name.trim().is_empty() {
|
||||||
return Err("Name cannot be empty".to_string());
|
return Err("name required".to_string());
|
||||||
}
|
}
|
||||||
ctx.db.player().insert(Player { id: 0, name, score: 0 });
|
ctx.db.player().try_insert(Player {
|
||||||
|
id: 0,
|
||||||
|
owner: ctx.sender(),
|
||||||
|
name,
|
||||||
|
created_at: ctx.timestamp,
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reducer Rules
|
Hard requirements:
|
||||||
|
|
||||||
1. First parameter must be `&ReducerContext`
|
- Import `Table` for table operations.
|
||||||
2. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display`
|
- Use `accessor = identifier`, not string literals.
|
||||||
3. All changes roll back on panic or `Err` return
|
- Use `ctx.sender()` for authorization.
|
||||||
4. Must import `Table` trait: `use spacetimedb::Table;`
|
- Use `ctx.rng()` / `ctx.random()` / `ctx.new_uuid_*()` for deterministic randomness and UUIDs.
|
||||||
|
- Use `Result<(), String>` for expected sender errors; avoid panics except impossible states.
|
||||||
|
- Use `try_insert()` in `Result` reducers when constraint violations should be reported cleanly.
|
||||||
|
|
||||||
### ReducerContext
|
## Tables
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
ctx.db // Database access
|
#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))]
|
||||||
ctx.sender() // Identity of the caller (method, not field!)
|
pub struct GameTickSchedule {
|
||||||
ctx.connection_id() // Option<ConnectionId> (None for scheduled/system reducers)
|
#[primary_key]
|
||||||
ctx.timestamp // Invocation timestamp
|
#[auto_inc]
|
||||||
ctx.identity() // Module's own identity
|
pub scheduled_id: u64,
|
||||||
ctx.rng() // Deterministic RNG (method, not field!)
|
pub scheduled_at: ScheduleAt,
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Table attributes:
|
||||||
|
|
||||||
|
| Attribute | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `accessor = identifier` | API name used in `ctx.db.{accessor}()` |
|
||||||
|
| `public` | Visible to clients via subscriptions |
|
||||||
|
| `event` | Transient event table |
|
||||||
|
| `scheduled(function_name)` | Schedule table that triggers a reducer/procedure |
|
||||||
|
| `index(accessor = idx, btree(columns = [a, b]))` | Multi-column index |
|
||||||
|
|
||||||
|
Column attributes:
|
||||||
|
|
||||||
|
| Attribute | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `#[primary_key]` | One primary key per table |
|
||||||
|
| `#[auto_inc]` | Auto-generates integer values when inserting `0` |
|
||||||
|
| `#[unique]` | Unique constraint and `find()` accessor |
|
||||||
|
| `#[index(btree)]` | B-tree index and `filter()` accessor |
|
||||||
|
| `#[default(...)]` | Required for new fields on existing Genarrative tables |
|
||||||
|
|
||||||
|
## Genarrative Schema Change Pattern
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[spacetimedb::table(accessor = creation_entry_config, public)]
|
||||||
|
pub struct CreationEntryConfig {
|
||||||
|
#[primary_key]
|
||||||
|
pub id: u64,
|
||||||
|
pub existing_field: String,
|
||||||
|
|
||||||
|
// Append new fields at the end and provide a default.
|
||||||
|
#[default(false)]
|
||||||
|
pub new_flag: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update `migration.rs`, table catalog/docs, generated bindings, and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run spacetime:generate
|
||||||
|
npm run check:spacetime-schema
|
||||||
|
```
|
||||||
|
|
||||||
## Table Operations
|
## Table Operations
|
||||||
|
|
||||||
### Insert
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Insert returns the row with auto_inc values populated
|
let row = ctx.db.player().insert(Player { id: 0, owner, name, created_at });
|
||||||
let player = ctx.db.player().insert(Player { id: 0, name: "Alice".into(), score: 100 });
|
ctx.db.player().try_insert(row)?;
|
||||||
log::info!("Created player with id: {}", player.id);
|
|
||||||
|
let by_id = ctx.db.player().id().find(&123u64);
|
||||||
|
for player in ctx.db.player().owner().filter(&ctx.sender()) {}
|
||||||
|
for player in ctx.db.player().level().filter(&(18u32..=65u32)) {}
|
||||||
|
for player in ctx.db.player().iter() {}
|
||||||
|
let count = ctx.db.player().count();
|
||||||
|
|
||||||
|
if let Some(player) = ctx.db.player().id().find(&id) {
|
||||||
|
ctx.db.player().id().update(Player { name: new_name, ..player });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.db.player().id().delete(&id);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Find and Filter
|
For delete/update based on non-PK filters, collect keys first to avoid iterator invalidation.
|
||||||
|
|
||||||
```rust
|
|
||||||
// Find by unique/primary key — returns Option
|
|
||||||
if let Some(player) = ctx.db.player().id().find(&123) {
|
|
||||||
log::info!("Found: {}", player.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional clarity: typed literals can avoid inference ambiguity
|
|
||||||
if let Some(player) = ctx.db.player().id().find(&123u64) {
|
|
||||||
log::info!("Found: {}", player.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by indexed column — returns iterator
|
|
||||||
for player in ctx.db.player().name().filter(&"Alice".to_string()) {
|
|
||||||
log::info!("Player: {}", player.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full table scan
|
|
||||||
for player in ctx.db.player().iter() { }
|
|
||||||
let total = ctx.db.player().count();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Update via primary key (2.0: only primary key has update)
|
|
||||||
if let Some(player) = ctx.db.player().id().find(&123) {
|
|
||||||
ctx.db.player().id().update(Player { score: player.score + 10, ..player });
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-PK changes: delete + insert
|
|
||||||
if let Some(old) = ctx.db.player().id().find(&id) {
|
|
||||||
ctx.db.player().id().delete(&id);
|
|
||||||
ctx.db.player().insert(Player { name: new_name, ..old });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Delete by primary key
|
|
||||||
ctx.db.player().id().delete(&123);
|
|
||||||
|
|
||||||
// Delete by indexed column (collect first to avoid iterator invalidation)
|
|
||||||
let to_remove: Vec<u64> = ctx.db.player().name().filter(&"Alice".to_string())
|
|
||||||
.map(|p| p.id)
|
|
||||||
.collect();
|
|
||||||
for id in to_remove {
|
|
||||||
ctx.db.player().id().delete(&id);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Indexes
|
## Indexes
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Single-column index
|
|
||||||
#[spacetimedb::table(accessor = player, public)]
|
|
||||||
pub struct Player {
|
|
||||||
#[primary_key]
|
|
||||||
id: u64,
|
|
||||||
#[index(btree)]
|
|
||||||
level: u32,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-column index
|
|
||||||
#[spacetimedb::table(
|
#[spacetimedb::table(
|
||||||
accessor = score, public,
|
accessor = score,
|
||||||
|
public,
|
||||||
index(accessor = by_player_level, btree(columns = [player_id, level]))
|
index(accessor = by_player_level, btree(columns = [player_id, level]))
|
||||||
)]
|
)]
|
||||||
pub struct Score {
|
pub struct Score {
|
||||||
player_id: u32,
|
pub player_id: u32,
|
||||||
level: u32,
|
pub level: u32,
|
||||||
points: i64,
|
pub points: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-column index querying: prefix match (first column only)
|
for row in ctx.db.score().by_player_level().filter(&(42,)) {}
|
||||||
for s in ctx.db.score().by_player_level().filter(&(42,)) {
|
for row in ctx.db.score().by_player_level().filter(&(42, 5)) {}
|
||||||
log::info!("Player 42, any level: {} pts", s.points);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full match (both columns)
|
|
||||||
for s in ctx.db.score().by_player_level().filter(&(42, 5)) {
|
|
||||||
log::info!("Player 42, level 5: {} pts", s.points);
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Event Tables
|
||||||
|
|
||||||
## Event Tables (2.0)
|
|
||||||
|
|
||||||
Reducer callbacks are removed in 2.0. Use event tables + `on_insert` instead.
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[table(accessor = damage_event, public, event)]
|
#[table(accessor = damage_event, public, event)]
|
||||||
@@ -321,182 +179,65 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Client subscribes and uses `on_insert`:
|
Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
|
||||||
|
|
||||||
|
In 2.5, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
|
||||||
|
|
||||||
|
Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
||||||
|
|
||||||
|
Official 2.4.1/2.5 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
conn.db.damage_event().on_insert(|ctx, event| {
|
#[spacetimedb::view(accessor = my_players, public, primary_key = id)]
|
||||||
play_damage_animation(event.target, event.amount);
|
pub fn my_players(ctx: &spacetimedb::ViewContext) -> Vec<Player> {
|
||||||
});
|
ctx.db.player().owner().filter(&ctx.sender()).collect()
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Event tables must be subscribed explicitly — they are excluded from `subscribe_to_all_tables()`.
|
Rust and TypeScript gained primary key support for procedural views in 2.4.1. With primary keys, clients can receive update events when subscribed to such views. Avoid duplicate primary keys in view results.
|
||||||
|
|
||||||
---
|
## Lifecycle & Scheduled Reducers
|
||||||
|
|
||||||
## Lifecycle Reducers
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[spacetimedb::reducer(init)]
|
#[spacetimedb::reducer(init)]
|
||||||
pub fn init(ctx: &ReducerContext) -> Result<(), String> {
|
pub fn init(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
||||||
log::info!("Database initializing...");
|
|
||||||
ctx.db.config().insert(Config {
|
|
||||||
id: 0,
|
|
||||||
max_players: 100,
|
|
||||||
game_mode: "default".to_string(),
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[spacetimedb::reducer(client_connected)]
|
#[spacetimedb::reducer(client_connected)]
|
||||||
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> {
|
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
||||||
let caller = ctx.sender();
|
|
||||||
log::info!("Client connected: {}", caller);
|
|
||||||
|
|
||||||
if let Some(user) = ctx.db.user().identity().find(&caller) {
|
|
||||||
ctx.db.user().identity().update(User { online: true, ..user });
|
|
||||||
} else {
|
|
||||||
ctx.db.user().insert(User {
|
|
||||||
identity: caller,
|
|
||||||
name: format!("User-{}", &caller.to_hex()[..8]),
|
|
||||||
online: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[spacetimedb::reducer(client_disconnected)]
|
#[spacetimedb::reducer(client_disconnected)]
|
||||||
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> {
|
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
||||||
let caller = ctx.sender();
|
|
||||||
if let Some(user) = ctx.db.user().identity().find(&caller) {
|
|
||||||
ctx.db.user().identity().update(User { online: false, ..user });
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
use spacetimedb::{ScheduleAt, TimeDuration};
|
||||||
|
|
||||||
## Scheduled Reducers
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use spacetimedb::ScheduleAt;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = 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) {
|
|
||||||
if !ctx.sender_auth().is_internal() { return; }
|
|
||||||
log::info!("Game tick at {:?}", ctx.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule at interval (e.g., in init reducer)
|
|
||||||
ctx.db.game_tick_schedule().insert(GameTickSchedule {
|
ctx.db.game_tick_schedule().insert(GameTickSchedule {
|
||||||
scheduled_id: 0,
|
scheduled_id: 0,
|
||||||
scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()),
|
scheduled_at: ScheduleAt::Interval(std::time::Duration::from_millis(100).into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schedule at specific time
|
let run_at = ctx.timestamp + std::time::Duration::from_secs(60);
|
||||||
let run_at = ctx.timestamp + Duration::from_secs(delay_secs);
|
ctx.db.game_tick_schedule().insert(GameTickSchedule {
|
||||||
ctx.db.reminder_schedule().insert(ReminderSchedule {
|
|
||||||
scheduled_id: 0,
|
scheduled_id: 0,
|
||||||
scheduled_at: ScheduleAt::Time(run_at),
|
scheduled_at: ScheduleAt::Time(run_at),
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer should only be system-triggered.
|
||||||
|
|
||||||
## Identity and Authentication
|
## Procedures
|
||||||
|
|
||||||
```rust
|
Procedures are stable in 2.5 and no longer require the `unstable` feature.
|
||||||
#[spacetimedb::table(accessor = user, public)]
|
|
||||||
pub struct User {
|
|
||||||
#[primary_key]
|
|
||||||
identity: Identity,
|
|
||||||
name: String,
|
|
||||||
online: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[spacetimedb::reducer]
|
|
||||||
pub fn set_name(ctx: &ReducerContext, new_name: String) -> Result<(), String> {
|
|
||||||
let caller = ctx.sender();
|
|
||||||
let user = ctx.db.user().identity().find(&caller)
|
|
||||||
.ok_or("User not found — connect first")?;
|
|
||||||
ctx.db.user().identity().update(User { name: new_name, ..user });
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Owner-Only Reducer Pattern
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn require_owner(ctx: &ReducerContext, entity_owner: &Identity) -> Result<(), String> {
|
|
||||||
if ctx.sender() != *entity_owner {
|
|
||||||
Err("Not authorized: you don't own this entity".to_string())
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[spacetimedb::reducer]
|
|
||||||
pub fn rename_character(ctx: &ReducerContext, char_id: u64, new_name: String) -> Result<(), String> {
|
|
||||||
let character = ctx.db.character().id().find(&char_id)
|
|
||||||
.ok_or("Character not found")?;
|
|
||||||
require_owner(ctx, &character.owner)?;
|
|
||||||
ctx.db.character().id().update(Character { name: new_name, ..character });
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Sender error — return Err (user sees message, transaction rolls back cleanly)
|
|
||||||
#[spacetimedb::reducer]
|
|
||||||
pub fn transfer(ctx: &ReducerContext, to: Identity, amount: u64) -> Result<(), String> {
|
|
||||||
let sender = ctx.db.wallet().identity().find(&ctx.sender())
|
|
||||||
.ok_or("Wallet not found")?;
|
|
||||||
if sender.balance < amount {
|
|
||||||
return Err("Insufficient balance".to_string());
|
|
||||||
}
|
|
||||||
// ... proceed with transfer
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Programmer error — panic (destroys the WASM instance, expensive!)
|
|
||||||
// Only use for truly impossible states
|
|
||||||
#[spacetimedb::reducer]
|
|
||||||
pub fn process(ctx: &ReducerContext, id: u64) {
|
|
||||||
let item = ctx.db.item().id().find(&id)
|
|
||||||
.expect("BUG: item should exist at this point");
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer `Result<(), String>` for all expected failure cases. Panics destroy and recreate the WASM instance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Procedures (Beta)
|
|
||||||
|
|
||||||
> Procedures are behind the `unstable` feature in `spacetimedb`.
|
|
||||||
> In `Cargo.toml`: `spacetimedb = { version = "...", features = ["unstable"] }`
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use spacetimedb::{procedure, ProcedureContext};
|
use spacetimedb::{procedure, ProcedureContext};
|
||||||
|
|
||||||
#[procedure]
|
#[procedure]
|
||||||
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
||||||
let data = fetch_from_url(&url)?;
|
let body = ctx.http.get(url).send()?.text()?;
|
||||||
ctx.try_with_tx(|tx| {
|
ctx.try_with_tx(|tx| {
|
||||||
tx.db.external_data().insert(ExternalData { id: 0, content: data });
|
tx.db.external_data().insert(ExternalData { id: 0, content: body });
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -505,52 +246,35 @@ fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), Str
|
|||||||
|
|
||||||
| Reducers | Procedures |
|
| Reducers | Procedures |
|
||||||
|----------|------------|
|
|----------|------------|
|
||||||
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
|
| `&ReducerContext` | `&mut ProcedureContext` |
|
||||||
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
|
| Direct `ctx.db` access | Use `with_tx()` / `try_with_tx()` |
|
||||||
| No HTTP/network | HTTP allowed |
|
| No HTTP/network | Outgoing HTTP via `ctx.http` |
|
||||||
| No return values | Can return data |
|
| Deterministic transaction path | Side-effect-capable workflow path |
|
||||||
|
|
||||||
---
|
In Genarrative, keep external provider protocols in `platform-*` by default unless the architecture explicitly moves that workflow into the module.
|
||||||
|
|
||||||
## Custom Types
|
## Identity & Auth
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use spacetimedb::SpacetimeType;
|
fn require_owner(ctx: &ReducerContext, owner: &Identity) -> Result<(), String> {
|
||||||
|
if ctx.sender() != *owner {
|
||||||
#[derive(SpacetimeType)]
|
return Err("Not authorized".to_string());
|
||||||
pub enum PlayerStatus { Active, Idle, Away }
|
}
|
||||||
|
Ok(())
|
||||||
#[derive(SpacetimeType)]
|
|
||||||
pub struct Position { x: f32, y: f32, z: f32 }
|
|
||||||
|
|
||||||
// Use in table (DO NOT derive SpacetimeType on the table!)
|
|
||||||
#[spacetimedb::table(accessor = player, public)]
|
|
||||||
pub struct Player {
|
|
||||||
#[primary_key]
|
|
||||||
id: u64,
|
|
||||||
status: PlayerStatus,
|
|
||||||
position: Position,
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
`ReducerContext::identity` is deprecated since 2.3; use the current database/module identity API when needed, and use `ctx.sender()` for caller identity.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spacetime build
|
spacetime build
|
||||||
spacetime publish my_database --module-path .
|
spacetime publish my_database --server http://127.0.0.1:3101 --module-path . --yes=migrate
|
||||||
spacetime publish my_database --clear-database --module-path .
|
spacetime publish my_database --server http://127.0.0.1:3101 --delete-data=on-conflict --module-path . --yes=migrate
|
||||||
spacetime logs my_database
|
spacetime logs my_database --server http://127.0.0.1:3101
|
||||||
spacetime call my_database create_player "Alice"
|
spacetime call --server http://127.0.0.1:3101 my_database create_player '"Alice"'
|
||||||
spacetime sql my_database "SELECT * FROM player"
|
spacetime sql my_database --server http://127.0.0.1:3101 "SELECT * FROM player"
|
||||||
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
npm run spacetime:generate
|
||||||
|
npm run check:spacetime-schema
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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.rng()` 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
|
|
||||||
|
|||||||
@@ -1,489 +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: "2.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!
|
|
||||||
|
|
||||||
// WRONG — old 1.0 patterns
|
|
||||||
spacetimedb.reducer('reducer_name', params, fn); // Use export const name = spacetimedb.reducer(params, fn)
|
|
||||||
schema(myTable); // Use schema({ myTable })
|
|
||||||
schema(t1, t2, t3); // Use schema({ t1, t2, t3 })
|
|
||||||
scheduled: 'run_cleanup' // Use scheduled: () => run_cleanup
|
|
||||||
.withModuleName('db') // Use .withDatabaseName('db') (2.0)
|
|
||||||
setReducerFlags.x('NoSuccessNotify') // Removed in 2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### CORRECT PATTERNS:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// CORRECT IMPORTS
|
|
||||||
import { DbConnection, tables } from './module_bindings'; // Generated!
|
|
||||||
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
|
|
||||||
import { Identity } from 'spacetimedb';
|
|
||||||
|
|
||||||
// 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, isReady]
|
|
||||||
const [items, isReady] = 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) of `table()` | "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" |
|
|
||||||
| Incorrect multi-column `.filter()` range shape | Match index prefix/tuple shape | Empty results or range/type errors |
|
|
||||||
| `.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 |
|
|
||||||
|
|
||||||
### Client-side errors
|
|
||||||
|
|
||||||
| Wrong | Right | Error |
|
|
||||||
|-------|-------|-------|
|
|
||||||
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
|
||||||
| `const rows = useTable(table)` | `const [rows, isReady] = useTable(table)` | Tuple destructuring |
|
|
||||||
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
|
||||||
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hard Requirements
|
|
||||||
|
|
||||||
1. **`schema({ table })`** — use a single tables object; optional module settings are allowed as a second argument
|
|
||||||
2. **Reducer/procedure names from exports** — `export const name = spacetimedb.reducer(params, fn)`; never `reducer('name', ...)`
|
|
||||||
3. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args
|
|
||||||
4. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb`
|
|
||||||
5. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
|
|
||||||
6. **Indexes go in OPTIONS (1st arg)** — not in COLUMNS (2nd arg) of `table()`
|
|
||||||
7. **Use BigInt for u64/i64 fields** — `0n`, `1n`, not `0`, `1`
|
|
||||||
8. **Reducers are transactional** — they do not return data
|
|
||||||
9. **Reducers must be deterministic** — no filesystem, network, timers, random
|
|
||||||
10. **Views should use index lookups** — `.iter()` causes severe performance issues
|
|
||||||
11. **Procedures need `ctx.withTx()`** — `ctx.db` doesn't exist in procedures
|
|
||||||
12. **Sum type values** — use `{ tag: 'variant', value: payload }` not `{ variant: payload }`
|
|
||||||
13. **Use `.withDatabaseName()`** — not `.withModuleName()` (2.0)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install spacetimedb
|
|
||||||
```
|
|
||||||
|
|
||||||
For Node.js environments without native fetch/WebSocket support, install `undici`.
|
|
||||||
|
|
||||||
## Generating Type Bindings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
spacetime generate --lang typescript --out-dir ./src/module_bindings --module-path ./server
|
|
||||||
```
|
|
||||||
|
|
||||||
## Client Connection
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { DbConnection } from './module_bindings';
|
|
||||||
|
|
||||||
const connection = DbConnection.builder()
|
|
||||||
.withUri('ws://localhost:3000')
|
|
||||||
.withDatabaseName('my_database')
|
|
||||||
.withToken(localStorage.getItem('spacetimedb_token') ?? undefined)
|
|
||||||
.onConnect((conn, identity, token) => {
|
|
||||||
// identity: your unique Identity for this database
|
|
||||||
console.log('Connected as:', identity.toHexString());
|
|
||||||
|
|
||||||
// Save token for reconnection (preserves identity across sessions)
|
|
||||||
localStorage.setItem('spacetimedb_token', token);
|
|
||||||
|
|
||||||
conn.subscriptionBuilder()
|
|
||||||
.onApplied(() => console.log('Cache ready'))
|
|
||||||
.subscribe('SELECT * FROM player');
|
|
||||||
})
|
|
||||||
.onDisconnect((ctx) => console.log('Disconnected'))
|
|
||||||
.onConnectError((ctx, error) => console.error('Connection failed:', error))
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Subscribing to Tables
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Basic subscription
|
|
||||||
connection.subscriptionBuilder()
|
|
||||||
.onApplied((ctx) => console.log('Cache ready'))
|
|
||||||
.subscribe('SELECT * FROM player');
|
|
||||||
|
|
||||||
// Multiple queries
|
|
||||||
connection.subscriptionBuilder()
|
|
||||||
.subscribe(['SELECT * FROM player', 'SELECT * FROM game_state']);
|
|
||||||
|
|
||||||
// Subscribe to all tables (development only — cannot mix with Subscribe)
|
|
||||||
connection.subscriptionBuilder().subscribeToAllTables();
|
|
||||||
|
|
||||||
// Subscription handle for later unsubscribe
|
|
||||||
const handle = connection.subscriptionBuilder()
|
|
||||||
.onApplied(() => console.log('Subscribed'))
|
|
||||||
.subscribe('SELECT * FROM player');
|
|
||||||
|
|
||||||
handle.unsubscribeThen(() => console.log('Unsubscribed'));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Accessing Table Data
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
for (const player of connection.db.player.iter()) { console.log(player.name); }
|
|
||||||
const players = Array.from(connection.db.player.iter());
|
|
||||||
const count = connection.db.player.count();
|
|
||||||
const player = connection.db.player.id.find(42n);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Table Event Callbacks
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
connection.db.player.onInsert((ctx, player) => console.log('New:', player.name));
|
|
||||||
connection.db.player.onDelete((ctx, player) => console.log('Left:', player.name));
|
|
||||||
connection.db.player.onUpdate((ctx, old, new_) => console.log(`${old.score} -> ${new_.score}`));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Calling Reducers
|
|
||||||
|
|
||||||
**CRITICAL: Use object syntax, not positional arguments.**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Snake_case to camelCase conversion
|
|
||||||
- Server: `export const do_something = spacetimedb.reducer(...)`
|
|
||||||
- Client: `conn.reducers.doSomething({ ... })`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Identity and Authentication
|
|
||||||
|
|
||||||
- `identity` and `token` are provided in the `onConnect` callback (see Client Connection above)
|
|
||||||
- `identity.toHexString()` for display or logging
|
|
||||||
- Omit `.withToken()` for anonymous connection — server assigns a new identity
|
|
||||||
- Pass a stale/invalid token: server issues a new identity and token in `onConnect`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
Connection-level errors (`.onConnectError`, `.onDisconnect`) are shown in the Client Connection example above.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Subscription error
|
|
||||||
connection.subscriptionBuilder()
|
|
||||||
.onApplied(() => console.log('Subscribed'))
|
|
||||||
.onError((ctx) => console.error('Subscription error:', ctx.event))
|
|
||||||
.subscribe('SELECT * FROM player');
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Server-Side Module Development
|
|
||||||
|
|
||||||
### Table Definition
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { schema, table, t } from 'spacetimedb/server';
|
|
||||||
|
|
||||||
export const Task = table({
|
|
||||||
name: 'task',
|
|
||||||
public: true,
|
|
||||||
indexes: [{ name: 'task_owner_id', algorithm: 'btree', columns: ['ownerId'] }]
|
|
||||||
}, {
|
|
||||||
id: t.u64().primaryKey().autoInc(),
|
|
||||||
ownerId: t.identity(),
|
|
||||||
title: t.string(),
|
|
||||||
createdAt: t.timestamp(),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Column types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
t.identity() // User identity
|
|
||||||
t.u64() // Unsigned 64-bit integer (use for IDs)
|
|
||||||
t.string() // Text
|
|
||||||
t.bool() // Boolean
|
|
||||||
t.timestamp() // Timestamp
|
|
||||||
t.scheduleAt() // For scheduled tables only
|
|
||||||
t.object('Name', {}) // Product types (nested objects)
|
|
||||||
t.enum('Name', {}) // Sum types (tagged unions)
|
|
||||||
t.string().optional() // Nullable
|
|
||||||
```
|
|
||||||
|
|
||||||
> BigInt syntax: All `u64`/`i64` fields use `0n`, `1n`, not `0`, `1`.
|
|
||||||
|
|
||||||
### Schema export
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const spacetimedb = schema({ Task, Player });
|
|
||||||
export default spacetimedb;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reducer Definition (2.0)
|
|
||||||
|
|
||||||
**Name comes from the export — NOT from a string argument.**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import spacetimedb from './schema';
|
|
||||||
import { t, SenderError } from 'spacetimedb/server';
|
|
||||||
|
|
||||||
export const create_task = spacetimedb.reducer(
|
|
||||||
{ title: t.string() },
|
|
||||||
(ctx, { title }) => {
|
|
||||||
if (!title) throw new SenderError('title required');
|
|
||||||
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title, createdAt: ctx.timestamp });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Pattern
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const existing = ctx.db.task.id.find(taskId);
|
|
||||||
if (!existing) throw new SenderError('Task not found');
|
|
||||||
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lifecycle Hooks
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
spacetimedb.clientConnected((ctx) => { /* ctx.sender is the connecting identity */ });
|
|
||||||
spacetimedb.clientDisconnected((ctx) => { /* clean up */ });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Event Tables (2.0)
|
|
||||||
|
|
||||||
Reducer callbacks are removed in 2.0. Use event tables + `onInsert` instead.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const DamageEvent = table(
|
|
||||||
{ name: 'damage_event', public: true, event: true },
|
|
||||||
{ target: t.identity(), amount: t.u32() }
|
|
||||||
);
|
|
||||||
|
|
||||||
export const deal_damage = spacetimedb.reducer(
|
|
||||||
{ target: t.identity(), amount: t.u32() },
|
|
||||||
(ctx, { target, amount }) => {
|
|
||||||
ctx.db.damageEvent.insert({ target, amount });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Client subscribes and uses `onInsert`:
|
|
||||||
```typescript
|
|
||||||
conn.db.damageEvent.onInsert((ctx, evt) => {
|
|
||||||
playDamageAnimation(evt.target, evt.amount);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Event tables must be subscribed explicitly — they are excluded from `subscribeToAllTables()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Views
|
|
||||||
|
|
||||||
### ViewContext vs AnonymousViewContext
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ViewContext — has ctx.sender, result varies per user
|
|
||||||
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 (better perf)
|
|
||||||
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(Player.rowType), (ctx) => {
|
|
||||||
return ctx.from.player.where(p => p.score.gt(1000));
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Views can only use index lookups — `.iter()` is NOT allowed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scheduled Tables
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const CleanupJob = table({
|
|
||||||
name: 'cleanup_job',
|
|
||||||
scheduled: () => run_cleanup // function returning the exported reducer
|
|
||||||
}, {
|
|
||||||
scheduledId: t.u64().primaryKey().autoInc(),
|
|
||||||
scheduledAt: t.scheduleAt(),
|
|
||||||
targetId: t.u64(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const run_cleanup = spacetimedb.reducer(
|
|
||||||
{ arg: CleanupJob.rowType },
|
|
||||||
(ctx, { arg }) => { /* arg.scheduledId, arg.targetId available */ }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schedule a job
|
|
||||||
import { ScheduleAt } from 'spacetimedb';
|
|
||||||
ctx.db.cleanupJob.insert({
|
|
||||||
scheduledId: 0n,
|
|
||||||
scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + 60_000_000n),
|
|
||||||
targetId: someId
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### ScheduleAt on Client
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ScheduleAt is a tagged union on the client
|
|
||||||
// { tag: 'Time', value: Timestamp } or { tag: 'Interval', value: TimeDuration }
|
|
||||||
const schedule = row.scheduledAt;
|
|
||||||
if (schedule.tag === 'Time') {
|
|
||||||
const date = new Date(Number(schedule.value.microsSinceUnixEpoch / 1000n));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Timestamps
|
|
||||||
|
|
||||||
### Server-side
|
|
||||||
```typescript
|
|
||||||
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
|
|
||||||
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client-side
|
|
||||||
```typescript
|
|
||||||
// Timestamps are objects with BigInt, not numbers
|
|
||||||
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Procedures (Beta)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const fetch_data = spacetimedb.procedure(
|
|
||||||
{ url: t.string() }, t.string(),
|
|
||||||
(ctx, { url }) => {
|
|
||||||
const response = ctx.http.fetch(url);
|
|
||||||
ctx.withTx(tx => { tx.db.myTable.insert({ id: 0n, content: response.text() }); });
|
|
||||||
return response.text();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Procedures don't have `ctx.db` — use `ctx.withTx(tx => tx.db...)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## React Integration
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
|
|
||||||
import { DbConnection, tables } from './module_bindings';
|
|
||||||
|
|
||||||
function Root() {
|
|
||||||
const connectionBuilder = useMemo(() =>
|
|
||||||
DbConnection.builder()
|
|
||||||
.withUri('ws://localhost:3000')
|
|
||||||
.withDatabaseName('my_game')
|
|
||||||
.withToken(localStorage.getItem('auth_token') || undefined)
|
|
||||||
.onConnect((conn, identity, token) => {
|
|
||||||
localStorage.setItem('auth_token', token);
|
|
||||||
conn.subscriptionBuilder().subscribe(tables.player);
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
|
|
||||||
<App />
|
|
||||||
</SpacetimeDBProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlayerList() {
|
|
||||||
const [players, isReady] = useTable(tables.player);
|
|
||||||
if (!isReady) return <div>Loading...</div>;
|
|
||||||
return <ul>{players.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Server (`backend/spacetimedb/`)
|
|
||||||
```
|
|
||||||
src/schema.ts -> Tables, export spacetimedb
|
|
||||||
src/index.ts -> Reducers, lifecycle, import schema
|
|
||||||
package.json -> { "type": "module", "dependencies": { "spacetimedb": "^2.0.0" } }
|
|
||||||
tsconfig.json -> Standard config
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client (`client/`)
|
|
||||||
```
|
|
||||||
src/module_bindings/ -> Generated (spacetime generate)
|
|
||||||
src/main.tsx -> Provider, connection setup
|
|
||||||
src/App.tsx -> UI components
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
spacetime start
|
|
||||||
spacetime publish <module-name> --module-path <backend-dir>
|
|
||||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
|
||||||
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
|
||||||
spacetime logs <module-name>
|
|
||||||
```
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
---
|
|
||||||
name: spacetimedb-unity
|
|
||||||
description: Integrate SpacetimeDB with Unity game projects. Use when building Unity clients with MonoBehaviour lifecycle, FrameTick, and PlayerPrefs token persistence.
|
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: clockworklabs
|
|
||||||
version: "2.0"
|
|
||||||
tested_with: "SpacetimeDB 2.0, Unity 2022.3+"
|
|
||||||
---
|
|
||||||
|
|
||||||
# SpacetimeDB Unity Integration
|
|
||||||
|
|
||||||
This skill covers Unity-specific patterns for connecting to SpacetimeDB. For server-side module development and general C# SDK usage, see the `spacetimedb-csharp` skill.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## HALLUCINATED APIs — DO NOT USE
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// WRONG — these do not exist in Unity SDK
|
|
||||||
SpacetimeDBClient.instance.Connect(...); // Use DbConnection.Builder()
|
|
||||||
SpacetimeDBClient.instance.Subscribe(...); // Use conn.SubscriptionBuilder()
|
|
||||||
NetworkManager.RegisterReducer(...); // SpacetimeDB is not a Unity networking plugin
|
|
||||||
|
|
||||||
// WRONG — old 1.0 patterns
|
|
||||||
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
|
|
||||||
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Mistakes
|
|
||||||
|
|
||||||
| Wrong | Right | Error |
|
|
||||||
|-------|-------|-------|
|
|
||||||
| Not calling `FrameTick()` | `conn?.FrameTick()` in `Update()` | No callbacks fire |
|
|
||||||
| Accessing `conn.Db` from background thread | Copy data in callback, use on main thread | Data races / crashes |
|
|
||||||
| Forgetting `DontDestroyOnLoad` | Add to manager `Awake()` | Connection lost on scene load |
|
|
||||||
| Connecting in `Update()` | Connect in `Start()` or on user action | Reconnects every frame |
|
|
||||||
| Not saving auth token | `PlayerPrefs.SetString(...)` in `OnConnect` | New identity every session |
|
|
||||||
| Missing generated bindings | Run `spacetime generate --lang csharp` | Compile errors |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Add via Unity Package Manager using the git URL:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git
|
|
||||||
```
|
|
||||||
|
|
||||||
**Window > Package Manager > + > Add package from git URL**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Generate Module Bindings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path PATH_TO_MODULE
|
|
||||||
```
|
|
||||||
|
|
||||||
Place generated files in your Assets folder so Unity compiles them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SpacetimeManager Singleton
|
|
||||||
|
|
||||||
The core pattern for Unity integration. This MonoBehaviour manages the connection lifecycle.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using UnityEngine;
|
|
||||||
using SpacetimeDB;
|
|
||||||
using SpacetimeDB.Types;
|
|
||||||
|
|
||||||
public class SpacetimeManager : MonoBehaviour
|
|
||||||
{
|
|
||||||
private const string TOKEN_KEY = "SpacetimeAuthToken";
|
|
||||||
private const string SERVER_URI = "http://localhost:3000";
|
|
||||||
private const string DATABASE_NAME = "my-game";
|
|
||||||
|
|
||||||
public static SpacetimeManager Instance { get; private set; }
|
|
||||||
public DbConnection Connection { get; private set; }
|
|
||||||
public Identity LocalIdentity { get; private set; }
|
|
||||||
|
|
||||||
void Awake()
|
|
||||||
{
|
|
||||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
|
||||||
Instance = this;
|
|
||||||
DontDestroyOnLoad(gameObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Start()
|
|
||||||
{
|
|
||||||
string savedToken = PlayerPrefs.GetString(TOKEN_KEY, null);
|
|
||||||
|
|
||||||
Connection = DbConnection.Builder()
|
|
||||||
.WithUri(SERVER_URI)
|
|
||||||
.WithDatabaseName(DATABASE_NAME)
|
|
||||||
.WithToken(savedToken)
|
|
||||||
.OnConnect(OnConnected)
|
|
||||||
.OnConnectError(err => Debug.LogError($"Connection failed: {err}"))
|
|
||||||
.OnDisconnect((conn, err) => {
|
|
||||||
if (err != null) Debug.LogError($"Disconnected: {err}");
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Update()
|
|
||||||
{
|
|
||||||
Connection?.FrameTick();
|
|
||||||
}
|
|
||||||
|
|
||||||
void OnDestroy()
|
|
||||||
{
|
|
||||||
Connection?.Disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnConnected(DbConnection conn, Identity identity, string authToken)
|
|
||||||
{
|
|
||||||
LocalIdentity = identity;
|
|
||||||
PlayerPrefs.SetString(TOKEN_KEY, authToken);
|
|
||||||
PlayerPrefs.Save();
|
|
||||||
|
|
||||||
Debug.Log($"Connected as: {identity}");
|
|
||||||
|
|
||||||
conn.SubscriptionBuilder()
|
|
||||||
.OnApplied(OnSubscriptionApplied)
|
|
||||||
.SubscribeToAllTables();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSubscriptionApplied(SubscriptionEventContext ctx)
|
|
||||||
{
|
|
||||||
Debug.Log("Subscription applied — game state loaded");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FrameTick — Critical
|
|
||||||
|
|
||||||
**`FrameTick()` must be called every frame in `Update()`.** The SDK queues all network messages and only processes them when you call `FrameTick()`. Without it:
|
|
||||||
- No callbacks fire (OnInsert, OnUpdate, OnDelete, reducer callbacks)
|
|
||||||
- The client appears frozen
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
void Update()
|
|
||||||
{
|
|
||||||
Connection?.FrameTick();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Thread safety**: `FrameTick()` processes messages on the calling thread (the main thread in Unity). Do NOT call it from a background thread. Do NOT access `conn.Db` from background threads.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Subscribing to Tables
|
|
||||||
|
|
||||||
Subscribe in the `OnConnected` callback:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private void OnConnected(DbConnection conn, Identity identity, string authToken)
|
|
||||||
{
|
|
||||||
// ...save token...
|
|
||||||
|
|
||||||
// Development: subscribe to all
|
|
||||||
conn.SubscriptionBuilder()
|
|
||||||
.OnApplied(OnSubscriptionApplied)
|
|
||||||
.SubscribeToAllTables();
|
|
||||||
|
|
||||||
// Production: subscribe to specific tables
|
|
||||||
conn.SubscriptionBuilder()
|
|
||||||
.OnApplied(OnSubscriptionApplied)
|
|
||||||
.Subscribe(new[] {
|
|
||||||
"SELECT * FROM player",
|
|
||||||
"SELECT * FROM game_state"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Row Callbacks for Game State
|
|
||||||
|
|
||||||
Register callbacks to update Unity GameObjects when table data changes.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
void RegisterCallbacks()
|
|
||||||
{
|
|
||||||
Connection.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
|
||||||
SpawnPlayerObject(player);
|
|
||||||
};
|
|
||||||
|
|
||||||
Connection.Db.Player.OnDelete += (EventContext ctx, Player player) => {
|
|
||||||
DestroyPlayerObject(player.Id);
|
|
||||||
};
|
|
||||||
|
|
||||||
Connection.Db.Player.OnUpdate += (EventContext ctx, Player oldPlayer, Player newPlayer) => {
|
|
||||||
UpdatePlayerObject(newPlayer);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Register these in `OnSubscriptionApplied` (after initial data is loaded) or in `Start()` before connecting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Calling Reducers from UI
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class GameUI : MonoBehaviour
|
|
||||||
{
|
|
||||||
public void OnMoveButtonClicked(Vector2 direction)
|
|
||||||
{
|
|
||||||
SpacetimeManager.Instance.Connection.Reducers.MovePlayer(direction.x, direction.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnSendChat(string message)
|
|
||||||
{
|
|
||||||
SpacetimeManager.Instance.Connection.Reducers.SendMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reducer Callbacks
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
SpacetimeManager.Instance.Connection.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
|
|
||||||
if (ctx.Event.Status is Status.Committed)
|
|
||||||
Debug.Log($"Message sent: {text}");
|
|
||||||
else if (ctx.Event.Status is Status.Failed(var reason))
|
|
||||||
Debug.LogError($"Send failed: {reason}");
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reading the Client Cache
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Find by primary key
|
|
||||||
if (Connection.Db.Player.Id.Find(playerId) is Player player)
|
|
||||||
{
|
|
||||||
Debug.Log($"Player: {player.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate all
|
|
||||||
foreach (var p in Connection.Db.Player.Iter())
|
|
||||||
{
|
|
||||||
Debug.Log(p.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by index
|
|
||||||
foreach (var p in Connection.Db.Player.Level.Filter(5))
|
|
||||||
{
|
|
||||||
Debug.Log($"Level 5: {p.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count
|
|
||||||
int total = Connection.Db.Player.Count;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Unity-Specific Considerations
|
|
||||||
|
|
||||||
### Main Thread Only
|
|
||||||
All SpacetimeDB SDK calls (`FrameTick`, `conn.Db` access, reducer calls) must happen on the main thread. If you need to pass data to a background thread, copy it first in the callback.
|
|
||||||
|
|
||||||
### Scene Loading
|
|
||||||
Use `DontDestroyOnLoad(gameObject)` on the SpacetimeManager to prevent the connection from being destroyed during scene transitions. Without it, the connection drops every time you load a new scene.
|
|
||||||
|
|
||||||
### IL2CPP / AOT
|
|
||||||
The SpacetimeDB SDK uses code generation. If you encounter issues with IL2CPP builds:
|
|
||||||
- Ensure generated bindings are up to date
|
|
||||||
- Check that `link.xml` preserves SpacetimeDB types if you use assembly stripping
|
|
||||||
|
|
||||||
### Token Persistence
|
|
||||||
Token save/load via `PlayerPrefs` is demonstrated in the SpacetimeManager singleton above. If the token is stale or invalid, the server issues a new identity and token in the `OnConnect` callback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
spacetime start
|
|
||||||
spacetime publish <module-name> --module-path <backend-dir>
|
|
||||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
|
||||||
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path <backend-dir>
|
|
||||||
spacetime logs <module-name>
|
|
||||||
```
|
|
||||||
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust
|
||||||
|
|
||||||
|
- 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。
|
||||||
|
- 决策:`.codex/skills/` 下只保留 `spacetimedb-cli`、`spacetimedb-concepts`、`spacetimedb-rust` 三个本地 SpacetimeDB skill;删除 `spacetimedb-typescript`、`spacetimedb-csharp`、`spacetimedb-unity`。前端 / Node 侧如需处理 SpacetimeDB 订阅或绑定,按当前生成绑定、项目代码和官方文档核对,不再依赖仓库内单独 TypeScript skill。
|
||||||
|
- 影响范围:`AGENTS.md` 的 SpacetimeDB skill 清单、`.codex/skills/` 本地 skill 维护范围、后续 SpacetimeDB 设计 / CLI / Rust module 开发协作口径。
|
||||||
|
- 验证方式:用上游 `clockworklabs/SpacetimeDB@master` 的 `skills/` 目录对照,运行本地 skill 校验、删除引用扫描、`git diff --check -- .codex/skills AGENTS.md .hermes/shared-memory/decision-log.md` 和 `npm run check:encoding`。
|
||||||
|
- 关联文档:`AGENTS.md`、`.codex/skills/spacetimedb-cli/SKILL.md`、`.codex/skills/spacetimedb-concepts/SKILL.md`、`.codex/skills/spacetimedb-rust/SKILL.md`。
|
||||||
|
|
||||||
## 2026-06-13 图片大图预览统一为黑底全屏查看器
|
## 2026-06-13 图片大图预览统一为黑底全屏查看器
|
||||||
|
|
||||||
- 背景:`CreativeImageInputPanel` 的参考图 / 主图预览曾使用白底 `UnifiedModal` 工具弹窗,移动端会透出原页面背景,且不能全屏查看、缩放或拖拽细节。
|
- 背景:`CreativeImageInputPanel` 的参考图 / 主图预览曾使用白底 `UnifiedModal` 工具弹窗,移动端会透出原页面背景,且不能全屏查看、缩放或拖拽细节。
|
||||||
|
|||||||
@@ -67,11 +67,10 @@ Single-context layout: read root `CONTEXT.md` when present. Current architecture
|
|||||||
- [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
|
- [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
|
||||||
- [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
|
- [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
|
||||||
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
|
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
|
||||||
- [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
|
|
||||||
- 涉及 `spacetime` CLI、发布、绑定生成、本地联调时,按 `spacetimedb-cli` 执行。
|
- 涉及 `spacetime` CLI、发布、绑定生成、本地联调时,按 `spacetimedb-cli` 执行。
|
||||||
- 涉及 `npm run dev` / `npm run dev:rust` / `npm run dev:web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。
|
- 涉及 `npm run dev` / `npm run dev:rust` / `npm run dev:web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。
|
||||||
- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。
|
- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。
|
||||||
- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时,按 `spacetimedb-typescript` 与 `spacetimedb-concepts` 执行。
|
- 涉及前端或 Node 侧的 SpacetimeDB 订阅、绑定使用时,按当前生成绑定、项目代码和官方文档核对;本仓库不再维护单独 TypeScript / C# / Unity SpacetimeDB skill。
|
||||||
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
|
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
|
||||||
- 修改后端代码后,必须使用 `npm run api-server` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。
|
- 修改后端代码后,必须使用 `npm run api-server` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。
|
||||||
- 数据库表结构更改后,需要对齐migration.rs
|
- 数据库表结构更改后,需要对齐migration.rs
|
||||||
|
|||||||
Reference in New Issue
Block a user