diff --git a/.gitignore b/.gitignore index 7ea9a05..51183cd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ Cargo.lock # --- SpacetimeDB Local State --- .spacetime/ +spacetime.local.json # --- IDEs / Editors --- .vscode/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 059808d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,840 +0,0 @@ -# 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 --module-path - -# Clear and republish -spacetime publish --clear-database -y --module-path - -# Generate client bindings -spacetime generate --lang --out-dir --module-path - -# View logs -spacetime logs -``` - ---- - -## Deployment - -- Maincloud is the spacetimedb hosted cloud and the default location for module publishing -- The default server marked by \*\*\* in `spacetime server list` should be used when publishing -- If the default server is maincloud you should publish to maincloud -- Publishing to maincloud is free of charge -- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/ -- The database owner can view utilization and performance metrics on the dashboard - ---- - -## Debugging Checklist - -1. Is SpacetimeDB server running? (`spacetime start`) -2. Is the module published? (`spacetime publish`) -3. Are client bindings generated? (`spacetime generate`) -4. Check server logs for errors (`spacetime logs `) -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/svelte`, `./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 | -| `` | `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-path - -# Clear database and republish -spacetime publish --clear-database -y --module-path - -# Generate bindings -spacetime generate --lang typescript --out-dir /src/module_bindings --module-path - -# View logs -spacetime logs -``` - ---- - -## 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 }` diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 5dd564c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,840 +0,0 @@ -# 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 --module-path - -# Clear and republish -spacetime publish --clear-database -y --module-path - -# Generate client bindings -spacetime generate --lang --out-dir --module-path - -# View logs -spacetime logs -``` - ---- - -## Deployment - -- Maincloud is the spacetimedb hosted cloud and the default location for module publishing -- The default server marked by \*\*\* in `spacetime server list` should be used when publishing -- If the default server is maincloud you should publish to maincloud -- Publishing to maincloud is free of charge -- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/ -- The database owner can view utilization and performance metrics on the dashboard - ---- - -## Debugging Checklist - -1. Is SpacetimeDB server running? (`spacetime start`) -2. Is the module published? (`spacetime publish`) -3. Are client bindings generated? (`spacetime generate`) -4. Check server logs for errors (`spacetime logs `) -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 | -| `` | `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.ts → Provider, connection setup -src/App.svelte → UI components -src/config.ts → MODULE_NAME, SPACETIMEDB_URI -``` - ---- - -## 11) Commands - -```bash -# Start local server -spacetime start - -# Publish module -spacetime publish --module-path - -# Clear database and republish -spacetime publish --clear-database -y --module-path - -# Generate bindings -spacetime generate --lang typescript --out-dir /src/module_bindings --module-path - -# View logs -spacetime logs -``` - ---- - -## 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 }` diff --git a/README.md b/README.md index 4240f2a..0ecb571 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,32 @@ -# Zep +# Ditchcord (Zep) -Zep is a self-hosted, real-time communication platform built with Svelte 5 and SpacetimeDB. It provides text, voice, and video messaging functionality designed for small groups and private communities. +Ditchcord is a sovereign, self-hosted communication platform designed for people who want to own their data without sacrificing a modern chat experience. Built with **Svelte 5** and **SpacetimeDB**, it provides text, voice, and video messaging for private communities, teams, and groups of friends. -## Core Tenets +> **Stop renting your community.** Ditchcord puts your data back where it belongs: on your hardware, under your control. -* **Low Dependencies:** We strive to keep the project's dependency graph as lean as possible, favoring native web capabilities over third-party libraries for better long-term maintainability and security. -* **Frictionless Deployment:** Self-hosting should be trivial. Getting your own Zep server running must require the lowest number of steps possible (ideally just one command). -* **Small Scale Focus:** The project is intended to be deployed amongst a small group of friends or team members. It is not currently designed to scale to massive numbers of concurrent users, although achieving higher scalability remains a stretch goal. -* **Low Idle Resource Usage:** Don't take up a bunch of resources when you're not even using the service +## Why Ditchcord? -## Data Ownership & Privacy +* **Data Sovereignty:** You own the database. No middleman, no telemetry, and no "AI training" on your private conversations. +* **Frictionless Hosting:** Deploy your own instance in minutes. We favor a "one-command" philosophy to make self-hosting accessible to everyone. +* **Modern Performance:** Experience instant channel switching and fluid reactivity powered by Svelte 5 Runes and a relational WASM backend. +* **Low Idle Resources:** Ditchcord is designed to be lean. It won't eat your RAM or CPU while you aren't using it. +* **Secure by Default:** Built-in identity management and support for PGP-encrypted messaging. -* **Self-Hosted:** The application is designed to be hosted by the user. Data is stored in a SpacetimeDB instance controlled by the host. -* **Data Isolation:** Data is not used for user profiling or advertising. -* **Hosting Options:** Compatible with local SpacetimeDB instances or SpacetimeDB Maincloud. +## Core Features -## Core Functionality +* **Universal Communication:** High-fidelity voice channels, screen sharing, and rich text messaging with threading. +* **Private Communities:** Create private servers that require unique, secure invite links to join. +* **Client-Side Speed:** Aggressive denormalization and keyed lookups ensure O(1) performance even as your history grows. +* **Rich Media:** Built-in image management with automatic memory cleanup and custom emoji support. +* **Cross-Platform:** Access via any modern web browser or as a native desktop application via Tauri. -* **Communication:** Text channels, voice channels, and threaded conversations. -* **Performance:** Uses a client-side cache (`recent_message` table) for immediate channel loading with lazy-loading for deep message history. -* **WebRTC Integration:** Peer-to-peer audio and screen sharing using SpacetimeDB for signaling. -* **Customization:** User profiles (avatars, banners, bios), server icons, and multiple CSS-based themes. -* **Media Support:** Built-in image uploads and custom emoji support. - -## Technology Stack +## The Technology Stack +Ditchcord is built on a "Lean & Fast" philosophy: * **Frontend:** Svelte 5 (Runes), TypeScript, Vite, Vanilla CSS. -* **Backend:** SpacetimeDB (TypeScript WASM module). -* **Communications:** WebRTC. -* **Desktop:** Native app support via Tauri. +* **Backend:** [SpacetimeDB](https://spacetimedb.com) — a relational database and server-side logic engine compiled to WASM. +* **Voice/Video:** Peer-to-peer WebRTC mesh for zero-latency communication. +* **Desktop:** Native system integration via Tauri. --- @@ -42,14 +40,16 @@ Zep is a self-hosted, real-time communication platform built with Svelte 5 and S --- -### One-Step Deployment (Recommended) +### One-Command Deployment (Recommended) -The easiest way to get Zep running locally is using Docker. This command builds and starts both the frontend and the SpacetimeDB module in a single step. +The fastest way to take control of your data is using Docker. This builds and starts both the frontend and your private SpacetimeDB instance in a single step. ```bash pnpm run deploy:local ``` +Once running, navigate to `http://localhost:5173` to start your community. + --- ## Local Development (Manual Setup) diff --git a/src/chat/services/navigation.svelte.ts b/src/chat/services/navigation.svelte.ts index ee1d218..2619f7a 100644 --- a/src/chat/services/navigation.svelte.ts +++ b/src/chat/services/navigation.svelte.ts @@ -9,6 +9,7 @@ export class NavigationService { activeThreadId = $state(null); pendingThreadParentMessageId = $state(null); lastActiveDmId = $state(null); + isRestored = $state(false); #db: DatabaseService; #identity: () => Identity | null; @@ -17,6 +18,53 @@ export class NavigationService { this.#db = db; this.#identity = identity; + // Restore last active state once DB is ready and identity is known + $effect(() => { + const myId = this.#identity()?.toHexString(); + const dbReady = this.#db.isReady; + if (!myId || !dbReady || this.isRestored) return; + + const saved = localStorage.getItem(`zep_nav_${myId}`); + if (saved) { + try { + const { serverId, channelId } = JSON.parse(saved); + untrack(() => { + if (serverId) { + const bigServerId = BigInt(serverId); + // Verify user is still in this server + if (this.joinedServerIds.has(bigServerId)) { + this.activeServerId = bigServerId; + if (channelId) { + this.activeChannelId = BigInt(channelId); + } + } + } else if (channelId) { + // It was a DM + this.activeChannelId = BigInt(channelId); + } + this.isRestored = true; + }); + } catch (e) { + console.error("[Navigation] Failed to restore state:", e); + this.isRestored = true; + } + } else { + this.isRestored = true; + } + }); + + // Save active state when it changes + $effect(() => { + const myId = this.#identity()?.toHexString(); + if (!myId || !this.isRestored) return; + + const state = { + serverId: this.activeServerId?.toString(), + channelId: this.activeChannelId?.toString() + }; + localStorage.setItem(`zep_nav_${myId}`, JSON.stringify(state)); + }); + let prevChannelId = this.activeChannelId; $effect(() => { const currentChannelId = this.activeChannelId;