initial commit
This commit is contained in:
@@ -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 }`
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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 }`
|
||||||
@@ -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 }`
|
||||||
@@ -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
|
||||||
@@ -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 }`
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
Vendored
+86
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -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>
|
||||||
Vendored
+1
@@ -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
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2993
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"run": "pnpm run dev"
|
||||||
|
},
|
||||||
|
"server": "maincloud",
|
||||||
|
"database": "my-spacetime-app-jdhdg",
|
||||||
|
"module-path": "./spacetimedb"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"database": "my-spacetime-app-jdhdg"
|
||||||
|
}
|
||||||
Vendored
+6385
File diff suppressed because one or more lines are too long
+21
@@ -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
|
||||||
+21
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
.pnpm/spacetimedb@2.1.0/node_modules/spacetimedb
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
.pnpm/typescript@5.9.3/node_modules/typescript
|
||||||
Generated
+113
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+107
@@ -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: {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/module_bindings/** linguist-generated=true
|
||||||
+455
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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 |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
Generated
+24
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||||
|
});
|
||||||
Generated
+253
@@ -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
@@ -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
@@ -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 {};
|
||||||
Generated
+16
@@ -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(),
|
||||||
|
};
|
||||||
Generated
+20
@@ -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"),
|
||||||
|
});
|
||||||
Generated
+16
@@ -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(),
|
||||||
|
};
|
||||||
Generated
+18
@@ -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"),
|
||||||
|
});
|
||||||
Generated
+18
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||||
|
};
|
||||||
Generated
+17
@@ -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()),
|
||||||
|
});
|
||||||
Generated
+15
@@ -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(),
|
||||||
|
};
|
||||||
Generated
+18
@@ -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(),
|
||||||
|
});
|
||||||
Generated
+95
@@ -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>;
|
||||||
|
|
||||||
Generated
+10
@@ -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
|
||||||
|
|
||||||
|
|
||||||
Generated
+34
@@ -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>;
|
||||||
|
|
||||||
Generated
+21
@@ -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()),
|
||||||
|
});
|
||||||
Generated
+16
@@ -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"),
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user