27 KiB
SpacetimeDB Rules (All Languages)
Migrating from 1.0 to 2.0?
If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply spacetimedb-migration-2.0.mdc first. It documents breaking changes (reducer callbacks → event tables, name→accessor, sender() method, etc.) and should be considered before other rules.
Language-Specific Rules
| Language | Rule File |
|---|---|
| TypeScript/React | spacetimedb-typescript.mdc (MANDATORY) |
| Rust | spacetimedb-rust.mdc (MANDATORY) |
| C# | spacetimedb-csharp.mdc (MANDATORY) |
| Migrating 1.0 → 2.0 | spacetimedb-migration-2.0.mdc |
Core Concepts
- Reducers are transactional — they do not return data to callers
- Reducers must be deterministic — no filesystem, network, timers, or random
- Read data via tables/subscriptions — not reducer return values
- Auto-increment IDs are not sequential — gaps are normal, don't use for ordering
ctx.senderis the authenticated principal — never trust identity args
Feature Implementation Checklist
When implementing a feature that spans backend and client:
- Backend: Define table(s) to store the data
- Backend: Define reducer(s) to mutate the data
- Client: Subscribe to the table(s)
- Client: Call the reducer(s) from UI — don't forget this step!
- Client: Render the data from the table(s)
Common mistake: Building backend tables/reducers but forgetting to wire up the client to call them.
Index System
SpacetimeDB automatically creates indexes for:
- Primary key columns
- Columns marked as unique
You can add explicit indexes on non-unique columns for query performance.
Index names must be unique across your entire module (all tables). If two tables have indexes with the same declared name → conflict error.
Schema ↔ Code coupling:
- Your query code references indexes by name
- If you add/remove/rename an index in the schema, update all code that uses it
- Removing an index without updating queries causes runtime errors
Commands
# Login to allow remote database deployment e.g. to maincloud
spacetime login
# Start local SpacetimeDB
spacetime start
# Publish module
spacetime publish <db-name> --module-path <module-path>
# Clear and republish
spacetime publish <db-name> --clear-database -y --module-path <module-path>
# Generate client bindings
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
# View logs
spacetime logs <db-name>
Deployment
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
- The default server marked by *** in
spacetime server listshould be used when publishing - If the default server is maincloud you should publish to maincloud
- Publishing to maincloud is free of charge
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/
- The database owner can view utilization and performance metrics on the dashboard
Debugging Checklist
- Is SpacetimeDB server running? (
spacetime start) - Is the module published? (
spacetime publish) - Are client bindings generated? (
spacetime generate) - Check server logs for errors (
spacetime logs <db-name>) - Is the reducer actually being called from the client?
Editing Behavior
- Make the smallest change necessary
- Do NOT touch unrelated files, configs, or dependencies
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
SpacetimeDB TypeScript SDK
⛔ HALLUCINATED APIs — DO NOT USE
These APIs DO NOT EXIST. LLMs frequently hallucinate them.
// ❌ 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 — static methods on generated types don't exist
User.filterByName('alice');
Message.findById(123n);
tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!
✅ CORRECT PATTERNS:
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from "./module_bindings"; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from "spacetimedb/react";
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: "test" });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
const [items, isLoading] = useTable(tables.item);
⛔ DO NOT:
- Invent hooks like
useItems(),useData()— useuseTable(tables.tableName) - Import from fake packages — only
spacetimedb,spacetimedb/react,./module_bindings
1) Common Mistakes Table
Server-side errors
| Wrong | Right | Error |
|---|---|---|
Missing package.json |
Create package.json |
"could not detect language" |
Missing tsconfig.json |
Create tsconfig.json |
"TsconfigNotFound" |
Entrypoint not at src/index.ts |
Use src/index.ts |
Module won't bundle |
indexes in COLUMNS (2nd arg) |
indexes in OPTIONS (1st arg) |
"reading 'tag'" error |
Index without algorithm |
algorithm: 'btree' |
"reading 'tag'" error |
filter({ ownerId }) |
filter(ownerId) |
"does not exist in type 'Range'" |
.filter() on unique column |
.find() on unique column |
TypeError |
insert({ ...without id }) |
insert({ id: 0n, ... }) |
"Property 'id' is missing" |
const id = table.insert(...) |
const row = table.insert(...) |
.insert() returns ROW, not ID |
.unique() + explicit index |
Just use .unique() |
"name is used for multiple entities" |
Index on .primaryKey() column |
Don't — already indexed | "name is used for multiple entities" |
| Same index name in multiple tables | Prefix with table name | "name is used for multiple entities" |
.indexName.filter() after removing index |
Use .iter() + manual filter |
"Cannot read properties of undefined" |
| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" |
Multi-column index .filter() |
⚠️ BROKEN — use single-column | PANIC or silent empty results |
JSON.stringify({ id: row.id }) |
Convert BigInt first: { id: row.id.toString() } |
"Do not know how to serialize a BigInt" |
ScheduleAt.Time(timestamp) |
ScheduleAt.time(timestamp) (lowercase) |
"ScheduleAt.Time is not a function" |
ctx.db.foo.myIndexName.filter() |
Use exact name: ctx.db.foo.my_index_name.filter() |
"Cannot read properties of undefined" |
.iter() in views |
Use index lookups | Severe performance issues (re-evaluates on any change) |
ctx.db in procedures |
ctx.withTx(tx => tx.db...) |
Procedures need explicit transactions |
ctx.myTable in procedure tx |
tx.db.myTable |
Wrong context variable |
Client-side errors
| Wrong | Right | Error |
|---|---|---|
@spacetimedb/sdk |
spacetimedb |
404 / missing subpath |
conn.reducers.foo("val") |
conn.reducers.foo({ param: "val" }) |
Wrong reducer syntax |
Inline connectionBuilder |
useMemo(() => ..., []) |
Reconnects every render |
const rows = useTable(table) |
const [rows, isLoading] = useTable(table) |
Tuple destructuring |
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
<SpacetimeDBProvider builder={...}> |
connectionBuilder={...} |
Wrong prop name |
2) Table Definition (CRITICAL)
table() takes TWO arguments: table(OPTIONS, COLUMNS)
import { schema, table, t } from "spacetimedb/server";
// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
export const Task = table(
{ name: "task" },
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }], // ❌ WRONG!
},
);
// ✅ RIGHT — indexes in OPTIONS (first argument)
export const Task = table(
{
name: "task",
public: true,
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
title: t.string(),
createdAt: t.timestamp(),
},
);
Column types
t.identity(); // User identity (primary key for per-user tables)
t.u64(); // Unsigned 64-bit integer (use for IDs)
t.string(); // Text
t.bool(); // Boolean
t.timestamp(); // Timestamp (use ctx.timestamp for current time)
t.scheduleAt(); // For scheduled tables only
// Product types (nested objects) — use t.object, NOT t.struct
const Point = t.object("Point", { x: t.i32(), y: t.i32() });
// Sum types (tagged unions) — use t.enum, NOT t.sum
const Shape = t.enum("Shape", { circle: t.i32(), rectangle: Point });
// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
// Modifiers
t.string().optional(); // Nullable
t.u64().primaryKey(); // Primary key
t.u64().primaryKey().autoInc(); // Auto-increment primary key
⚠️ BIGINT SYNTAX: All
u64,i64, and ID fields use JavaScript BigInt.
- Literals:
0n,1n,100n(NOT0,1,100)- Comparisons:
row.id === 5n(NOTrow.id === 5)- Arithmetic:
row.count + 1n(NOTrow.count + 1)
Auto-increment placeholder
// ✅ MUST provide 0n placeholder for auto-inc fields
ctx.db.task.insert({
id: 0n,
ownerId: ctx.sender,
title: "New",
createdAt: ctx.timestamp,
});
Insert returns ROW, not ID
// ❌ WRONG
const id = ctx.db.task.insert({ ... });
// ✅ RIGHT
const row = ctx.db.task.insert({ ... });
const newId = row.id; // Extract .id from returned row
Schema export (CRITICAL)
// At end of schema.ts — schema() takes exactly ONE argument: an object
const spacetimedb = schema({ table1, table2, table3 });
export default spacetimedb;
// ❌ WRONG — never pass tables directly or as multiple args
schema(myTable); // WRONG!
schema(t1, t2, t3); // WRONG!
3) Index Access
TypeScript Query Patterns
// 1. PRIMARY KEY — use .pkColumn.find()
const user = ctx.db.user.identity.find(ctx.sender);
const msg = ctx.db.message.id.find(messageId);
// 2. EXPLICIT INDEX — use .indexName.filter(value)
const msgs = [...ctx.db.message.message_room_id.filter(roomId)];
// 3. NO INDEX — use .iter() + manual filter
for (const m of ctx.db.roomMember.iter()) {
if (m.roomId === roomId) {
/* ... */
}
}
Index Definition Syntax
// In table OPTIONS (first argument), not columns
export const Message = table(
{
name: "message",
public: true,
indexes: [
{ name: "message_room_id", algorithm: "btree", columns: ["roomId"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
roomId: t.u64(),
// ...
},
);
Naming conventions
Table names — automatic transformation:
- Schema:
table({ name: 'my_messages' }) - Access:
ctx.db.myMessages(automatic snake_case → camelCase)
Index names — NO transformation, use EXACTLY as defined:
// Schema definition
indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
// ❌ WRONG — don't assume camelCase transformation
ctx.db.canvasMember.canvasMember_canvas_id.filter(...) // WRONG!
ctx.db.canvasMember.canvasMemberCanvasId.filter(...) // WRONG!
// ✅ RIGHT — use exact name from schema
ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
⚠️ Index names are used VERBATIM — pick a convention (snake_case or camelCase) and stick with it.
Index naming pattern — use {tableName}_{columnName}:
// ✅ GOOD — unique names across entire module
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
indexes: [{ name: 'reaction_message_id', algorithm: 'btree', columns: ['messageId'] }]
// ❌ BAD — will collide if multiple tables use same index name
indexes: [{ name: 'by_owner', ... }] // in Task table
indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!
Client-side table names:
- Check generated
module_bindings/index.tsfor exact export names - Usage:
useTable(tables.MyMessages)ortables.myMessages(varies by SDK version)
Filter vs Find
// Filter takes VALUE directly, not object — returns iterator
const rows = [...ctx.db.task.by_owner.filter(ownerId)];
// Unique columns use .find() — returns single row or undefined
const row = ctx.db.player.identity.find(ctx.sender);
⚠️ Multi-column indexes are BROKEN
// ❌ DON'T — causes PANIC
ctx.db.scores.by_player_level.filter(playerId);
// ✅ DO — use single-column index + manual filter
for (const row of ctx.db.scores.by_player.filter(playerId)) {
if (row.level === targetLevel) {
/* ... */
}
}
4) Reducers
Definition syntax (CRITICAL)
Reducer name comes from the export — NOT from a string argument. Use reducer(params, fn) or reducer(fn).
import spacetimedb from './schema';
import { t, SenderError } from 'spacetimedb/server';
// ✅ CORRECT — export const name = spacetimedb.reducer(params, fn)
export const reducer_name = spacetimedb.reducer({ param1: t.string(), param2: t.u64() }, (ctx, { param1, param2 }) => {
// Validation
if (!param1) throw new SenderError('param1 required');
// Access tables via ctx.db
const row = ctx.db.myTable.primaryKey.find(param2);
// Mutations
ctx.db.myTable.insert({ ... });
ctx.db.myTable.primaryKey.update({ ...row, newField: value });
ctx.db.myTable.primaryKey.delete(param2);
});
// No params: export const init = spacetimedb.reducer((ctx) => { ... });
// ❌ WRONG — reducer('name', params, fn) does NOT exist
spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... });
Update pattern (CRITICAL)
// ✅ CORRECT — spread existing row, override specific fields
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,
});
// ❌ WRONG — partial update nulls out other fields!
ctx.db.task.id.update({ id: taskId, title: newTitle });
Delete pattern
// Delete by primary key VALUE (not row object)
ctx.db.task.id.delete(taskId); // taskId is the u64 value
ctx.db.player.identity.delete(ctx.sender); // delete by identity
Lifecycle hooks
spacetimedb.clientConnected((ctx) => {
// ctx.sender is the connecting identity
// Create/update user record, set online status, etc.
});
spacetimedb.clientDisconnected((ctx) => {
// Clean up: set offline status, remove ephemeral data, etc.
});
Snake_case to camelCase conversion
- Server:
export const do_something = spacetimedb.reducer(...)— name from export - Client:
conn.reducers.doSomething({ ... })
Object syntax required
// ❌ WRONG - positional
conn.reducers.doSomething("value");
// ✅ RIGHT - object
conn.reducers.doSomething({ param: "value" });
5) Scheduled Tables
// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
export const CleanupJob = table(
{
name: "cleanup_job",
scheduled: () => run_cleanup, // reducer defined below
},
{
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(), // Your custom data
},
);
// 2. Define scheduled reducer (receives full row as arg)
export const run_cleanup = spacetimedb.reducer(
{ arg: CleanupJob.rowType },
(ctx, { arg }) => {
// arg.scheduledId, arg.targetId available
// Row is auto-deleted after reducer completes
},
);
// Schedule a job
import { ScheduleAt } from "spacetimedb";
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(futureTime),
targetId: someId,
});
// Cancel a job by deleting the row
ctx.db.cleanupJob.scheduledId.delete(jobId);
6) Timestamps
Server-side
import { Timestamp, ScheduleAt } from "spacetimedb";
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Future time (add microseconds)
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
Client-side (CRITICAL)
Timestamps are objects, not numbers:
// ❌ WRONG
const date = new Date(row.createdAt);
const date = new Date(Number(row.createdAt / 1000n));
// ✅ RIGHT
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
ScheduleAt on client
// ScheduleAt is a tagged union
if (scheduleAt.tag === "Time") {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
7) Data Visibility & Subscriptions
public: true exposes ALL rows to ALL clients.
| Scenario | Pattern |
|---|---|
| Everyone sees all rows | public: true |
| Users see only their data | Private table + filtered subscription |
Subscription patterns (client-side)
// Subscribe to ALL public tables (simplest)
conn.subscriptionBuilder().subscribeToAll();
// Subscribe to specific tables with SQL
conn
.subscriptionBuilder()
.subscribe([
"SELECT * FROM message",
"SELECT * FROM room WHERE is_public = true",
]);
// Handle subscription lifecycle
conn
.subscriptionBuilder()
.onApplied(() => console.log("Initial data loaded"))
.onError((e) => console.error("Subscription failed:", e))
.subscribeToAll();
Private table + view pattern (RECOMMENDED)
Views are the recommended approach for controlling data visibility. They provide:
- Server-side filtering (reduces network traffic)
- Real-time updates when underlying data changes
- Full control over what data clients can access
⚠️ Do NOT use Row Level Security (RLS) — it is deprecated.
⚠️ CRITICAL: Procedural views (views that compute results in code) can ONLY access data via index lookups, NOT
.iter(). If you need a view that scans/filters across many rows (including the entire table), return a query built with the query builder (ctx.from...).
// Private table with index on ownerId
export const PrivateData = table(
{
name: "private_data",
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
secret: t.string(),
},
);
// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
spacetimedb.view(
{ name: "my_data_slow", public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.iter()], // Works but VERY slow at scale
);
// ✅ GOOD — index lookup enables targeted invalidation
spacetimedb.view(
{ name: "my_data", public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)],
);
Query builder view pattern (can scan)
// Query-builder views return a query; the SQL engine maintains the result incrementally.
// This can scan the whole table if needed (e.g. leaderboard-style queries).
spacetimedb.anonymousView(
{ name: "top_players", public: true },
t.array(Player.rowType),
(ctx) => ctx.from.player.where((p) => p.score.gt(1000)),
);
ViewContext vs AnonymousViewContext
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view(
{ name: "my_items", public: true },
t.array(Item.rowType),
(ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
},
);
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView(
{ name: "leaderboard", public: true },
t.array(LeaderboardRow),
(ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
},
);
Views require explicit subscription:
conn.subscriptionBuilder().subscribe([
"SELECT * FROM public_table",
"SELECT * FROM my_data", // Views need explicit SQL!
]);
8) React Integration
Key patterns
// Memoize connectionBuilder to prevent reconnects on re-render
const builder = useMemo(
() =>
DbConnection.builder()
.withUri(SPACETIMEDB_URI)
.withDatabaseName(MODULE_NAME)
.withToken(localStorage.getItem("auth_token") || undefined)
.onConnect(onConnect)
.onConnectError(onConnectError),
[], // Empty deps - only create once
);
// useTable returns tuple [rows, isLoading]
const [rows, isLoading] = useTable(tables.myTable);
// Compare identities using toHexString()
const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
9) Procedures (Beta)
Procedures are for side effects (HTTP requests, etc.) that reducers can't do.
⚠️ Procedures are currently in beta. API may change.
Defining a procedure
Procedure name comes from the export — NOT from a string argument. Use procedure(params, ret, fn) or procedure(ret, fn).
// ✅ CORRECT — export const name = spacetimedb.procedure(params, ret, fn)
export const fetch_external_data = spacetimedb.procedure(
{ url: t.string() },
t.string(), // return type
(ctx, { url }) => {
const response = ctx.http.fetch(url);
return response.text();
},
);
Database access in procedures
⚠️ CRITICAL: Procedures don't have ctx.db. Use ctx.withTx() for database access.
spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
// Fetch external data (outside transaction)
const response = ctx.http.fetch(url);
const data = response.text();
// ❌ WRONG — ctx.db doesn't exist in procedures
ctx.db.myTable.insert({ ... });
// ✅ RIGHT — use ctx.withTx() for database access
ctx.withTx(tx => {
tx.db.myTable.insert({
id: 0n,
content: data,
fetchedAt: tx.timestamp,
fetchedBy: tx.sender,
});
});
return {};
});
Key differences from reducers
| Reducers | Procedures |
|---|---|
ctx.db available directly |
Must use ctx.withTx(tx => tx.db...) |
| Automatic transaction | Manual transaction management |
| No HTTP/network | ctx.http.fetch() available |
| No return values to caller | Can return data to caller |
10) Project Structure
Server (backend/spacetimedb/)
src/schema.ts → Tables, export spacetimedb
src/index.ts → Reducers, lifecycle, import schema
package.json → { "type": "module", "dependencies": { "spacetimedb": "^1.11.0" } }
tsconfig.json → Standard config
Avoiding circular imports
schema.ts → defines tables AND exports spacetimedb
index.ts → imports spacetimedb from ./schema, defines reducers
Client (client/)
src/module_bindings/ → Generated (spacetime generate)
src/main.ts → Provider, connection setup
src/App.svelte → UI components
src/config.ts → MODULE_NAME, SPACETIMEDB_URI
11) Commands
# Start local server
spacetime start
# Publish module
spacetime publish <module-name> --module-path <backend-dir>
# Clear database and republish
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
# Generate bindings
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
# View logs
spacetime logs <module-name>
12) Hard Requirements
TypeScript-specific:
schema({ table })— takes exactly one object; neverschema(table)orschema(t1, t2, t3)- Reducer/procedure names from exports —
export const name = spacetimedb.reducer(params, fn); neverreducer('name', ...) - Reducer calls use object syntax —
{ param: 'value' }not positional args - Import
DbConnectionfrom./module_bindings— not fromspacetimedb - DO NOT edit generated bindings — regenerate with
spacetime generate - Indexes go in OPTIONS (1st arg) — not in COLUMNS (2nd arg) of
table() - Use BigInt for u64/i64 fields —
0n,1n, not0,1 - Reducers are transactional — they do not return data
- Reducers must be deterministic — no filesystem, network, timers, random
- Views should use index lookups —
.iter()causes severe performance issues - Procedures need
ctx.withTx()—ctx.dbdoesn't exist in procedures - Sum type values — use
{ tag: 'variant', value: payload }not{ variant: payload }