initial commit

This commit is contained in:
2026-03-28 22:05:24 -04:00
commit b33fce53dd
72 changed files with 16957 additions and 0 deletions
+659
View File
@@ -0,0 +1,659 @@
---
description: "⛔ MANDATORY: Read this ENTIRE file before writing ANY SpacetimeDB TypeScript code. Contains critical SDK patterns and HALLUCINATED APIs to avoid."
globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx
alwaysApply: true
---
# SpacetimeDB TypeScript SDK
## ⛔ 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 — 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:
```typescript
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
const [items, isLoading] = useTable(tables.item);
```
### ⛔ DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
---
## 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)`**
```typescript
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
```typescript
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
```typescript
// ✅ 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
```typescript
// ❌ 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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:**
```typescript
// 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}`:**
```typescript
// ✅ 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
```typescript
// 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
```typescript
// ❌ 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)`.
```typescript
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) => { ... });
```
```typescript
// ❌ WRONG — reducer('name', params, fn) does NOT exist
spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... });
```
### Update pattern (CRITICAL)
```typescript
// ✅ 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
```typescript
// 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
```typescript
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
```typescript
// ❌ WRONG - positional
conn.reducers.doSomething('value');
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });
```
---
## 5) Scheduled Tables
```typescript
// 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
```typescript
import { Timestamp, ScheduleAt } from 'spacetimedb';
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Future time (add microseconds)
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
```
### Client-side (CRITICAL)
**Timestamps are objects, not numbers:**
```typescript
// ❌ WRONG
const date = new Date(row.createdAt);
const date = new Date(Number(row.createdAt / 1000n));
// ✅ RIGHT
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
### ScheduleAt on client
```typescript
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
```
---
## 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)
```typescript
// 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...`).
```typescript
// 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)
```typescript
// 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
```typescript
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
```
**Views require explicit subscription:**
```typescript
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
]);
```
---
## 8) React Integration
### Key patterns
```typescript
// 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)`.
```typescript
// ✅ 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.**
```typescript
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
```bash
# 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 }`
+116
View File
@@ -0,0 +1,116 @@
---
description: "⛔ MANDATORY: Core SpacetimeDB concepts (all languages)."
globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx,**/*.rs,**/*.cs
alwaysApply: true
---
# 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
```bash
# 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/@<username>/<database-name>
- 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
+23
View File
@@ -0,0 +1,23 @@
# Generic / backend
SPACETIMEDB_DB_NAME=my-spacetime-app-jdhdg
SPACETIMEDB_HOST=https://maincloud.spacetimedb.com
# Vite
VITE_SPACETIMEDB_DB_NAME=my-spacetime-app-jdhdg
VITE_SPACETIMEDB_HOST=https://maincloud.spacetimedb.com
# Next.js
NEXT_PUBLIC_SPACETIMEDB_DB_NAME=my-spacetime-app-jdhdg
NEXT_PUBLIC_SPACETIMEDB_HOST=https://maincloud.spacetimedb.com
# Create React App
REACT_APP_SPACETIMEDB_DB_NAME=my-spacetime-app-jdhdg
REACT_APP_SPACETIMEDB_HOST=https://maincloud.spacetimedb.com
# Expo
EXPO_PUBLIC_SPACETIMEDB_DB_NAME=my-spacetime-app-jdhdg
EXPO_PUBLIC_SPACETIMEDB_HOST=https://maincloud.spacetimedb.com
# SvelteKit
PUBLIC_SPACETIMEDB_DB_NAME=my-spacetime-app-jdhdg
PUBLIC_SPACETIMEDB_HOST=https://maincloud.spacetimedb.com
+766
View File
@@ -0,0 +1,766 @@
# 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
```bash
# 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/@<username>/<database-name>
- 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.**
```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 — 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:
```typescript
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
const [items, isLoading] = useTable(tables.item);
```
### ⛔ DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
---
## 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)`**
```typescript
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
```typescript
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
```typescript
// ✅ 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
```typescript
// ❌ 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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:**
```typescript
// 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}`:**
```typescript
// ✅ 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
```typescript
// 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
```typescript
// ❌ 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)`.
```typescript
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) => { ... });
```
```typescript
// ❌ WRONG — reducer('name', params, fn) does NOT exist
spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... });
```
### Update pattern (CRITICAL)
```typescript
// ✅ 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
```typescript
// 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
```typescript
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
```typescript
// ❌ WRONG - positional
conn.reducers.doSomething('value');
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });
```
---
## 5) Scheduled Tables
```typescript
// 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
```typescript
import { Timestamp, ScheduleAt } from 'spacetimedb';
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Future time (add microseconds)
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
```
### Client-side (CRITICAL)
**Timestamps are objects, not numbers:**
```typescript
// ❌ WRONG
const date = new Date(row.createdAt);
const date = new Date(Number(row.createdAt / 1000n));
// ✅ RIGHT
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
### ScheduleAt on client
```typescript
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
```
---
## 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)
```typescript
// 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...`).
```typescript
// 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)
```typescript
// 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
```typescript
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
```
**Views require explicit subscription:**
```typescript
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
]);
```
---
## 8) React Integration
### Key patterns
```typescript
// 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)`.
```typescript
// ✅ 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.**
```typescript
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
```bash
# 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 }`
+47
View File
@@ -0,0 +1,47 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Dependency directory
node_modules/
jspm_packages/
# Build output
dist/
out/
build/
# IDEs and editors
.idea
.vscode/
*.iml
*~
# Misc
.DS_Store
*.env.local
.env.development.local
.env.test.local
.env.production.local
# Vite
.vite/
# SpacetimeDB build output
spacetimedb/dist/
# Ignore this file
.gitignore
+766
View File
@@ -0,0 +1,766 @@
# 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
```bash
# 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/@<username>/<database-name>
- 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.**
```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 — 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:
```typescript
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
const [items, isLoading] = useTable(tables.item);
```
### ⛔ DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
---
## 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)`**
```typescript
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
```typescript
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
```typescript
// ✅ 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
```typescript
// ❌ 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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:**
```typescript
// 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}`:**
```typescript
// ✅ 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
```typescript
// 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
```typescript
// ❌ 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)`.
```typescript
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) => { ... });
```
```typescript
// ❌ WRONG — reducer('name', params, fn) does NOT exist
spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... });
```
### Update pattern (CRITICAL)
```typescript
// ✅ 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
```typescript
// 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
```typescript
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
```typescript
// ❌ WRONG - positional
conn.reducers.doSomething('value');
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });
```
---
## 5) Scheduled Tables
```typescript
// 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
```typescript
import { Timestamp, ScheduleAt } from 'spacetimedb';
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Future time (add microseconds)
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
```
### Client-side (CRITICAL)
**Timestamps are objects, not numbers:**
```typescript
// ❌ WRONG
const date = new Date(row.createdAt);
const date = new Date(Number(row.createdAt / 1000n));
// ✅ RIGHT
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
### ScheduleAt on client
```typescript
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
```
---
## 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)
```typescript
// 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...`).
```typescript
// 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)
```typescript
// 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
```typescript
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
```
**Views require explicit subscription:**
```typescript
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
]);
```
---
## 8) React Integration
### Key patterns
```typescript
// 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)`.
```typescript
// ✅ 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.**
```typescript
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
```bash
# 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 }`
+766
View File
@@ -0,0 +1,766 @@
# 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
```bash
# 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/@<username>/<database-name>
- 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.**
```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 — 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:
```typescript
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
const [items, isLoading] = useTable(tables.item);
```
### ⛔ DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
---
## 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)`**
```typescript
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
```typescript
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
```typescript
// ✅ 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
```typescript
// ❌ 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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:**
```typescript
// 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}`:**
```typescript
// ✅ 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
```typescript
// 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
```typescript
// ❌ 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)`.
```typescript
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) => { ... });
```
```typescript
// ❌ WRONG — reducer('name', params, fn) does NOT exist
spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... });
```
### Update pattern (CRITICAL)
```typescript
// ✅ 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
```typescript
// 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
```typescript
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
```typescript
// ❌ WRONG - positional
conn.reducers.doSomething('value');
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });
```
---
## 5) Scheduled Tables
```typescript
// 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
```typescript
import { Timestamp, ScheduleAt } from 'spacetimedb';
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Future time (add microseconds)
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
```
### Client-side (CRITICAL)
**Timestamps are objects, not numbers:**
```typescript
// ❌ WRONG
const date = new Date(row.createdAt);
const date = new Date(Number(row.createdAt / 1000n));
// ✅ RIGHT
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
### ScheduleAt on client
```typescript
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
```
---
## 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)
```typescript
// 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...`).
```typescript
// 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)
```typescript
// 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
```typescript
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
```
**Views require explicit subscription:**
```typescript
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
]);
```
---
## 8) React Integration
### Key patterns
```typescript
// 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)`.
```typescript
// ✅ 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.**
```typescript
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
```bash
# 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 }`
+29
View File
@@ -0,0 +1,29 @@
# quickstart-chat
## 0.0.1
### Patch Changes
- Updated dependencies [[`cf7b7d8`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/cf7b7d89a1547fb3863f6641f5b2eb40a27c05d8), [`941cf4e`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/941cf4eba6b7df934d74696b373b89cc62764673), [`a501f5c`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/a501f5ccf9a0a926eb4f345ddeb01ffcb872d67e), [`9032269`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/9032269004d4dae587c39ccd85da0a32fb9a0114), [`6547882`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/6547882bb28ed9a1ca436335745e9997328026ff), [`5d7304b`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/5d7304bd3e05dd7a032cfb7069aab97b881f0179)]:
- @clockworklabs/spacetimedb-sdk@1.2.0
## 0.0.3-rc1.0
### Patch Changes
- Updated dependencies [[`cf7b7d8`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/cf7b7d89a1547fb3863f6641f5b2eb40a27c05d8), [`a501f5c`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/a501f5ccf9a0a926eb4f345ddeb01ffcb872d67e), [`9032269`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/9032269004d4dae587c39ccd85da0a32fb9a0114), [`6547882`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/6547882bb28ed9a1ca436335745e9997328026ff), [`5d7304b`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/5d7304bd3e05dd7a032cfb7069aab97b881f0179)]:
- @clockworklabs/spacetimedb-sdk@1.0.0-rc1.0
## 0.0.2
### Patch Changes
- Updated dependencies [[`2f6c82c`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/2f6c82c724b9f9407c7bedee13252ca8ffab8f7d), [`b9db9b6`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/b9db9b6e46d8c98b29327d97c12c07b7a2fc96bf), [`79c278b`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/79c278be71b2dfd82106ada983fd81d395b1d912)]:
- @clockworklabs/spacetimedb-sdk@0.12.1
## 0.0.1
### Patch Changes
- Updated dependencies [[`5adb557`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/5adb55776c81d0760cf0268df0fa5dee600f0ef8), [`ab1f463`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/ab1f463d7da6e530a6cd47e2433141bfd16addd1), [`b8c944c`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/b8c944cd23d3b53c72131803a775127bf0a95213), [`17227c0`](https://github.com/clockworklabs/spacetimedb-typescript-sdk/commit/17227c0f65def3a9d5e767756ccf46777210041a)]:
- @clockworklabs/spacetimedb-sdk@0.12.0
+766
View File
@@ -0,0 +1,766 @@
# 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
```bash
# 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/@<username>/<database-name>
- 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.**
```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 — 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:
```typescript
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
const [items, isLoading] = useTable(tables.item);
```
### ⛔ DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
---
## 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)`**
```typescript
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
```typescript
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
```typescript
// ✅ 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
```typescript
// ❌ 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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:**
```typescript
// 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}`:**
```typescript
// ✅ 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
```typescript
// 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
```typescript
// ❌ 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)`.
```typescript
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) => { ... });
```
```typescript
// ❌ WRONG — reducer('name', params, fn) does NOT exist
spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... });
```
### Update pattern (CRITICAL)
```typescript
// ✅ 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
```typescript
// 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
```typescript
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
```typescript
// ❌ WRONG - positional
conn.reducers.doSomething('value');
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });
```
---
## 5) Scheduled Tables
```typescript
// 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
```typescript
import { Timestamp, ScheduleAt } from 'spacetimedb';
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Future time (add microseconds)
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
```
### Client-side (CRITICAL)
**Timestamps are objects, not numbers:**
```typescript
// ❌ WRONG
const date = new Date(row.createdAt);
const date = new Date(Number(row.createdAt / 1000n));
// ✅ RIGHT
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
### ScheduleAt on client
```typescript
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
```
---
## 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)
```typescript
// 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...`).
```typescript
// 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)
```typescript
// 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
```typescript
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
```
**Views require explicit subscription:**
```typescript
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
]);
```
---
## 8) React Integration
### Key patterns
```typescript
// 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)`.
```typescript
// ✅ 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.**
```typescript
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
```bash
# 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 }`
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Clockwork Labs, Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+67
View File
@@ -0,0 +1,67 @@
# SpacetimeDB TypeScript Quickstart Chat
This is a simple chat application that demonstrates how to use SpacetimeDB with TypeScript and React. The chat application is a simple chat room where users can send messages to each other. The chat application uses SpacetimeDB to store the chat messages.
It is based directly on the plain React + TypeScript + Vite template. You can follow the quickstart guide for how creating this project from scratch at [SpacetimeDB TypeScript Quickstart](https://spacetimedb.com/docs/sdks/typescript/quickstart).
You can follow the instructions for creating your own SpacetimeDB module here: [SpacetimeDB Rust Module Quickstart](https://spacetimedb.com/docs/modules/rust/quickstart). Place the module in the `quickstart-chat/server` directory for compability with this project.
In order to run this example, you need to:
- `pnpm build` in the root directory (`spacetimedb-typescriptsdk`)
- `pnpm install` in this directory
- `pnpm build` in this directory
- `pnpm dev` in this directory to run the example
Below is copied from the original template README:
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
});
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react';
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
});
```
+86
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/index-DnMf931V.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-a-MKc3No.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
{
"name": "my-spacetime-app",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"format": "prettier . --write --ignore-path ../../.prettierignore",
"lint": "eslint . && prettier . --check --ignore-path ../../.prettierignore",
"preview": "vite preview",
"test": "vitest run",
"generate": "cargo run -p gen-bindings -- --out-dir src/module_bindings --module-path spacetimedb && prettier --write src/module_bindings",
"spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb",
"spacetime:publish:local": "spacetime publish --module-path server --server local",
"spacetime:publish": "spacetime publish --module-path server --server maincloud"
},
"dependencies": {
"oidc-client-ts": "^3.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-oidc-context": "^3.3.1",
"spacetimedb": "^2.1.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"prettier": "^3.3.3",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^7.1.5",
"vitest": "3.2.4"
}
}
+2993
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"dev": {
"run": "pnpm run dev"
},
"server": "maincloud",
"database": "my-spacetime-app-jdhdg",
"module-path": "./spacetimedb"
}
+3
View File
@@ -0,0 +1,3 @@
{
"database": "my-spacetime-app-jdhdg"
}
+6385
View File
File diff suppressed because one or more lines are too long
Generated Vendored Executable
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi
Generated Vendored Executable
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/alamers/git/my-spacetime-app/spacetimedb/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi
+51
View File
@@ -0,0 +1,51 @@
{
"hoistedDependencies": {
"headers-polyfill@4.0.3": {
"headers-polyfill": "private"
},
"base64-js@1.5.1": {
"base64-js": "private"
},
"url-polyfill@1.1.14": {
"url-polyfill": "private"
},
"object-inspect@1.13.4": {
"object-inspect": "private"
},
"pure-rand@7.0.1": {
"pure-rand": "private"
},
"statuses@2.0.2": {
"statuses": "private"
},
"safe-stable-stringify@2.5.0": {
"safe-stable-stringify": "private"
},
"prettier@3.8.1": {
"prettier": "private"
}
},
"hoistPattern": [
"*"
],
"included": {
"dependencies": true,
"devDependencies": true,
"optionalDependencies": true
},
"injectedDeps": {},
"layoutVersion": 5,
"nodeLinker": "isolated",
"packageManager": "pnpm@10.29.3",
"pendingBuilds": [],
"publicHoistPattern": [],
"prunedAt": "Sat, 28 Mar 2026 23:54:58 GMT",
"registries": {
"default": "https://registry.npmjs.org/",
"@jsr": "https://npm.jsr.io/"
},
"skipped": [],
"storeDir": "/Users/alamers/Library/pnpm/store/v10",
"virtualStoreDir": ".pnpm",
"virtualStoreDirMaxLength": 120
}
+25
View File
@@ -0,0 +1,25 @@
{
"lastValidatedTimestamp": 1774742098032,
"projects": {},
"pnpmfiles": [],
"settings": {
"autoInstallPeers": true,
"dedupeDirectDeps": false,
"dedupeInjectedDeps": true,
"dedupePeerDependents": true,
"dev": true,
"excludeLinksFromLockfile": false,
"hoistPattern": [
"*"
],
"hoistWorkspacePackages": true,
"injectWorkspacePackages": false,
"linkWorkspacePackages": false,
"nodeLinker": "isolated",
"optional": true,
"preferWorkspacePackages": false,
"production": true,
"publicHoistPattern": []
},
"filteredInstall": false
}
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
.pnpm/spacetimedb@2.1.0/node_modules/spacetimedb
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
.pnpm/typescript@5.9.3/node_modules/typescript
+113
View File
@@ -0,0 +1,113 @@
{
"name": "sdk-test-module",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk-test-module",
"license": "ISC",
"dependencies": {
"fast-text-encoding": "^1.0.0"
},
"devDependencies": {
"@types/fast-text-encoding": "^1.0.3",
"tsup": "^8.1.0"
}
},
"../../node_modules/.pnpm/fast-text-encoding@1.0.6/node_modules/fast-text-encoding": {
"version": "1.0.6",
"license": "Apache-2.0"
},
"../../node_modules/.pnpm/tsup@8.5.0_jiti@2.5.1_postcss@8.5.6_tsx@4.20.4_typescript@5.9.2/node_modules/tsup": {
"version": "8.5.0",
"dev": true,
"license": "MIT",
"dependencies": {
"bundle-require": "^5.1.0",
"cac": "^6.7.14",
"chokidar": "^4.0.3",
"consola": "^3.4.0",
"debug": "^4.4.0",
"esbuild": "^0.25.0",
"fix-dts-default-cjs-exports": "^1.0.0",
"joycon": "^3.1.1",
"picocolors": "^1.1.1",
"postcss-load-config": "^6.0.1",
"resolve-from": "^5.0.0",
"rollup": "^4.34.8",
"source-map": "0.8.0-beta.0",
"sucrase": "^3.35.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.11",
"tree-kill": "^1.2.2"
},
"bin": {
"tsup": "dist/cli-default.js",
"tsup-node": "dist/cli-node.js"
},
"devDependencies": {
"@microsoft/api-extractor": "^7.50.0",
"@rollup/plugin-json": "6.1.0",
"@swc/core": "1.10.18",
"@types/debug": "4.1.12",
"@types/node": "22.13.4",
"@types/resolve": "1.20.6",
"bumpp": "^10.0.3",
"flat": "6.0.1",
"postcss": "8.5.2",
"postcss-simple-vars": "7.0.1",
"prettier": "3.5.1",
"resolve": "1.22.10",
"rollup-plugin-dts": "6.1.1",
"sass": "1.85.0",
"strip-json-comments": "5.0.1",
"svelte": "5.19.9",
"svelte-preprocess": "6.0.3",
"terser": "^5.39.0",
"ts-essentials": "10.0.4",
"tsup": "8.3.6",
"typescript": "5.7.3",
"vitest": "3.0.6",
"wait-for-expect": "3.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@microsoft/api-extractor": "^7.36.0",
"@swc/core": "^1",
"postcss": "^8.4.12",
"typescript": ">=4.5.0"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
},
"@swc/core": {
"optional": true
},
"postcss": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@types/fast-text-encoding": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz",
"integrity": "sha512-bbGJt6IyiuyAhPOX7htQDDzv2bDGJdWyd6X+e1BcdPzU3e5jyjOdB86LoTSoE80faY9v8Wt7/ix3Sp+coRJ03Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-text-encoding": {
"resolved": "../../node_modules/.pnpm/fast-text-encoding@1.0.6/node_modules/fast-text-encoding",
"link": true
},
"node_modules/tsup": {
"resolved": "../../node_modules/.pnpm/tsup@8.5.0_jiti@2.5.1_postcss@8.5.6_tsx@4.20.4_typescript@5.9.2/node_modules/tsup",
"link": true
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "my-spacetime-app",
"license": "ISC",
"type": "module",
"scripts": {
"build": "cargo build -p spacetimedb-standalone && cargo run -p spacetimedb-cli -- build",
"generate-ts": "cargo build -p spacetimedb-standalone && cargo run -p spacetimedb-cli -- generate --lang typescript --out-dir ts-codegen",
"publish": "cargo run -p spacetimedb-cli -- publish"
},
"dependencies": {
"spacetimedb": "^2.1.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
+107
View File
@@ -0,0 +1,107 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
spacetimedb:
specifier: ^2.1.0
version: 2.1.0
devDependencies:
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'}
hasBin: true
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
spacetimedb@2.1.0:
resolution: {integrity: sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==}
peerDependencies:
'@angular/core': '>=17.0.0'
'@tanstack/react-query': ^5.0.0
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
svelte: ^4.0.0 || ^5.0.0
undici: ^6.19.2
vue: ^3.3.0
peerDependenciesMeta:
'@angular/core':
optional: true
'@tanstack/react-query':
optional: true
react:
optional: true
svelte:
optional: true
undici:
optional: true
vue:
optional: true
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
url-polyfill@1.1.14:
resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==}
snapshots:
base64-js@1.5.1: {}
headers-polyfill@4.0.3: {}
object-inspect@1.13.4: {}
prettier@3.8.1: {}
pure-rand@7.0.1: {}
safe-stable-stringify@2.5.0: {}
spacetimedb@2.1.0:
dependencies:
base64-js: 1.5.1
headers-polyfill: 4.0.3
object-inspect: 1.13.4
prettier: 3.8.1
pure-rand: 7.0.1
safe-stable-stringify: 2.5.0
statuses: 2.0.2
url-polyfill: 1.1.14
statuses@2.0.2: {}
typescript@5.9.3: {}
url-polyfill@1.1.14: {}
+430
View File
@@ -0,0 +1,430 @@
import { schema, t, table, SenderError } from 'spacetimedb/server';
const channel_kind = t.enum('ChannelKind', { text: t.unit(), voice: t.unit() });
const user = table(
{
name: 'user',
public: true,
},
{
identity: t.identity().primaryKey(),
name: t.string().optional(),
online: t.bool(),
issuer: t.string().optional(),
subject: t.string().optional(),
username: t.string().optional(), // For creds-based auth
password: t.string().optional(), // For creds-based auth (Note: plain text for MVP)
}
);
const server = table(
{ name: 'server', public: true },
{
id: t.u64().primaryKey().autoInc(),
name: t.string(),
owner: t.identity().optional(),
}
);
const channel = table(
{
name: 'channel',
public: true,
indexes: [
{ accessor: 'by_server_id', algorithm: 'btree', columns: ['server_id'] }
]
},
{
id: t.u64().primaryKey().autoInc(),
server_id: t.u64(),
name: t.string(),
kind: channel_kind,
}
);
const voice_state = table(
{
name: 'voice_state',
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
]
},
{
identity: t.identity().primaryKey(),
channel_id: t.u64(),
}
);
const sdp_offer = table(
{
name: 'sdp_offer',
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
]
},
{
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
}
);
const sdp_answer = table(
{
name: 'sdp_answer',
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
]
},
{
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
}
);
const ice_candidate = table(
{
name: 'ice_candidate',
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
]
},
{
sender: t.identity(),
receiver: t.identity(),
candidate: t.string(),
channel_id: t.u64(),
}
);
const thread = table(
{
name: 'thread',
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
]
},
{
id: t.u64().primaryKey().autoInc(),
channel_id: t.u64(),
parent_message_id: t.u64().unique(),
name: t.string(),
}
);
const message = table(
{
name: 'message',
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] },
{ accessor: 'by_thread_id', algorithm: 'btree', columns: ['thread_id'] }
]
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
sent: t.timestamp(),
text: t.string(),
channel_id: t.u64(),
thread_id: t.u64().optional(),
}
);
const spacetimedb = schema({ user, server, channel, voice_state, sdp_offer, sdp_answer, ice_candidate, thread, message });
export default spacetimedb;
function validateName(name: string) {
if (!name || name.trim().length === 0) throw new SenderError('Names must not be empty');
}
export const set_name = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError('Cannot set name for unknown user');
ctx.db.user.identity.update({ ...user, name });
}
);
export const register = spacetimedb.reducer(
{ username: t.string(), password: t.string() },
(ctx, { username, password }) => {
validateName(username);
if (!password || password.length < 4) throw new SenderError('Password must be at least 4 characters');
for (const u of ctx.db.user.iter()) {
if (u.username === username) {
throw new SenderError('Username already taken');
}
}
const user = ctx.db.user.identity.find(ctx.sender);
if (user) {
ctx.db.user.identity.update({
...user,
username,
password,
name: user.name || username
});
} else {
ctx.db.user.insert({
identity: ctx.sender,
username,
password,
name: username,
online: true,
issuer: undefined,
subject: undefined
});
}
}
);
export const login = spacetimedb.reducer(
{ username: t.string(), password: t.string() },
(ctx, { username, password }) => {
let foundUser = null;
for (const u of ctx.db.user.iter()) {
if (u.username === username && u.password === password) {
foundUser = u;
break;
}
}
if (!foundUser) {
throw new SenderError('Invalid username or password');
}
const currentIdentityUser = ctx.db.user.identity.find(ctx.sender);
if (currentIdentityUser && currentIdentityUser.identity.toHexString() !== foundUser.identity.toHexString()) {
ctx.db.user.identity.delete(ctx.sender);
}
if (foundUser.identity.toHexString() !== ctx.sender.toHexString()) {
ctx.db.user.identity.delete(foundUser.identity);
ctx.db.user.insert({
...foundUser,
identity: ctx.sender,
online: true
});
} else {
ctx.db.user.identity.update({
...foundUser,
online: true
});
}
}
);
export const create_server = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a server');
}
const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'text' } });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'voice' } });
}
);
export const create_channel = spacetimedb.reducer(
{ name: t.string(), serverId: t.u64(), isVoice: t.bool() },
(ctx, { name, serverId, isVoice }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a channel');
}
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError('Server not found');
ctx.db.channel.insert({
id: 0n,
server_id: serverId,
name,
kind: isVoice ? { tag: 'voice' } : { tag: 'text' }
});
}
);
export const join_voice = spacetimedb.reducer(
{ channelId: t.u64() },
(ctx, { channelId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to join voice');
}
const chan = ctx.db.channel.id.find(channelId);
if (!chan || chan.kind.tag !== 'voice') throw new SenderError('Invalid voice channel');
const existing = ctx.db.voice_state.identity.find(ctx.sender);
if (existing) {
if (existing.channel_id !== channelId) {
clearSignalingForUser(ctx, ctx.sender);
ctx.db.voice_state.identity.update({ identity: ctx.sender, channel_id: channelId });
}
} else {
ctx.db.voice_state.insert({ identity: ctx.sender, channel_id: channelId });
}
}
);
export const leave_voice = spacetimedb.reducer((ctx) => {
ctx.db.voice_state.identity.delete(ctx.sender);
clearSignalingForUser(ctx, ctx.sender);
});
export const send_sdp_offer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
ctx.db.sdp_offer.insert({ sender: ctx.sender, receiver, sdp, channel_id: channelId });
}
);
export const send_sdp_answer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
ctx.db.sdp_answer.insert({ sender: ctx.sender, receiver, sdp, channel_id: channelId });
}
);
export const send_ice_candidate = spacetimedb.reducer(
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
(ctx, { receiver, candidate, channelId }) => {
ctx.db.ice_candidate.insert({ sender: ctx.sender, receiver, candidate, channel_id: channelId });
}
);
function clearSignalingForUser(ctx: any, identity: any) {
// Clean up stale signaling messages for the user
// Note: Iterating and deleting might not be the most performant for large tables.
// In a production scenario, consider TTLs or more targeted deletion strategies.
const userOffers = ctx.db.sdp_offer.iter().filter((offer: any) =>
offer.sender.isEqual(identity) || offer.receiver.isEqual(identity)
);
for (const offer of userOffers) {
ctx.db.sdp_offer.delete(offer.id); // Assuming 'id' is the primary key
}
const userAnswers = ctx.db.sdp_answer.iter().filter((answer: any) =>
answer.sender.isEqual(identity) || answer.receiver.isEqual(identity)
);
for (const answer of userAnswers) {
ctx.db.sdp_answer.delete(answer.id); // Assuming 'id' is the primary key
}
const userCandidates = ctx.db.ice_candidate.iter().filter((candidate: any) =>
candidate.sender.isEqual(identity) || candidate.receiver.isEqual(identity)
);
for (const candidate of userCandidates) {
ctx.db.ice_candidate.delete(candidate.id); // Assuming 'id' is the primary key
}
}
export const create_thread = spacetimedb.reducer(
{ name: t.string(), channelId: t.u64(), parentMessageId: t.u64() },
(ctx, { name, channelId, parentMessageId }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a thread');
}
const parentMsg = ctx.db.message.id.find(parentMessageId);
if (!parentMsg) throw new SenderError('Parent message not found');
ctx.db.thread.insert({ id: 0n, channel_id: channelId, parent_message_id: parentMessageId, name });
}
);
export const send_message = spacetimedb.reducer(
{ text: t.string(), channelId: t.u64(), threadId: t.u64().optional() },
(ctx, { text, channelId, threadId }) => {
if (!text || text.trim().length === 0) throw new SenderError('Messages must not be empty');
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to send messages');
}
ctx.db.message.insert({
id: 0n,
sender: ctx.sender,
text,
sent: ctx.timestamp,
channel_id: channelId,
thread_id: threadId,
});
}
);
export const init = spacetimedb.init(ctx => {
let hasServers = false;
for (const _ of ctx.db.server.iter()) {
hasServers = true;
break;
}
if (!hasServers) {
const s = ctx.db.server.insert({ id: 0n, name: 'Spacetime Community', owner: undefined });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'text' } });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'voice' } });
}
});
export const onConnect = spacetimedb.clientConnected(ctx => {
const user = ctx.db.user.identity.find(ctx.sender);
if (ctx.senderAuth.hasJWT && ctx.senderAuth.jwt) {
const jwt = ctx.senderAuth.jwt;
const issuer = jwt.issuer;
const subject = jwt.subject;
const payload = jwt.fullPayload;
const name = (payload.name as string) || (payload.nickname as string) || (payload.preferred_username as string) || (payload.email as string);
if (user) {
ctx.db.user.identity.update({
...user,
online: true,
name: user.name || name,
issuer,
subject
});
} else {
ctx.db.user.insert({
name,
identity: ctx.sender,
online: true,
issuer,
subject,
username: undefined,
password: undefined
});
}
} else if (user) {
ctx.db.user.identity.update({ ...user, online: true });
}
});
export const onDisconnect = spacetimedb.clientDisconnected(ctx => {
const user = ctx.db.user.identity.find(ctx.sender);
if (user) {
ctx.db.user.identity.update({ ...user, online: false });
}
// Auto-leave voice on disconnect
ctx.db.voice_state.identity.delete(ctx.sender);
// Clean up signaling messages associated with the disconnected user
clearSignalingForUser(ctx, ctx.sender);
});
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"strict": true,
"declaration": false,
"emitDeclarationOnly": false,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowImportingTsExtensions": true,
"noImplicitAny": true,
"moduleResolution": "bundler",
"isolatedDeclarations": false,
// This library is ESM-only, do not import commonjs modules
"esModuleInterop": false,
"allowSyntheticDefaultImports": false,
"useDefineForClassFields": true,
// Crucial when using esbuild/swc/babel instead of tsc emit:
"verbatimModuleSyntax": true,
"isolatedModules": true
},
"include": ["src/index.ts", "tests/**/*"],
"exclude": ["node_modules", "**/__tests__/*", "dist/**/*"]
}
+1
View File
@@ -0,0 +1 @@
/module_bindings/** linguist-generated=true
+455
View File
@@ -0,0 +1,455 @@
:root {
--background-primary: #313338;
--background-secondary: #2b2d31;
--background-tertiary: #1e1f22;
--background-accent: #404249;
--channel-sidebar-width: 240px;
--server-sidebar-width: 72px;
--text-normal: #dbdee1;
--text-muted: #949ba4;
--header-primary: #ffffff;
--interactive-normal: #b5bac1;
--interactive-hover: #dbdee1;
--interactive-active: #ffffff;
--brand: #5865f2;
--brand-hover: #4752c4;
}
body {
margin: 0;
padding: 0;
background-color: var(--background-tertiary);
color: var(--text-normal);
font-family: 'gg sans', 'Noto Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
width: 100vw;
}
/* Login Screen */
.login-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background-color: var(--background-tertiary);
gap: 20px;
}
.login-card {
background-color: var(--background-primary);
padding: 32px;
border-radius: 8px;
box-shadow: 0 2px 10px 0 rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
width: 400px;
}
.login-card h1 {
margin: 0;
color: var(--header-primary);
}
.login-card p {
color: var(--text-muted);
text-align: center;
margin: 0;
}
/* Sidebar for Servers (simplified icons) */
.server-sidebar {
width: var(--server-sidebar-width);
background-color: var(--background-tertiary);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 12px;
gap: 8px;
}
.server-icon {
width: 48px;
height: 48px;
background-color: var(--background-accent);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-radius 0.2s, background-color 0.2s;
color: var(--text-normal);
}
.server-icon:hover {
border-radius: 16px;
background-color: var(--brand);
}
.server-icon.active {
border-radius: 16px;
background-color: var(--brand);
}
/* Sidebar for Channels */
.channel-sidebar {
width: var(--channel-sidebar-width);
background-color: var(--background-secondary);
display: flex;
flex-direction: column;
}
.channel-header {
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
box-shadow: 0 1px 0 rgba(0,0,0,0.2);
font-weight: bold;
color: var(--header-primary);
}
.channel-list {
flex: 1;
padding: 8px;
overflow-y: auto;
}
.channel-item {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
color: var(--interactive-normal);
margin-bottom: 2px;
}
.channel-item:hover {
background-color: var(--background-accent);
color: var(--interactive-hover);
}
.channel-item.active {
background-color: var(--background-accent);
color: var(--interactive-active);
}
.channel-item-hash {
margin-right: 6px;
color: var(--text-muted);
font-size: 1.2rem;
}
/* User Profile at bottom of sidebar */
.user-profile {
height: 52px;
background-color: #232428;
display: flex;
align-items: center;
padding: 0 8px;
gap: 8px;
}
.avatar {
width: 32px;
height: 32px;
background-color: #5865f2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
color: white;
}
.user-info {
flex: 1;
overflow: hidden;
}
.username {
font-size: 0.85rem;
font-weight: 600;
color: var(--header-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-status {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Main Chat Area */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--background-primary);
}
.chat-header {
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
box-shadow: 0 1px 0 rgba(0,0,0,0.2);
color: var(--header-primary);
font-weight: bold;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 16px;
}
.message-avatar {
width: 40px;
height: 40px;
background-color: #5865f2;
border-radius: 50%;
margin-top: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.message-content {
flex: 1;
}
.message-meta {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
}
.message-sender {
font-weight: 500;
color: var(--header-primary);
}
.message-timestamp {
font-size: 0.75rem;
color: var(--text-muted);
}
.message-text {
color: var(--text-normal);
line-height: 1.375rem;
word-wrap: break-word;
}
.message-thread-btn {
font-size: 0.75rem;
color: var(--brand);
background: none;
border: none;
cursor: pointer;
padding: 0;
margin-top: 4px;
}
.message-thread-btn:hover {
text-decoration: underline;
}
/* Chat Input */
.chat-input-container {
padding: 0 16px 24px 16px;
}
.chat-input-wrapper {
background-color: #383a40;
border-radius: 8px;
padding: 11px 16px;
display: flex;
align-items: center;
}
.chat-input {
flex: 1;
background: none;
border: none;
color: var(--text-normal);
font-family: inherit;
font-size: 1rem;
outline: none;
}
.chat-input::placeholder {
color: var(--text-muted);
}
/* Right Sidebar (Users/Thread) */
.right-sidebar {
width: 240px;
background-color: var(--background-secondary);
display: flex;
flex-direction: column;
border-left: 1px solid rgba(255,255,255,0.05);
}
.thread-view {
display: flex;
flex-direction: column;
height: 100%;
}
.thread-header {
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 0 rgba(0,0,0,0.2);
font-weight: bold;
}
.close-btn {
background: none;
border: none;
color: var(--interactive-normal);
cursor: pointer;
font-size: 1.2rem;
}
.thread-messages {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.member-list {
padding: 16px 8px;
}
.member-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
color: var(--text-muted);
}
.member-item:hover {
background-color: var(--background-accent);
color: var(--interactive-hover);
}
.member-avatar {
width: 32px;
height: 32px;
background-color: #5865f2;
border-radius: 50%;
}
.member-name {
font-size: 0.9rem;
font-weight: 500;
}
/* Modals/Dialogs */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0,0,0,0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: var(--background-primary);
padding: 24px;
border-radius: 8px;
width: 400px;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-content h2 {
margin: 0;
color: var(--header-primary);
}
.modal-content input {
background-color: var(--background-tertiary);
border: none;
padding: 12px;
border-radius: 4px;
color: var(--text-normal);
outline: none;
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn-primary {
background-color: var(--brand);
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
.btn-secondary {
background-color: transparent;
color: white;
border: none;
padding: 10px 20px;
cursor: pointer;
}
.btn-danger {
background-color: #da373c;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
.btn-danger:hover {
background-color: #a12829;
}
.add-btn {
background: none;
border: none;
color: var(--interactive-normal);
cursor: pointer;
font-size: 1.2rem;
}
+76
View File
@@ -0,0 +1,76 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import App from './App';
import { SpacetimeDBProvider } from 'spacetimedb/react';
import { DbConnection } from './module_bindings';
describe('App Integration Test', () => {
it('connects to the DB, allows name change and message sending', async () => {
const connectionBuilder = DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('quickstart-chat')
.withToken(
localStorage.getItem(
'ws://localhost:3000/quickstart-chat/auth_token'
) || ''
);
render(
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
<App />
</SpacetimeDBProvider>
);
// Initially, we should see "Connecting..."
expect(screen.getByText(/Connecting.../i)).toBeInTheDocument();
// Wait until "Connecting..." is gone (meaning we've connected)
// This might require the actual DB to accept the connection
await waitFor(
() =>
expect(screen.queryByText(/Connecting.../i)).not.toBeInTheDocument(),
{ timeout: 10000 }
);
// The profile section should show the default name or truncated identity
// For example, you can check if the text is rendered.
// If your default identity is something like 'abcdef12' or 'Unknown'
// we do a generic check:
expect(
screen.getByRole('heading', { name: /profile/i })
).toBeInTheDocument();
// Let's change the user's name
const editNameButton = screen.getByText(/Edit Name/i);
await userEvent.click(editNameButton);
const nameInput = screen.getByRole('textbox', { name: /name input/i });
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'TestUser');
const submitNameButton = screen.getByRole('button', { name: /submit/i });
await userEvent.click(submitNameButton);
// If your DB or UI updates instantly, we can check that the new name shows up
await waitFor(
() => {
expect(screen.getByText('TestUser')).toBeInTheDocument();
},
{ timeout: 10000 }
);
// Now let's send a message
const textarea = screen.getByRole('textbox', { name: /message input/i });
await userEvent.type(textarea, 'Hello from GH Actions!');
const sendButton = screen.getByRole('button', { name: /send/i });
await userEvent.click(sendButton);
// Wait for message to appear in the UI
await waitFor(
() => {
expect(screen.getByText('Hello from GH Actions!')).toBeInTheDocument();
},
{ timeout: 10000 }
);
});
});
+653
View File
@@ -0,0 +1,653 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import './App.css';
import { tables, reducers } from './module_bindings';
import type * as Types from './module_bindings/types';
import { useSpacetimeDB, useTable, useReducer } from 'spacetimedb/react';
import { Identity } from 'spacetimedb';
import { useAuth } from "react-oidc-context";
import { TOKEN_KEY } from './main';
const VoiceAudio = ({ stream }: { stream?: MediaStream }) => {
const audioRef = React.useRef<HTMLAudioElement>(null);
useEffect(() => {
if (audioRef.current && stream) {
audioRef.current.srcObject = stream;
}
}, [stream]);
return <audio ref={audioRef} autoPlay />;
};
function App() {
const { identity, isActive: connected } = useSpacetimeDB();
const auth = useAuth();
// State
const [activeServerId, setActiveServerId] = useState<bigint | null>(null);
const [activeChannelId, setActiveChannelId] = useState<bigint | null>(null);
const [activeThreadId, setActiveThreadId] = useState<bigint | null>(null);
const [messageText, setMessageText] = useState('');
const [threadMessageText, setThreadMessageText] = useState('');
const [showCreateServerModal, setShowCreateServerModal] = useState(false);
const [newServerName, setNewServerName] = useState('');
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
const [newChannelName, setNewChannelName] = useState('');
const [isVoiceChannel, setIsVoiceChannel] = useState(false);
const [showSetNameModal, setShowSetNameModal] = useState(false);
const [newName, setNewName] = useState('');
// Credentials Auth State
const [authMode, setAuthMode] = useState<'login' | 'register' | 'oidc'>('oidc');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [authError, setAuthError] = useState('');
// Refs for scrolling
const messagesEndRef = useRef<HTMLDivElement>(null);
const threadMessagesEndRef = useRef<HTMLDivElement>(null);
// Reducers
const setName = useReducer(reducers.setName);
const createServer = useReducer(reducers.createServer);
const createChannel = useReducer(reducers.createChannel);
const createThread = useReducer(reducers.createThread);
const sendMessage = useReducer(reducers.sendMessage);
const register = useReducer(reducers.register);
const login = useReducer(reducers.login);
const joinVoice = useReducer(reducers.joinVoice);
const leaveVoice = useReducer(reducers.leaveVoice);
// Tables
const [servers] = useTable(tables.server);
const [channels] = useTable(tables.channel);
const [users, isUsersReady] = useTable(tables.user);
const [allMessages] = useTable(tables.message);
const [allThreads] = useTable(tables.thread);
const [voiceStates] = useTable(tables.voice_state);
// Initialization
useEffect(() => {
if (!activeServerId && servers.length > 0) {
setActiveServerId(servers[0].id);
}
}, [servers, activeServerId]);
useEffect(() => {
if (activeServerId) {
const serverChannels = channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Text');
if (serverChannels.length > 0) {
if (!activeChannelId || !channels.some(c => c.id === activeChannelId && c.serverId === activeServerId)) {
setActiveChannelId(serverChannels[0].id);
}
}
}
}, [activeServerId, channels, activeChannelId]);
// Derived Data
const activeServer = useMemo(() =>
servers.find(s => s.id === activeServerId),
[servers, activeServerId]
);
const activeChannel = useMemo(() =>
channels.find(c => c.id === activeChannelId),
[channels, activeChannelId]
);
const activeThread = useMemo(() =>
allThreads.find(t => t.id === activeThreadId),
[allThreads, activeThreadId]
);
const textChannels = useMemo(() => {
if (!activeServerId) return [];
return channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Text');
}, [channels, activeServerId]);
const voiceChannels = useMemo(() => {
if (!activeServerId) return [];
return channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Voice');
}, [channels, activeServerId]);
const channelMessages = useMemo(() => {
if (!activeChannelId) return [];
return allMessages
.filter(m => m.channelId === activeChannelId && m.threadId === undefined)
.sort((a, b) => a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1);
}, [allMessages, activeChannelId]);
const threadMessages = useMemo(() => {
if (!activeThreadId) return [];
return allMessages
.filter(m => m.threadId === activeThreadId)
.sort((a, b) => a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1);
}, [allMessages, activeThreadId]);
const currentUser = useMemo(() =>
users.find(u => u.identity.isEqual(identity || Identity.zero())),
[users, identity]
);
const currentVoiceState = useMemo(() =>
voiceStates.find(vs => vs.identity.isEqual(identity || Identity.zero())),
[voiceStates, identity]
);
const connectedVoiceChannel = useMemo(() =>
channels.find(c => c.id === currentVoiceState?.channelId),
[channels, currentVoiceState]
);
const onlineUsers = useMemo(() =>
users.filter(u => u.online),
[users]
);
const isFullyAuthenticated = useMemo(() => {
if (!currentUser) return false;
const hasOidc = !!(currentUser.issuer && currentUser.subject);
const hasCreds = !!(currentUser.username && currentUser.password);
return hasOidc || hasCreds;
}, [currentUser]);
// Auto-scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [channelMessages]);
useEffect(() => {
threadMessagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [threadMessages]);
// Handlers
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!messageText.trim() || !activeChannelId) return;
sendMessage({ text: messageText, channelId: activeChannelId, threadId: undefined });
setMessageText('');
};
const handleSendThreadMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!threadMessageText.trim() || !activeThreadId || !activeChannelId) return;
sendMessage({ text: threadMessageText, channelId: activeChannelId, threadId: activeThreadId });
setThreadMessageText('');
};
const handleCreateServer = (e: React.FormEvent) => {
e.preventDefault();
if (!newServerName.trim()) return;
createServer({ name: newServerName });
setNewServerName('');
setShowCreateServerModal(false);
};
const handleCreateChannel = (e: React.FormEvent) => {
e.preventDefault();
if (!newChannelName.trim() || !activeServerId) return;
createChannel({ name: newChannelName, serverId: activeServerId, isVoice: isVoiceChannel });
setNewChannelName('');
setIsVoiceChannel(false);
setShowCreateChannelModal(false);
};
const handleSetName = (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setName({ name: newName });
setShowSetNameModal(false);
};
const handleStartThread = (msg: Types.Message) => {
const threadName = `Thread on: ${msg.text.substring(0, 20)}...`;
createThread({ name: threadName, channelId: msg.channelId, parentMessageId: msg.id });
};
const handleCredentialsLogin = (e: React.FormEvent) => {
e.preventDefault();
setAuthError('');
login({ username, password })
.catch(err => setAuthError(err.message || 'Login failed'));
};
const handleCredentialsRegister = (e: React.FormEvent) => {
e.preventDefault();
setAuthError('');
register({ username, password })
.then(() => {
setAuthMode('login');
setAuthError('Registration successful! Please log in.');
})
.catch(err => setAuthError(err.message || 'Registration failed'));
};
const handleJoinVoice = (channelId: bigint) => {
joinVoice({ channelId });
};
// --- AUTH FLOW ---
if (auth.isLoading) {
return (
<div className="login-screen">
<div className="login-card">
<h1>Loading Authentication...</h1>
<p>Please wait while we prepare your session.</p>
</div>
</div>
);
}
if (!connected || !identity) {
return (
<div className="login-screen">
<div className="login-card">
<h1>Connecting...</h1>
<p>Establishing connection to SpacetimeDB server.</p>
<div className="avatar" style={{width: '48px', height: '48px', fontSize: '1.2rem'}}>...</div>
</div>
</div>
);
}
if (!isUsersReady) {
return (
<div className="login-screen">
<div className="login-card">
<h1>Loading User Data...</h1>
<p>Fetching your profile from the server.</p>
<div className="avatar" style={{width: '48px', height: '48px', fontSize: '1.2rem'}}>...</div>
</div>
</div>
);
}
const canAccessApp = auth.isAuthenticated || isFullyAuthenticated;
if (!canAccessApp) {
return (
<div className="login-screen">
<div className="login-card">
<h1>Welcome to Spacetime Discord</h1>
<p style={{marginBottom: '10px'}}>Authentication is required to enter the community.</p>
<div style={{width: '100%', display: 'flex', flexDirection: 'column', gap: '20px'}}>
<div style={{display: 'flex', flexDirection: 'column', gap: '10px'}}>
<h3 style={{margin: 0, fontSize: '0.9rem', color: 'var(--text-muted)'}}>FAST TRACK</h3>
<button className="btn-primary" style={{width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px'}} onClick={() => auth.signinRedirect()}>
<span style={{fontSize: '1.2rem'}}>🌐</span> Log In with OIDC
</button>
</div>
<div style={{display: 'flex', alignItems: 'center', width: '100%', gap: '10px'}}>
<div style={{flex: 1, height: '1px', backgroundColor: 'var(--background-accent)'}}></div>
<span style={{fontSize: '0.8rem', color: 'var(--text-muted)'}}>OR USE CREDENTIALS</span>
<div style={{flex: 1, height: '1px', backgroundColor: 'var(--background-accent)'}}></div>
</div>
<form onSubmit={authMode === 'register' ? handleCredentialsRegister : handleCredentialsLogin} style={{width: '100%', display: 'flex', flexDirection: 'column', gap: '16px'}}>
<div style={{display: 'flex', flexDirection: 'column', gap: '8px'}}>
<label style={{fontSize: '0.8rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>USERNAME</label>
<input
autoFocus
className="chat-input"
style={{backgroundColor: 'var(--background-tertiary)', borderRadius: '4px', padding: '10px'}}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div style={{display: 'flex', flexDirection: 'column', gap: '8px'}}>
<label style={{fontSize: '0.8rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>PASSWORD</label>
<input
type="password"
className="chat-input"
style={{backgroundColor: 'var(--background-tertiary)', borderRadius: '4px', padding: '10px'}}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{authError && <div style={{color: '#da373c', fontSize: '0.85rem', textAlign: 'center'}}>{authError}</div>}
<button type="submit" className="btn-secondary" style={{width: '100%', backgroundColor: 'var(--background-accent)'}}>
{authMode === 'register' ? 'Create Account' : 'Log In'}
</button>
<div style={{fontSize: '0.9rem', color: 'var(--text-muted)', textAlign: 'center'}}>
{authMode === 'login' ? "Need an account? " : "Already have an account? "}
<span
style={{color: 'var(--brand)', cursor: 'pointer', fontWeight: 'bold'}}
onClick={() => {
setAuthMode(authMode === 'login' ? 'register' : 'login');
setAuthError('');
}}
>
{authMode === 'login' ? 'Register' : 'Log In'}
</span>
</div>
</form>
</div>
</div>
</div>
);
}
// --- MAIN UI ---
const getUsername = (userIdentity: Identity) => {
const user = users.find(u => u.identity.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
const formatTime = (ts: any) => {
const date = new Date(Number(ts.microsSinceUnixEpoch / 1000n));
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
return (
<div className="app-container">
{/* Server Sidebar */}
<div className="server-sidebar">
{servers.map(server => (
<div
key={server.id.toString()}
className={`server-icon ${activeServerId === server.id ? 'active' : ''}`}
onClick={() => {
setActiveServerId(server.id);
setActiveThreadId(null);
}}
title={server.name}
>
{server.name.substring(0, 2).toUpperCase()}
</div>
))}
<div className="server-icon" onClick={() => setShowCreateServerModal(true)}>+</div>
</div>
{/* Channel Sidebar */}
<div className="channel-sidebar">
<div className="channel-header" style={{justifyContent: 'space-between'}}>
<span style={{overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}>
{activeServer?.name || 'No Server Selected'}
</span>
{activeServer && (
<button className="add-btn" onClick={() => setShowCreateChannelModal(true)}>+</button>
)}
</div>
<div className="channel-list">
<div style={{fontSize: '0.7rem', fontWeight: 'bold', color: 'var(--text-muted)', padding: '8px 8px 4px 8px'}}>TEXT CHANNELS</div>
{textChannels.map(channel => (
<div
key={channel.id.toString()}
className={`channel-item ${activeChannelId === channel.id ? 'active' : ''}`}
onClick={() => {
setActiveChannelId(channel.id);
setActiveThreadId(null);
}}
>
<span className="channel-item-hash">#</span>
{channel.name}
</div>
))}
<div style={{fontSize: '0.7rem', fontWeight: 'bold', color: 'var(--text-muted)', padding: '16px 8px 4px 8px'}}>VOICE CHANNELS</div>
{voiceChannels.map(channel => (
<div key={channel.id.toString()}>
<div
className={`channel-item ${currentVoiceState?.channelId === channel.id ? 'active' : ''}`}
onClick={() => handleJoinVoice(channel.id)}
>
<span className="channel-item-hash">🔊</span>
{channel.name}
</div>
<div style={{marginLeft: '24px', display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px'}}>
{voiceStates.filter(vs => vs.channelId === channel.id).map(vs => {
const peerIdHex = vs.identity.toHexString();
const peer = remotePeers.get(peerIdHex);
const status = peer ? `${peer.connectionState} / ${peer.iceConnectionState}` : 'Connecting...';
return (
<div key={peerIdHex} style={{display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.85rem', color: 'var(--text-muted)'}}>
<div className="avatar" style={{width: '18px', height: '18px', fontSize: '0.5rem'}}>
{getUsername(vs.identity).substring(0, 2).toUpperCase()}
</div>
<span className="member-name">{getUsername(vs.identity)}</span>
<span style={{fontSize: '0.7rem', color: 'var(--text-muted)', marginLeft: 'auto'}}>({status})</span>
</div>
);
})}
</div>
</div>
))}
</div>
{connectedVoiceChannel && (
<div style={{backgroundColor: '#232428', borderBottom: '1px solid rgba(255,255,255,0.05)', padding: '10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
<div style={{display: 'flex', flexDirection: 'column'}}>
<span style={{color: '#23a559', fontSize: '0.8rem', fontWeight: 'bold'}}>Voice Connected</span>
<span style={{color: 'var(--text-muted)', fontSize: '0.75rem'}}>{connectedVoiceChannel.name} / {activeServer?.name}</span>
</div>
<button className="add-btn" style={{color: '#da373c'}} onClick={() => leaveVoice()}>📞</button>
</div>
)}
<div className="user-profile">
<div className="avatar">{getUsername(identity || Identity.zero()).substring(0, 2).toUpperCase()}</div>
<div className="user-info">
<div className="username">{getUsername(identity || Identity.zero())}</div>
<div className="user-status">{isFullyAuthenticated ? 'Online' : 'Unlinked Account'}</div>
</div>
<button className="add-btn" onClick={() => { setNewName(currentUser?.name || ''); setShowSetNameModal(true); }}></button>
</div>
</div>
{/* Main Content */}
<div className="main-content">
<div className="chat-header">
<span style={{color: 'var(--text-muted)', marginRight: '8px'}}>#</span>
{activeChannel?.name || 'Select a channel'}
</div>
<div className="messages-container">
{channelMessages.map(msg => (
<div key={msg.id.toString()} className="message">
<div className="message-avatar">{getUsername(msg.sender).substring(0, 2).toUpperCase()}</div>
<div className="message-content">
<div className="message-meta">
<span className="message-sender">{getUsername(msg.sender)}</span>
<span className="message-timestamp">{formatTime(msg.sent)}</span>
</div>
<div className="message-text">{msg.text}</div>
{(() => {
const existingThread = allThreads.find(t => t.parentMessageId === msg.id);
if (existingThread) {
return (
<button className="message-thread-btn" onClick={() => setActiveThreadId(existingThread.id)}>
View Thread
</button>
);
}
return (
<button className="message-thread-btn" onClick={() => handleStartThread(msg)}>
Start Thread
</button>
);
})()}
{allThreads.filter(t => t.parentMessageId === msg.id).map(t => (
<div
key={t.id.toString()}
style={{marginLeft: '12px', marginTop: '4px', cursor: 'pointer', color: 'var(--brand)'}}
onClick={() => setActiveThreadId(t.id)}
>
{t.name}
</div>
))}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-container">
<form className="chat-input-wrapper" onSubmit={handleSendMessage}>
<input
className="chat-input"
placeholder={isFullyAuthenticated ? `Message #${activeChannel?.name || ''}` : "Log in to chat"}
disabled={!isFullyAuthenticated}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
/>
</form>
</div>
</div>
{/* Right Sidebar */}
<div className="right-sidebar">
{activeThreadId ? (
<div className="thread-view">
<div className="thread-header">
<span>Thread</span>
<button className="close-btn" onClick={() => setActiveThreadId(null)}>×</button>
</div>
<div className="thread-messages">
<div style={{padding: '8px', borderBottom: '1px solid var(--background-accent)', marginBottom: '8px'}}>
<div style={{fontWeight: 'bold'}}>{activeThread?.name}</div>
</div>
{threadMessages.map(msg => (
<div key={msg.id.toString()} className="message" style={{gap: '8px', marginBottom: '8px'}}>
<div className="message-avatar" style={{width: '32px', height: '32px'}}>{getUsername(msg.sender).substring(0, 2).toUpperCase()}</div>
<div className="message-content">
<div className="message-meta">
<span className="message-sender" style={{fontSize: '0.9rem'}}>{getUsername(msg.sender)}</span>
</div>
<div className="message-text" style={{fontSize: '0.9rem'}}>{msg.text}</div>
</div>
</div>
))}
<div ref={threadMessagesEndRef} />
</div>
<div className="chat-input-container" style={{padding: '8px'}}>
<form className="chat-input-wrapper" onSubmit={handleSendThreadMessage}>
<input
className="chat-input"
style={{fontSize: '0.85rem'}}
placeholder={isFullyAuthenticated ? "Reply in thread..." : "Log in to chat"}
disabled={!isFullyAuthenticated}
value={threadMessageText}
onChange={(e) => setThreadMessageText(e.target.value)}
/>
</form>
</div>
</div>
) : (
<div className="member-list">
<div style={{padding: '0 8px 8px 8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>
ONLINE {onlineUsers.length}
</div>
{onlineUsers.map(user => (
<div key={user.identity.toHexString()} className="member-item">
<div className="member-avatar" style={{width: '24px', height: '24px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontSize: '0.7rem'}}>
{(user.name || user.identity.toHexString()).substring(0, 2).toUpperCase()}
</div>
<span className="member-name">{user.name || user.identity.toHexString().substring(0, 8)}</span>
</div>
))}
</div>
)}
</div>
{/* Modals */}
{showCreateServerModal && (
<div className="modal-overlay">
<form className="modal-content" onSubmit={handleCreateServer}>
<h2>Create Server</h2>
<input
autoFocus
placeholder="server-name"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
/>
<div className="modal-buttons">
<button type="button" className="btn-secondary" onClick={() => setShowCreateServerModal(false)}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!isFullyAuthenticated}>Create</button>
</div>
</form>
</div>
)}
{showCreateChannelModal && (
<div className="modal-overlay">
<form className="modal-content" onSubmit={handleCreateChannel}>
<h2>Create Channel</h2>
<input
autoFocus
placeholder="channel-name"
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
/>
<div style={{display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-normal)'}}>
<input
type="checkbox"
checked={isVoiceChannel}
onChange={(e) => setIsVoiceChannel(e.target.checked)}
/>
<label>Voice Channel</label>
</div>
<div className="modal-buttons">
<button type="button" className="btn-secondary" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!isFullyAuthenticated}>Create</button>
</div>
</form>
</div>
)}
{showSetNameModal && (
<div className="modal-overlay">
<form className="modal-content" onSubmit={handleSetName}>
<h2>Account Settings</h2>
<input
autoFocus
placeholder="Display Name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<div style={{fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '8px'}}>
{currentUser?.issuer ? (
<>Signed in via OIDC as: <b>{auth.user?.profile.name || auth.user?.profile.sub}</b></>
) : currentUser?.username ? (
<>Logged in as: <b>{currentUser.username}</b></>
) : (
<>Unlinked Anonymous Account</>
)}
</div>
<div className="modal-buttons" style={{marginTop: '16px', justifyContent: 'space-between'}}>
<button type="button" className="btn-danger" onClick={() => {
if (auth.isAuthenticated) {
auth.signoutRedirect();
}
localStorage.removeItem(TOKEN_KEY);
window.location.reload();
}}>
Sign Out / Reset
</button>
<div style={{display: 'flex', gap: '8px'}}>
<button type="button" className="btn-secondary" onClick={() => setShowSetNameModal(false)}>Cancel</button>
<button type="submit" className="btn-primary">Save</button>
</div>
</div>
</form>
</div>
)}
</div>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+76
View File
@@ -0,0 +1,76 @@
/* ----- CSS Reset & Global Settings ----- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ----- Color Variables ----- */
:root {
--theme-color: #3dc373;
--theme-color-contrast: #08180e;
--textbox-color: #edfef4;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--theme-color: #4cf490;
--theme-color-contrast: #132219;
--textbox-color: #0f311d;
}
}
/* ----- Page Setup ----- */
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
/* ----- Buttons ----- */
button {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 0.375rem;
background-color: var(--theme-color);
color: var(--theme-color-contrast);
cursor: pointer;
font-weight: 600;
letter-spacing: 0.1px;
font-family: monospace;
}
/* ----- Inputs & Textareas ----- */
input,
textarea {
border: none;
border-radius: 0.375rem;
caret-color: var(--theme-color);
font-family: monospace;
font-weight: 600;
letter-spacing: 0.1px;
padding: 0.5rem 0.75rem;
}
input:focus,
textarea:focus {
outline: none;
box-shadow: 0 0 0 2px var(--theme-color);
}
+81
View File
@@ -0,0 +1,81 @@
import { StrictMode, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { Identity } from 'spacetimedb';
import { SpacetimeDBProvider } from 'spacetimedb/react';
import { DbConnection, ErrorContext } from './module_bindings/index.ts';
import { AuthProvider, useAuth } from "react-oidc-context";
const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000';
const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'my-spacetime-app-jdhdg';
export const TOKEN_KEY = `${HOST}/${DB_NAME}/auth_token`;
// OIDC Configuration - User should replace these with their own provider values
const oidcConfig = {
authority: import.meta.env.VITE_OIDC_AUTHORITY ?? "https://accounts.google.com",
client_id: import.meta.env.VITE_OIDC_CLIENT_ID ?? "REPLACE_ME",
redirect_uri: window.location.origin,
scope: "openid profile email",
response_type: "code",
onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname);
}
};
const onConnect = (conn: DbConnection, identity: Identity, token: string) => {
localStorage.setItem(TOKEN_KEY, token);
console.log(
'Connected to SpacetimeDB with identity:',
identity.toHexString()
);
};
const onDisconnect = () => {
console.log('Disconnected from SpacetimeDB');
};
const onConnectError = (_ctx: ErrorContext, err: Error) => {
console.log('Error connecting to SpacetimeDB:', err);
};
function SpacetimeDBWrapper() {
const auth = useAuth();
const connectionBuilder = useMemo(() => {
const builder = DbConnection.builder()
.withUri(HOST)
.withDatabaseName(DB_NAME)
.onConnect(onConnect)
.onDisconnect(onDisconnect)
.onConnectError(onConnectError);
// If we have an OIDC token, use it. Otherwise, use the stored SpacetimeDB token.
if (auth.isAuthenticated && auth.user?.id_token) {
console.log("Connecting with OIDC token");
return builder.withToken(auth.user.id_token);
} else {
const storedToken = localStorage.getItem(TOKEN_KEY);
if (storedToken) {
console.log("Connecting with stored SpacetimeDB token");
return builder.withToken(storedToken);
}
}
return builder;
}, [auth.isAuthenticated, auth.user?.id_token]);
return (
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
<App />
</SpacetimeDBProvider>
);
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider {...oidcConfig}>
<SpacetimeDBWrapper />
</AuthProvider>
</StrictMode>
);
+24
View File
@@ -0,0 +1,24 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
import {
ChannelKind,
} from "./types";
export default __t.row({
id: __t.u64().primaryKey(),
serverId: __t.u64().name("server_id"),
name: __t.string(),
get kind() {
return ChannelKind;
},
});
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
name: __t.string(),
serverId: __t.u64(),
isVoice: __t.bool(),
};
+15
View File
@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
name: __t.string(),
};
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
name: __t.string(),
channelId: __t.u64(),
parentMessageId: __t.u64(),
};
+18
View File
@@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
sender: __t.identity(),
receiver: __t.identity(),
candidate: __t.string(),
channelId: __t.u64().name("channel_id"),
});
+253
View File
@@ -0,0 +1,253 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
/* eslint-disable */
/* tslint:disable */
import {
DbConnectionBuilder as __DbConnectionBuilder,
DbConnectionImpl as __DbConnectionImpl,
SubscriptionBuilderImpl as __SubscriptionBuilderImpl,
TypeBuilder as __TypeBuilder,
Uuid as __Uuid,
convertToAccessorMap as __convertToAccessorMap,
makeQueryBuilder as __makeQueryBuilder,
procedureSchema as __procedureSchema,
procedures as __procedures,
reducerSchema as __reducerSchema,
reducers as __reducers,
schema as __schema,
t as __t,
table as __table,
type AlgebraicTypeType as __AlgebraicTypeType,
type DbConnectionConfig as __DbConnectionConfig,
type ErrorContextInterface as __ErrorContextInterface,
type Event as __Event,
type EventContextInterface as __EventContextInterface,
type Infer as __Infer,
type QueryBuilder as __QueryBuilder,
type ReducerEventContextInterface as __ReducerEventContextInterface,
type RemoteModule as __RemoteModule,
type SubscriptionEventContextInterface as __SubscriptionEventContextInterface,
type SubscriptionHandleImpl as __SubscriptionHandleImpl,
} from "spacetimedb";
// Import all reducer arg schemas
import CreateChannelReducer from "./create_channel_reducer";
import CreateServerReducer from "./create_server_reducer";
import CreateThreadReducer from "./create_thread_reducer";
import JoinVoiceReducer from "./join_voice_reducer";
import LeaveVoiceReducer from "./leave_voice_reducer";
import LoginReducer from "./login_reducer";
import RegisterReducer from "./register_reducer";
import SendIceCandidateReducer from "./send_ice_candidate_reducer";
import SendMessageReducer from "./send_message_reducer";
import SendSdpAnswerReducer from "./send_sdp_answer_reducer";
import SendSdpOfferReducer from "./send_sdp_offer_reducer";
import SetNameReducer from "./set_name_reducer";
// Import all procedure arg schemas
// Import all table schema definitions
import ChannelRow from "./channel_table";
import IceCandidateRow from "./ice_candidate_table";
import MessageRow from "./message_table";
import SdpAnswerRow from "./sdp_answer_table";
import SdpOfferRow from "./sdp_offer_table";
import ServerRow from "./server_table";
import ThreadRow from "./thread_table";
import UserRow from "./user_table";
import VoiceStateRow from "./voice_state_table";
/** Type-only namespace exports for generated type groups. */
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
const tablesSchema = __schema({
channel: __table({
name: 'channel',
indexes: [
{ accessor: 'id', name: 'channel_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_server_id', name: 'channel_server_id_idx_btree', algorithm: 'btree', columns: [
'serverId',
] },
],
constraints: [
{ name: 'channel_id_key', constraint: 'unique', columns: ['id'] },
],
}, ChannelRow),
ice_candidate: __table({
name: 'ice_candidate',
indexes: [
{ accessor: 'by_receiver', name: 'ice_candidate_receiver_idx_btree', algorithm: 'btree', columns: [
'receiver',
] },
],
constraints: [
],
}, IceCandidateRow),
message: __table({
name: 'message',
indexes: [
{ accessor: 'by_channel_id', name: 'message_channel_id_idx_btree', algorithm: 'btree', columns: [
'channelId',
] },
{ accessor: 'id', name: 'message_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_thread_id', name: 'message_thread_id_idx_btree', algorithm: 'btree', columns: [
'threadId',
] },
],
constraints: [
{ name: 'message_id_key', constraint: 'unique', columns: ['id'] },
],
}, MessageRow),
sdp_answer: __table({
name: 'sdp_answer',
indexes: [
{ accessor: 'by_receiver', name: 'sdp_answer_receiver_idx_btree', algorithm: 'btree', columns: [
'receiver',
] },
],
constraints: [
],
}, SdpAnswerRow),
sdp_offer: __table({
name: 'sdp_offer',
indexes: [
{ accessor: 'by_receiver', name: 'sdp_offer_receiver_idx_btree', algorithm: 'btree', columns: [
'receiver',
] },
],
constraints: [
],
}, SdpOfferRow),
server: __table({
name: 'server',
indexes: [
{ accessor: 'id', name: 'server_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
],
constraints: [
{ name: 'server_id_key', constraint: 'unique', columns: ['id'] },
],
}, ServerRow),
thread: __table({
name: 'thread',
indexes: [
{ accessor: 'by_channel_id', name: 'thread_channel_id_idx_btree', algorithm: 'btree', columns: [
'channelId',
] },
{ accessor: 'id', name: 'thread_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'parent_message_id', name: 'thread_parent_message_id_idx_btree', algorithm: 'btree', columns: [
'parentMessageId',
] },
],
constraints: [
{ name: 'thread_id_key', constraint: 'unique', columns: ['id'] },
{ name: 'thread_parent_message_id_key', constraint: 'unique', columns: ['parentMessageId'] },
],
}, ThreadRow),
user: __table({
name: 'user',
indexes: [
{ accessor: 'identity', name: 'user_identity_idx_btree', algorithm: 'btree', columns: [
'identity',
] },
],
constraints: [
{ name: 'user_identity_key', constraint: 'unique', columns: ['identity'] },
],
}, UserRow),
voice_state: __table({
name: 'voice_state',
indexes: [
{ accessor: 'by_channel_id', name: 'voice_state_channel_id_idx_btree', algorithm: 'btree', columns: [
'channelId',
] },
{ accessor: 'identity', name: 'voice_state_identity_idx_btree', algorithm: 'btree', columns: [
'identity',
] },
],
constraints: [
{ name: 'voice_state_identity_key', constraint: 'unique', columns: ['identity'] },
],
}, VoiceStateRow),
});
/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */
const reducersSchema = __reducers(
__reducerSchema("create_channel", CreateChannelReducer),
__reducerSchema("create_server", CreateServerReducer),
__reducerSchema("create_thread", CreateThreadReducer),
__reducerSchema("join_voice", JoinVoiceReducer),
__reducerSchema("leave_voice", LeaveVoiceReducer),
__reducerSchema("login", LoginReducer),
__reducerSchema("register", RegisterReducer),
__reducerSchema("send_ice_candidate", SendIceCandidateReducer),
__reducerSchema("send_message", SendMessageReducer),
__reducerSchema("send_sdp_answer", SendSdpAnswerReducer),
__reducerSchema("send_sdp_offer", SendSdpOfferReducer),
__reducerSchema("set_name", SetNameReducer),
);
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */
const proceduresSchema = __procedures(
);
/** The remote SpacetimeDB module schema, both runtime and type information. */
const REMOTE_MODULE = {
versionInfo: {
cliVersion: "2.1.0" as const,
},
tables: tablesSchema.schemaType.tables,
reducers: reducersSchema.reducersType.reducers,
...proceduresSchema,
} satisfies __RemoteModule<
typeof tablesSchema.schemaType,
typeof reducersSchema.reducersType,
typeof proceduresSchema
>;
/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> = __makeQueryBuilder(tablesSchema.schemaType);
/** The reducers available in this remote SpacetimeDB module. */
export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);
/** The context type returned in callbacks for all possible events. */
export type EventContext = __EventContextInterface<typeof REMOTE_MODULE>;
/** The context type returned in callbacks for reducer events. */
export type ReducerEventContext = __ReducerEventContextInterface<typeof REMOTE_MODULE>;
/** The context type returned in callbacks for subscription events. */
export type SubscriptionEventContext = __SubscriptionEventContextInterface<typeof REMOTE_MODULE>;
/** The context type returned in callbacks for error events. */
export type ErrorContext = __ErrorContextInterface<typeof REMOTE_MODULE>;
/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */
export type SubscriptionHandle = __SubscriptionHandleImpl<typeof REMOTE_MODULE>;
/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<typeof REMOTE_MODULE> {}
/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */
export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */
export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
/** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */
static builder = (): DbConnectionBuilder => {
return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig<typeof REMOTE_MODULE>) => new DbConnection(config));
};
/** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */
override subscriptionBuilder = (): SubscriptionBuilder => {
return new SubscriptionBuilder(this);
};
}
+15
View File
@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
channelId: __t.u64(),
};
+13
View File
@@ -0,0 +1,13 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {};
+16
View File
@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
username: __t.string(),
password: __t.string(),
};
+20
View File
@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.u64().primaryKey(),
sender: __t.identity(),
sent: __t.timestamp(),
text: __t.string(),
channelId: __t.u64().name("channel_id"),
threadId: __t.option(__t.u64()).name("thread_id"),
});
+16
View File
@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
username: __t.string(),
password: __t.string(),
};
+18
View File
@@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64().name("channel_id"),
});
+18
View File
@@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64().name("channel_id"),
});
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
receiver: __t.identity(),
candidate: __t.string(),
channelId: __t.u64(),
};
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
text: __t.string(),
channelId: __t.u64(),
threadId: __t.option(__t.u64()),
};
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
};
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
};
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.u64().primaryKey(),
name: __t.string(),
owner: __t.option(__t.identity()),
});
+15
View File
@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
name: __t.string(),
};
+18
View File
@@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.u64().primaryKey(),
channelId: __t.u64().name("channel_id"),
parentMessageId: __t.u64().name("parent_message_id"),
name: __t.string(),
});
+95
View File
@@ -0,0 +1,95 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export const Channel = __t.object("Channel", {
id: __t.u64(),
serverId: __t.u64(),
name: __t.string(),
get kind() {
return ChannelKind;
},
});
export type Channel = __Infer<typeof Channel>;
// The tagged union or sum type for the algebraic type `ChannelKind`.
export const ChannelKind = __t.enum("ChannelKind", {
Text: __t.unit(),
Voice: __t.unit(),
});
export type ChannelKind = __Infer<typeof ChannelKind>;
export const IceCandidate = __t.object("IceCandidate", {
sender: __t.identity(),
receiver: __t.identity(),
candidate: __t.string(),
channelId: __t.u64(),
});
export type IceCandidate = __Infer<typeof IceCandidate>;
export const Message = __t.object("Message", {
id: __t.u64(),
sender: __t.identity(),
sent: __t.timestamp(),
text: __t.string(),
channelId: __t.u64(),
threadId: __t.option(__t.u64()),
});
export type Message = __Infer<typeof Message>;
export const SdpAnswer = __t.object("SdpAnswer", {
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
});
export type SdpAnswer = __Infer<typeof SdpAnswer>;
export const SdpOffer = __t.object("SdpOffer", {
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
});
export type SdpOffer = __Infer<typeof SdpOffer>;
export const Server = __t.object("Server", {
id: __t.u64(),
name: __t.string(),
owner: __t.option(__t.identity()),
});
export type Server = __Infer<typeof Server>;
export const Thread = __t.object("Thread", {
id: __t.u64(),
channelId: __t.u64(),
parentMessageId: __t.u64(),
name: __t.string(),
});
export type Thread = __Infer<typeof Thread>;
export const User = __t.object("User", {
identity: __t.identity(),
name: __t.option(__t.string()),
online: __t.bool(),
issuer: __t.option(__t.string()),
subject: __t.option(__t.string()),
username: __t.option(__t.string()),
password: __t.option(__t.string()),
});
export type User = __Infer<typeof User>;
export const VoiceState = __t.object("VoiceState", {
identity: __t.identity(),
channelId: __t.u64(),
});
export type VoiceState = __Infer<typeof VoiceState>;
+10
View File
@@ -0,0 +1,10 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import { type Infer as __Infer } from "spacetimedb";
// Import all procedure arg schemas
+34
View File
@@ -0,0 +1,34 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import { type Infer as __Infer } from "spacetimedb";
// Import all reducer arg schemas
import CreateChannelReducer from "../create_channel_reducer";
import CreateServerReducer from "../create_server_reducer";
import CreateThreadReducer from "../create_thread_reducer";
import JoinVoiceReducer from "../join_voice_reducer";
import LeaveVoiceReducer from "../leave_voice_reducer";
import LoginReducer from "../login_reducer";
import RegisterReducer from "../register_reducer";
import SendIceCandidateReducer from "../send_ice_candidate_reducer";
import SendMessageReducer from "../send_message_reducer";
import SendSdpAnswerReducer from "../send_sdp_answer_reducer";
import SendSdpOfferReducer from "../send_sdp_offer_reducer";
import SetNameReducer from "../set_name_reducer";
export type CreateChannelParams = __Infer<typeof CreateChannelReducer>;
export type CreateServerParams = __Infer<typeof CreateServerReducer>;
export type CreateThreadParams = __Infer<typeof CreateThreadReducer>;
export type JoinVoiceParams = __Infer<typeof JoinVoiceReducer>;
export type LeaveVoiceParams = __Infer<typeof LeaveVoiceReducer>;
export type LoginParams = __Infer<typeof LoginReducer>;
export type RegisterParams = __Infer<typeof RegisterReducer>;
export type SendIceCandidateParams = __Infer<typeof SendIceCandidateReducer>;
export type SendMessageParams = __Infer<typeof SendMessageReducer>;
export type SendSdpAnswerParams = __Infer<typeof SendSdpAnswerReducer>;
export type SendSdpOfferParams = __Infer<typeof SendSdpOfferReducer>;
export type SetNameParams = __Infer<typeof SetNameReducer>;
+21
View File
@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
identity: __t.identity().primaryKey(),
name: __t.option(__t.string()),
online: __t.bool(),
issuer: __t.option(__t.string()),
subject: __t.option(__t.string()),
username: __t.option(__t.string()),
password: __t.option(__t.string()),
});
+16
View File
@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
identity: __t.identity().primaryKey(),
channelId: __t.u64().name("channel_id"),
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Identity } from 'spacetimedb';
import { tables } from './module_bindings';
import { useTable } from 'spacetimedb/react';
const [offers] = useTable(tables.sdp_offer);
const [answers] = useTable(tables.sdp_answer);
const [candidates] = useTable(tables.ice_candidate);
export type PeerConnection = {
identity: Identity;
connection: RTCPeerConnection;
stream?: MediaStream;
connectionState: RTCPeerConnectionState;
iceConnectionState: RTCIceConnectionState;
};
export function useWebRTC(
identity: Identity | null,
currentChannelId: bigint | null,
voiceStates: any[]
) {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remotePeers, setRemotePeers] = useState<Map<string, PeerConnection>>(new Map());
const peersRef = useRef<Map<string, PeerConnection>>(new Map());
const localStreamRef = useRef<MediaStream | null>(null);
const iceConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
};
const cleanupPeer = useCallback((peerId: string) => {
const peer = peersRef.current.get(peerId);
if (peer) {
peer.connection.close();
peersRef.current.delete(peerId);
setRemotePeers(new Map(peersRef.current));
}
}, []);
const createPeerConnection = useCallback((peerIdentity: Identity, isOfferer: boolean) => {
const peerId = peerIdentity.toHexString();
if (peersRef.current.has(peerId)) return peersRef.current.get(peerId)!.connection;
const pc = new RTCPeerConnection(iceConfig);
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => {
pc.addTrack(track, localStreamRef.current!);
});
}
pc.onicecandidate = (event) => {
if (event.candidate && currentChannelId) {
try {
sendIceCandidate({
receiver: peerIdentity,
candidate: JSON.stringify(event.candidate),
channelId: currentChannelId
});
} catch (e) {
console.error("Error sending ICE candidate:", e);
}
}
};
pc.ontrack = (event) => {
console.log("Received remote track from", peerId);
const peer = peersRef.current.get(peerId);
if (peer) {
peer.stream = event.streams[0];
setRemotePeers(new Map(peersRef.current));
}
};
pc.onconnectionstatechange = () => {
console.log(`Connection state change for ${peerId}: ${pc.connectionState}`);
const updatedPeer = peersRef.current.get(peerId);
if (updatedPeer) {
updatedPeer.connectionState = pc.connectionState;
setRemotePeers(new Map(peersRef.current));
}
};
pc.oniceconnectionstatechange = () => {
console.log(`ICE connection state change for ${peerId}: ${pc.iceConnectionState}`);
const updatedPeer = peersRef.current.get(peerId);
if (updatedPeer) {
updatedPeer.iceConnectionState = pc.iceConnectionState;
setRemotePeers(new Map(peersRef.current));
}
};
const peerObj: PeerConnection = { identity: peerIdentity, connection: pc, connectionState: pc.connectionState, iceConnectionState: pc.iceConnectionState };
peersRef.current.set(peerId, peerObj);
setRemotePeers(new Map(peersRef.current));
return pc;
}, [currentChannelId]);
useEffect(() => {
if (currentChannelId) {
navigator.mediaDevices.getUserMedia({ audio: true, video: false })
.then(stream => {
setLocalStream(stream);
localStreamRef.current = stream;
})
.catch(err => console.error("Error getting audio stream:", err));
} else {
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(t => t.stop());
localStreamRef.current = null;
setLocalStream(null);
}
peersRef.current.forEach((_, id) => cleanupPeer(id));
}
}, [currentChannelId, cleanupPeer]);
useEffect(() => {
if (!currentChannelId || !identity || !localStream) { // Ensure localStream is available before proceeding
return;
}
voiceStates.forEach(async (vs) => {
if (vs.channelId === currentChannelId && !vs.identity.isEqual(identity)) {
const peerId = vs.identity.toHexString();
if (!peersRef.current.has(peerId)) {
if (identity.toHexString() > peerId) { // Polite peer logic
const pc = createPeerConnection(vs.identity, true);
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendSdpOffer({
receiver: vs.identity,
sdp: JSON.stringify(offer),
channelId: currentChannelId
});
} catch (e) {
console.error("Error creating or sending offer:", e);
}
}
}
}
});
peersRef.current.forEach((_, id) => {
if (!voiceStates.some(vs => vs.identity.toHexString() === id && vs.channelId === currentChannelId)) {
cleanupPeer(id);
}
});
}, [voiceStates, currentChannelId, identity, localStream, createPeerConnection, cleanupPeer]);
useEffect(() => {
if (!currentChannelId || !identity || !localStream) { // Ensure localStream is available before proceeding
return;
}
offers.forEach(async (offerRow) => {
if (offerRow.receiver.isEqual(identity) && offerRow.channelId === currentChannelId) {
const peerId = offerRow.sender.toHexString();
if (!peersRef.current.has(peerId)) {
const pc = createPeerConnection(offerRow.sender, false);
try {
const offer = JSON.parse(offerRow.sdp);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendSdpAnswer({
receiver: offerRow.sender,
sdp: JSON.stringify(answer),
channelId: currentChannelId
});
} catch (e) {
console.error("Error creating or sending answer:", e);
}
}
}
});
}, [offers, currentChannelId, identity, localStream, createPeerConnection]);
useEffect(() => {
if (!currentChannelId || !identity) return;
candidates.forEach(async (candRow) => {
if (candRow.receiver.isEqual(identity) && candRow.channelId === currentChannelId) {
const peerId = candRow.sender.toHexString();
const peer = peersRef.current.get(peerId);
if (peer && peer.connection.remoteDescription) {
try {
const candidate = JSON.parse(candRow.candidate);
await peer.connection.addIceCandidate(new RTCIceCandidate(candidate));
} catch (e) {
console.error("Error adding ICE candidate:", e);
}
}
}
});
}, [candidates, currentChannelId, identity]);
return { localStream, remotePeers };
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import basicSsl from '@vitejs/plugin-basic-ssl';
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
basicSsl(),
],
server: {
https: true,
},
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom', // or "node" if you're not testing DOM
setupFiles: './src/setupTests.ts',
testTimeout: 15_000, // give extra time for real connections
hookTimeout: 15_000,
},
});