https://checkmate.social
checkmate AGENTS.md
25 kB
Markdown
at main

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 #

  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 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 }