Files
zep/.github/copilot-instructions.md
2026-03-30 17:41:54 -04:00

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, nameaccessor, 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

  1. Reducers are transactional — they do not return data to callers
  2. Reducers must be deterministic — no filesystem, network, timers, or random
  3. Read data via tables/subscriptions — not reducer return values
  4. Auto-increment IDs are not sequential — gaps are normal, don't use for ordering
  5. ctx.sender is the authenticated principal — never trust identity args

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 — don't forget 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.


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 list should 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

  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?

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() — use useTable(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 (NOT 0, 1, 100)
  • Comparisons: row.id === 5n (NOT row.id === 5)
  • Arithmetic: row.count + 1n (NOT row.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.ts for exact export names
  • Usage: useTable(tables.MyMessages) or tables.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();

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.tsx         → Provider, connection setup
src/App.tsx          → 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:

  1. schema({ table }) — takes exactly one object; never schema(table) or schema(t1, t2, t3)
  2. Reducer/procedure names from exportsexport 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 fields0n, 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 }