diff --git a/docs/.cursor/rules/spacetimedb-csharp.mdc b/docs/.cursor/rules/spacetimedb-csharp.mdc new file mode 100644 index 00000000000..fc074636300 --- /dev/null +++ b/docs/.cursor/rules/spacetimedb-csharp.mdc @@ -0,0 +1,531 @@ +--- +description: "⛔ MANDATORY: Read this ENTIRE file before writing ANY SpacetimeDB C# code. Contains SDK patterns from official documentation." +globs: **/*.cs +alwaysApply: true +--- + +# SpacetimeDB C# SDK + +## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS + +These are **actual errors** observed when LLMs generate SpacetimeDB C# code: + +### 1. Wrong Package Name for Server Modules +```csharp +// ❌ WRONG — this package doesn't exist + + +// ✅ CORRECT — use Runtime for server modules + +``` + +### 2. Lifecycle Hook Names Starting with "On" +```csharp +// ❌ WRONG — will cause STDB0010 error +[SpacetimeDB.Reducer(ReducerKind.ClientConnected)] +public static void OnClientConnected(ReducerContext ctx) { } + +// ✅ CORRECT — no "On" prefix +[SpacetimeDB.Reducer(ReducerKind.ClientConnected)] +public static void ClientConnected(ReducerContext ctx) { } +``` + +### 3. Wrong Timestamp Property Name +```csharp +// ❌ WRONG — property doesn't exist +var micros = timestamp.MicrosecondsSinceEpoch; + +// ✅ CORRECT — full name +var micros = timestamp.MicrosecondsSinceUnixEpoch; +``` + +### 4. Wrong ScheduleAt Syntax +```csharp +// ❌ WRONG — ScheduleAt.Time is not a method +ScheduledAt = ScheduleAt.Time(timestamp) + +// ✅ CORRECT — use constructor syntax +ScheduledAt = new ScheduleAt.Time(new Timestamp(microseconds)) +``` + +### 5. Table Accessor Casing: Server vs Client DIFFER! +```csharp +// Table defined as: +[SpacetimeDB.Table(Name = "user", Public = true)] +public partial class User { ... } + +// SERVER-SIDE (in reducers, using ReducerContext): +// Accessor matches the Name attribute value (lowercase if lowercase) +ctx.Db.user.Insert(...) // ✅ lowercase matches Name="user" + +// CLIENT-SIDE (in app code, using DbConnection): +// Generated bindings use PascalCase properties regardless of Name attribute! +_conn.Db.User.OnInsert += ... // ✅ PascalCase property +_conn.Db.user.OnInsert += ... // ❌ WRONG - lowercase doesn't exist on client! + +// The generated RemoteTables class has PascalCase properties: +// _conn.Db.User, _conn.Db.Canvas, _conn.Db.CanvasMember, etc. +``` + +### 6. Client Callback Signature Errors +```csharp +// ❌ WRONG — guessing at signatures +.OnDisconnect((conn, ctx, err) => ...) +.OnConnectError((ctx, err) => ...) + +// ✅ CORRECT — check actual delegate types (may vary by SDK version) +.OnDisconnect((conn, err) => ...) // DbConnection, Exception? +.OnConnectError(err => ...) // Exception + +// TIP: Let IDE show you the expected signature via autocomplete +``` + +### 7. WithUri Takes String, Not Uri +```csharp +// ❌ WRONG — Uri object not accepted +.WithUri(new Uri("http://localhost:3000")) + +// ✅ CORRECT — use string directly +.WithUri("http://localhost:3000") +``` + +### 8. Missing RuntimeIdentifier for WASM Build +```xml + +net8.0 + + +net8.0 +wasi-wasm +``` + +### 9. Subscribing Before Connected +```csharp +// ❌ WRONG — subscription fails silently, data never arrives +_conn = builder.Build(); +_conn.SubscriptionBuilder().SubscribeToAllTables(); // NOT CONNECTED YET! + +// ✅ CORRECT — subscribe inside OnConnect callback +private void OnConnected(DbConnection conn, Identity identity, string token) +{ + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); // Now connected! +} +``` + +### 10. Nullable Struct Handling with Find() +```csharp +// Find() returns a nullable struct (e.g., User?) +var existing = ctx.Db.User.Identity.Find(ctx.Sender); + +// ❌ WRONG — accessing properties directly on nullable struct +if (existing != null) +{ + ctx.Db.User.Identity.Update(new User { Identity = existing.Identity, ... }); +} + +// ✅ CORRECT — use .Value after null check +if (existing != null) +{ + var user = existing.Value; // Extract the struct value + ctx.Db.User.Identity.Update(new User { Identity = user.Identity, ... }); +} +``` + +### 11. Index Accessor Name Depends on Attribute Placement +```csharp +// OPTION A: Class-level attribute with custom Name +[SpacetimeDB.Table(Name = "Reaction", Public = true)] +[SpacetimeDB.Index.BTree(Name = "idx_reaction_msg", Columns = new[] { "MessageId" })] +public partial struct Reaction { public ulong MessageId; ... } + +// Accessor uses the Index Name: +ctx.Db.Reaction.idx_reaction_msg.Filter(messageId) // Uses index name! + +// OPTION B: Field-level attribute (RECOMMENDED) +[SpacetimeDB.Table(Name = "Reaction", Public = true)] +public partial struct Reaction +{ + [SpacetimeDB.Index.BTree] // On the field itself + public ulong MessageId; +} + +// ✅ Accessor uses the FIELD name: +ctx.Db.Reaction.MessageId.Filter(messageId) // Cleaner! +``` + +--- + +## 1) Table Definition (Server-side Module) + +**Tables use `[SpacetimeDB.Table]` attribute on `partial struct` or `partial class`:** + +```csharp +using SpacetimeDB; + +[SpacetimeDB.Table(Name = "my_table", Public = true)] +public partial struct MyTable +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + + [SpacetimeDB.Unique] + public Identity Owner; + + [SpacetimeDB.Index.BTree] + public string Name; + + public ulong Value; + public Timestamp CreatedAt; +} +``` + +### Column Attributes + +```csharp +[SpacetimeDB.PrimaryKey] // Primary key (auto-indexed) +[SpacetimeDB.AutoInc] // Auto-increment (use with PrimaryKey) +[SpacetimeDB.Unique] // Unique constraint (auto-indexed) +[SpacetimeDB.Index.BTree] // B-Tree index for queries +``` + +### Multi-Column Indexes + +```csharp +[SpacetimeDB.Table(Public = true)] +[SpacetimeDB.Index.BTree(Name = "by_foo_and_bar", Columns = new[] { "Foo", "Bar" })] +public partial struct MyTable +{ + public ulong Foo; + public Timestamp Bar; + // ... +} +``` + +### Table Visibility + +```csharp +// Public table - clients can subscribe +[SpacetimeDB.Table(Name = "my_table", Public = true)] + +// Private table - only reducers can access +[SpacetimeDB.Table(Name = "my_table")] // Public = false is default +``` + +--- + +## 2) Reducers (Server-side Module) + +### Definition Syntax + +```csharp +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.Reducer] + public static void DoSomething(ReducerContext ctx, string param1, ulong param2) + { + // Validate input + if (string.IsNullOrEmpty(param1)) + { + throw new ArgumentException("param1 cannot be empty"); + } + + // Insert returns the inserted row (NOT the ID) + var row = ctx.Db.MyTable.Insert(new MyTable + { + Id = 0, // Auto-increment placeholder + Owner = ctx.Sender, + Name = param1, + Value = param2, + CreatedAt = ctx.Timestamp + }); + + // row.Id now contains the assigned ID + Log.Info($"Created row with ID: {row.Id}"); + } + + [SpacetimeDB.Reducer] + public static void UpdateSomething(ReducerContext ctx, ulong id, string newValue) + { + var row = ctx.Db.MyTable.Id.Find(id); + if (row == null) + { + throw new Exception("Row not found"); + } + + // Update by primary key - must provide complete row + ctx.Db.MyTable.Id.Update(new MyTable + { + Id = row.Id, + Owner = row.Owner, + Name = newValue, // Changed field + Value = row.Value, // Preserve other fields + CreatedAt = row.CreatedAt + }); + } + + [SpacetimeDB.Reducer] + public static void DeleteSomething(ReducerContext ctx, ulong id) + { + // Delete by primary key value + ctx.Db.MyTable.Id.Delete(id); + } +} +``` + +### Update Pattern (CRITICAL) + +```csharp +// ✅ CORRECT — provide complete row with all fields +var existing = ctx.Db.MyTable.Id.Find(id); +ctx.Db.MyTable.Id.Update(new MyTable +{ + Id = existing.Id, + Owner = existing.Owner, + Name = newValue, // Changed + Value = existing.Value, // Preserved + CreatedAt = existing.CreatedAt +}); + +// ❌ WRONG — partial update will null out other fields! +ctx.Db.MyTable.Id.Update(new MyTable { Id = id, Name = newValue }); +``` + +### Lifecycle Hooks + +**⚠️ CRITICAL: Method names must NOT start with "On" — causes STDB0010 error!** + +```csharp +// ✅ CORRECT — no "On" prefix +[SpacetimeDB.Reducer(ReducerKind.ClientConnected)] +public static void ClientConnected(ReducerContext ctx) +{ + // ctx.Sender is the connecting identity + Log.Info($"Client connected: {ctx.Sender}"); +} + +[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)] +public static void ClientDisconnected(ReducerContext ctx) +{ + // Clean up ephemeral data, set offline status, etc. + Log.Info($"Client disconnected: {ctx.Sender}"); +} +``` + +--- + +## 3) Index Access (Server-side) + +### Primary Key / Unique - `.Find()` returns single row or null + +```csharp +// Primary key lookup +var row = ctx.Db.MyTable.Id.Find(id); // Returns MyTable? +var row = ctx.Db.MyTable.Owner.Find(ctx.Sender); // Unique column + +if (row == null) +{ + throw new Exception("Not found"); +} +``` + +### BTree Index - `.Filter()` returns IEnumerable + +```csharp +// Index lookup - returns multiple rows +foreach (var row in ctx.Db.MyTable.SomeColumn.Filter(value)) +{ + // Process each matching row +} + +// Or collect to list +var rows = ctx.Db.MyTable.SomeColumn.Filter(value).ToList(); +``` + +### No Index - `.Iter()` + manual filter + +```csharp +// Full table scan - avoid for large tables +foreach (var row in ctx.Db.MyTable.Iter()) +{ + if (row.Name.StartsWith("A")) + { + // ... + } +} +``` + +--- + +## 4) Client SDK Setup + +```csharp +// Connection pattern +_conn = DbConnection.Builder() + .WithUri("http://localhost:3000") // String, NOT Uri object! + .WithModuleName("my-module") + .WithToken(savedToken) // null for first connection + .OnConnect(OnConnected) + .OnDisconnect((conn, err) => { }) // 2 params + .OnConnectError(err => { }) // 1 param + .Build(); + +// Subscribe in OnConnected callback, NOT before! +private void OnConnected(DbConnection conn, Identity identity, string token) +{ + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); +} +``` + +### ⚠️ CRITICAL: FrameTick + +**You MUST call `FrameTick()` regularly** — without it, no callbacks fire: + +```csharp +while (running) +{ + conn.FrameTick(); + Thread.Sleep(16); // ~60 times per second +} +``` + +--- + +## 5) Row Callbacks (Client-side) + +```csharp +// Register callbacks BEFORE subscribing +_conn.Db.MyTable.OnInsert += (EventContext ctx, MyTable row) => +{ + // Row was inserted +}; + +_conn.Db.MyTable.OnUpdate += (EventContext ctx, MyTable oldRow, MyTable newRow) => +{ + // Row was updated +}; + +_conn.Db.MyTable.OnDelete += (EventContext ctx, MyTable row) => +{ + // Row was deleted +}; + +// Check if event came from our reducer call +if (ctx.Event is Event.Reducer reducerEvent) +{ + if (reducerEvent.ReducerEvent.CallerIdentity == _myIdentity) + { + // This was our call + } +} +``` + +--- + +## 6) Invoking Reducers (Client-side) + +```csharp +// Reducers are called as methods on conn.Reducers +_conn.Reducers.DoSomething("value", 123); + +// Register callback for reducer completion +_conn.Reducers.OnDoSomething += (ctx) => +{ + if (ctx.Event.Status is Status.Committed) + { + // Success + } + else if (ctx.Event.Status is Status.Failed failed) + { + // Failed: failed.Item contains error + } +}; +``` + +--- + +## 7) Timestamps + +### Server-side + +```csharp +// Use ctx.Timestamp for current time +ctx.Db.MyTable.Insert(new MyTable +{ + // ... + CreatedAt = ctx.Timestamp +}); + +// Never use DateTime.Now - it's non-deterministic! +``` + +### Client-side + +```csharp +// Timestamp has MicrosecondsSinceUnixEpoch property (NOT MicrosecondsSinceEpoch!) +var dateTime = DateTimeOffset.FromUnixTimeMilliseconds( + row.CreatedAt.MicrosecondsSinceUnixEpoch / 1000 +).LocalDateTime; +``` + +--- + +## 8) Scheduled Tables + +```csharp +// Server-side: Define scheduled table +[SpacetimeDB.Table(Scheduled = "process_job")] +public partial struct ScheduledJob +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong ScheduledId; + + public SpacetimeDB.ScheduleAt ScheduledAt; + public ulong TargetId; // Your custom data +} + +// The scheduled reducer receives the row +[SpacetimeDB.Reducer] +public static void ProcessJob(ReducerContext ctx, ScheduledJob job) +{ + // job.TargetId available + // Row is auto-deleted after reducer completes + Log.Info($"Processing: {job.TargetId}"); +} + +// Schedule a job +[SpacetimeDB.Reducer] +public static void ScheduleJob(ReducerContext ctx, ulong targetId, ulong delayMs) +{ + var futureTime = ctx.Timestamp.MicrosecondsSinceUnixEpoch + (long)(delayMs * 1000); + ctx.Db.scheduled_job.Insert(new ScheduledJob // snake_case table name! + { + ScheduledId = 0, + ScheduledAt = new ScheduleAt.Time(new Timestamp(futureTime)), // constructor syntax! + TargetId = targetId + }); +} +``` + +--- + +## 9) Hard Requirements + +**C#-specific:** + +1. **Tables MUST be `partial struct` or `partial class`** — required for code generation +2. **Reducers MUST be `static` methods** — instance methods won't work +3. **MUST call `FrameTick()` regularly** — callbacks won't fire otherwise +4. **Use `ctx.Timestamp`** — never `DateTime.Now` or `DateTime.UtcNow` in reducers +5. **Wait for `OnApplied` before accessing `Db`** — tables are empty until subscription applies +6. **Server uses lowercase table accessors** — `ctx.Db.user` matches `Name="user"` +7. **Client uses PascalCase table accessors** — `_conn.Db.User` (generated bindings) +8. **Lifecycle hook names must NOT start with "On"** — causes STDB0010 error +9. **DO NOT edit generated bindings** — regenerate with `spacetime generate` \ No newline at end of file diff --git a/docs/.cursor/rules/spacetimedb-rust.mdc b/docs/.cursor/rules/spacetimedb-rust.mdc new file mode 100644 index 00000000000..51da70c8e2c --- /dev/null +++ b/docs/.cursor/rules/spacetimedb-rust.mdc @@ -0,0 +1,463 @@ +--- +description: "⛔ MANDATORY: Read this ENTIRE file before writing ANY SpacetimeDB Rust code. Contains SDK patterns from official documentation." +globs: **/*.rs +alwaysApply: true +--- + +# SpacetimeDB Rust SDK + +## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS + +These are **actual errors** observed when LLMs generate SpacetimeDB Rust code: + +### 1. Wrong Crate for Server vs Client + +```rust +// ❌ WRONG — using client crate for server module +use spacetimedb_sdk::*; // This is for CLIENTS only! + +// ✅ CORRECT — use spacetimedb for server modules +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +``` + +### 2. Wrong Table Macro Syntax + +```rust +// ❌ WRONG — using attribute-style like C# +#[spacetimedb::table] +#[primary_key] +pub struct User { ... } + +// ✅ CORRECT — use #[table(...)] macro with options +#[table(name = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + name: Option, +} +``` + +### 3. Wrong Table Access Pattern + +```rust +// ❌ WRONG — using ctx.Db or ctx.db() method +ctx.Db.user.Insert(...); +ctx.db().user().insert(...); + +// ✅ CORRECT — ctx.db is a field, table names are methods +ctx.db.user().insert(User { ... }); +ctx.db.user().identity().find(ctx.sender); +``` + +### 4. Wrong Update Pattern + +```rust +// ❌ WRONG — partial update or using .update() directly on table +ctx.db.user().update(User { name: Some("new".into()), ..Default::default() }); + +// ✅ CORRECT — find existing, spread it, update via primary key accessor +if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { name: Some("new".into()), ..user }); +} +``` + +### 5. Wrong Reducer Return Type + +```rust +// ❌ WRONG — returning data from reducer +#[reducer] +pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option { ... } + +// ✅ CORRECT — reducers return Result<(), String> or nothing +#[reducer] +pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> { + if value.is_empty() { + return Err("Value cannot be empty".to_string()); + } + // ... do work ... + Ok(()) +} +``` + +### 6. Wrong Client Connection Pattern + +```rust +// ❌ WRONG — subscribing before connected +let conn = DbConnection::builder().build()?; +conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET! + +// ✅ CORRECT — subscribe in on_connect callback +DbConnection::builder() + .on_connect(|conn, identity, token| { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); + }) + .build()?; +``` + +### 7. Forgetting to Advance the Connection + +```rust +// ❌ WRONG — connection never processes messages +let conn = DbConnection::builder().build()?; +// ... callbacks never fire ... + +// ✅ CORRECT — must call one of these to process messages +conn.run_threaded(); // Spawn background thread +// OR +conn.run_async().await; // Async task +// OR (in game loop) +conn.frame_tick()?; // Manual polling +``` + +### 8. Missing Table Trait Import + +```rust +// ❌ WRONG — "no method named `insert` found" +use spacetimedb::{table, reducer, ReducerContext}; +ctx.db.user().insert(...); // ERROR! + +// ✅ CORRECT — import Table trait for table methods +use spacetimedb::{table, reducer, Table, ReducerContext}; +ctx.db.user().insert(...); // Works! +``` + +### 9. Wrong ScheduleAt Variant + +```rust +// ❌ WRONG — At variant doesn't exist +scheduled_at: ScheduleAt::At(future_time), + +// ✅ CORRECT — use Time variant +scheduled_at: ScheduleAt::Time(future_time), +``` + +### 10. Identity to String Conversion + +```rust +// ❌ WRONG — to_hex() returns HexString<32>, not String +let id: String = identity.to_hex(); // Type mismatch! + +// ✅ CORRECT — chain .to_string() +let id: String = identity.to_hex().to_string(); +``` + +### 11. Timestamp Duration Extraction + +```rust +// ❌ WRONG — returns Result, not Duration directly +let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros(); + +// ✅ CORRECT — unwrap the Result +let micros = ctx.timestamp.to_duration_since_unix_epoch() + .unwrap_or_default() + .as_micros(); +``` + +### 12. Borrow After Move + +```rust +// ❌ WRONG — `tool` moved into struct, then borrowed +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if tool == "eraser" { ... } // ERROR: value moved! + +// ✅ CORRECT — check before move, or use clone +let is_eraser = tool == "eraser"; +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if is_eraser { ... } +``` + +### 13. Client SDK Uses Blocking I/O + +The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor. + +--- + +## 1) Server Module — Table Definition + +**Tables use `#[table(...)]` macro on `pub struct`:** + +> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc. + +```rust +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; + +#[table(name = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + + #[unique] + username: Option, + + online: bool, +} + +#[table(name = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + sender: Identity, + text: String, + sent: Timestamp, +} +``` + +### Table Options + +```rust +#[table(name = my_table)] // Private table (default) +#[table(name = my_table, public)] // Public table - clients can subscribe +``` + +### Column Attributes + +```rust +#[primary_key] // Primary key (auto-indexed, enables .find()) +#[auto_inc] // Auto-increment (use with #[primary_key]) +#[unique] // Unique constraint (auto-indexed) +#[index(btree)] // B-Tree index for queries +``` + +### Scheduled Tables + +```rust +use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp}; + +#[table(name = cleanup_job, scheduled(cleanup_expired))] +pub struct CleanupJob { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + + scheduled_at: ScheduleAt, + target_id: u64, +} + +#[reducer] +pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) { + // Job row is auto-deleted after reducer completes + log::info!("Cleaning up: {}", job.target_id); +} + +// Schedule a job +#[reducer] +pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) { + let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms); + ctx.db.cleanup_job().insert(CleanupJob { + scheduled_id: 0, // auto-inc placeholder + scheduled_at: ScheduleAt::Time(future_time), + target_id, + }); +} +``` + +--- + +## 2) Server Module — Reducers + +### Definition Syntax + +```rust +use spacetimedb::{reducer, ReducerContext}; + +#[reducer] +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { + // Validate input + if text.is_empty() { + return Err("Message cannot be empty".to_string()); + } + + // Insert returns the inserted row + let row = ctx.db.message().insert(Message { + id: 0, // auto-inc placeholder + sender: ctx.sender, + text, + sent: ctx.timestamp, + }); + + log::info!("Message {} sent by {:?}", row.id, ctx.sender); + Ok(()) +} +``` + +### Update Pattern (CRITICAL) + +```rust +#[reducer] +pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { + // Find existing row + let user = ctx.db.user().identity().find(ctx.sender) + .ok_or("User not found")?; + + // ✅ CORRECT — spread existing row, override specific fields + ctx.db.user().identity().update(User { + name: Some(name), + ..user // Preserves identity, online, etc. + }); + + Ok(()) +} + +// ❌ WRONG — partial update nulls out other fields! +// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() }); +``` + +### Delete Pattern + +```rust +#[reducer] +pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> { + // Delete by primary key value + ctx.db.message().id().delete(message_id); + Ok(()) +} +``` + +### Lifecycle Hooks + +```rust +#[reducer(client_connected)] +pub fn client_connected(ctx: &ReducerContext) { + // ctx.sender is the connecting identity + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + // Returning user - set online + ctx.db.user().identity().update(User { online: true, ..user }); + } else { + // New user - create record + ctx.db.user().insert(User { + identity: ctx.sender, + username: None, + online: true, + }); + } +} + +#[reducer(client_disconnected)] +pub fn client_disconnected(ctx: &ReducerContext) { + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); + } +} +``` + +--- + +## 3) Server Module — Index Access + +### Primary Key / Unique — `.find()` returns `Option` + +```rust +// Primary key lookup +let user = ctx.db.user().identity().find(ctx.sender); + +// Unique column lookup +let user = ctx.db.user().username().find(&"alice".to_string()); + +if let Some(user) = user { + // Found +} +``` + +### BTree Index — `.filter()` returns iterator + +```rust +#[table(name = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + #[index(btree)] + room_id: u64, + + text: String, +} + +// Filter by indexed column +for msg in ctx.db.message().room_id().filter(&room_id) { + // Process each message in room +} +``` + +### No Index — `.iter()` + manual filter + +```rust +// Full table scan +for user in ctx.db.user().iter() { + if user.online { + // Process online users + } +} +``` + +--- + +## 4) Client SDK + +```rust +// Connection pattern +let conn = DbConnection::builder() + .with_uri("http://localhost:3000") + .with_module_name("my-module") + .with_token(load_saved_token()) // None for first connection + .on_connect(on_connected) + .build() + .expect("Failed to connect"); + +// Subscribe in on_connect callback, NOT before! +fn on_connected(conn: &DbConnection, identity: Identity, token: &str) { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); +} +``` + +### ⚠️ CRITICAL: Advance the Connection + +**You MUST call one of these** — without it, no callbacks fire: + +```rust +conn.run_threaded(); // Background thread (simplest) +conn.run_async().await; // Async task +conn.frame_tick()?; // Manual polling (game loops) +``` + +### Table Access & Callbacks + +```rust +// Iterate +for user in ctx.db.user().iter() { ... } + +// Find by primary key +if let Some(user) = ctx.db.user().identity().find(&identity) { ... } + +// Row callbacks +ctx.db.user().on_insert(|ctx, user| { ... }); +ctx.db.user().on_update(|ctx, old, new| { ... }); +ctx.db.user().on_delete(|ctx, user| { ... }); + +// Call reducers +ctx.reducers.set_name("Alice".to_string()).unwrap(); +``` + +--- + +## 5) Hard Requirements + +**Rust-specific:** + +1. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk` +2. **Tables MUST use `#[table(...)]` macro** — not attribute-style macros +3. **Reducers take `&ReducerContext` as first argument** — must be a reference +4. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers +5. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()` +6. **Subscribe in `on_connect` callback** — not before connection is established +7. **Update requires full row** — spread existing row with `..existing` +8. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +9. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc. +10. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()` +11. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes diff --git a/docs/.cursor/rules/spacetimedb-typescript.mdc b/docs/.cursor/rules/spacetimedb-typescript.mdc new file mode 100644 index 00000000000..7437db9e8b1 --- /dev/null +++ b/docs/.cursor/rules/spacetimedb-typescript.mdc @@ -0,0 +1,488 @@ +--- +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" | + +### 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 + +// 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 +```typescript +// At end of schema.ts +export const spacetimedb = schema(Table1, Table2, Table3); +``` + +--- + +## 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 +```typescript +import { spacetimedb } from './schema'; +import { t, SenderError } from 'spacetimedb/server'; + +spacetimedb.reducer('reducer_name', { 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); +}); +``` + +### 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: `spacetimedb.reducer('do_something', ...)` +- 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 +// Scheduled table MUST use scheduledId and scheduledAt columns +export const CleanupJob = table({ + name: 'cleanup_job', + scheduled: 'run_cleanup' // reducer name +}, { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + targetId: t.u64(), // Your custom data +}); + +// Scheduled reducer receives full row as arg +spacetimedb.reducer('run_cleanup', { 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. + +```typescript +// Private table (no public: true) +export const PrivateData = table( + { name: 'private_data' }, + { id: t.u64().primaryKey().autoInc(), ownerId: t.identity(), secret: t.string() } +); + +// Public view filtered by sender — use spacetimedb.view() for identity-aware views +spacetimedb.view( + { name: 'my_data', public: true }, + t.array(t.row('MyDataRow', { id: t.u64(), secret: t.string() })), + (ctx) => [...ctx.db.privateData.iter()] + .filter(row => row.ownerId.toHexString() === ctx.sender.toHexString()) + .map(row => ({ id: row.id, secret: row.secret })) +); + +// Use spacetimedb.anonymousView() for views that don't depend on caller identity +spacetimedb.anonymousView( + { name: 'public_stats', public: true }, + t.array(t.row('StatsRow', { count: t.u64() })), + (ctx) => [{ count: BigInt([...ctx.db.privateData.iter()].length) }] +); +``` + +**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) + .withModuleName(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) Hard Requirements + +**TypeScript-specific:** + +1. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args +2. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb` +3. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +4. **Indexes go in OPTIONS (1st arg)** — not in COLUMNS (2nd arg) of `table()` +5. **Use BigInt for u64/i64 fields** — `0n`, `1n`, not `0`, `1` diff --git a/docs/.cursor/rules/spacetimedb.mdc b/docs/.cursor/rules/spacetimedb.mdc new file mode 100644 index 00000000000..c6342b79e81 --- /dev/null +++ b/docs/.cursor/rules/spacetimedb.mdc @@ -0,0 +1,40 @@ +--- +description: "⛔ MANDATORY: Core SpacetimeDB concepts (all languages)." +globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx,**/*.rs,**/*.cs +alwaysApply: true +--- +# SpacetimeDB Rules (All Languages) + +## 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 + +--- + +## 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 + +--- + +## 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 diff --git a/tools/llm-oneshot/.cursor/rules/benchmark.mdc b/tools/llm-oneshot/.cursor/rules/benchmark.mdc new file mode 100644 index 00000000000..c0e938f08be --- /dev/null +++ b/tools/llm-oneshot/.cursor/rules/benchmark.mdc @@ -0,0 +1,126 @@ +--- +description: Rules for executing benchmark prompts. Ensures clean, uncontaminated code generation for fair AI comparison. +globs: apps/**/* +--- + +# Benchmark Generation Rules + +## ⛔ CRITICAL: No Cross-Contamination + +When executing prompts from any `apps/*/prompts/` folder: + +### NEVER read or reference: +- Other timestamped implementation folders (e.g., `app-name-YYYYMMDD-HHMMSS/`) +- Any folder under `apps/*///` containing previously generated code +- Any folder under `apps/*/staging/` containing other implementations +- Any other AI-generated implementations in the workspace + +These folders contain other AI-generated code. Looking at them contaminates the benchmark and invalidates results. + +### ONLY use these sources: +1. **The prompt itself** — The specific `.md` file being executed +2. **Official documentation** — `docs/docs/**/*.md` +3. **Language/framework rules** — `.cursor/rules/*.mdc` +4. **Test harness** — `apps/*/test-harness/` (for understanding test expectations, not implementations) + +## Prompt Execution Process + +### Project Location + +All new benchmark apps must be created in the **staging** folder: +``` +apps//staging////app-YYYYMMDD-HHMMSS/ +``` + +Example: `apps/chat-app/staging/typescript/opus-4-5/spacetime/chat-app-20260108-120000/` + +Use the test harness to scaffold: +```bash +cd apps/chat-app/test-harness +npm run create -- --lang=typescript --llm=opus-4-5 --backend=spacetime +``` + +### For SpacetimeDB Apps (5-Phase Workflow) + +**Phase 1: Backend** +1. Read the prompt file completely +2. Read relevant documentation (never existing implementations) +3. Create timestamped folder in `staging////` +4. Write backend code (`schema.ts`, reducers, `index.ts`) +5. Install backend dependencies: `cd && npm install` +6. Publish to SpacetimeDB: `spacetime publish --project-path ` + +**Phase 2: Bindings** +7. Generate client bindings: `spacetime generate --lang typescript --out-dir /src/module_bindings --project-path ` + +**Phase 3: Client** +8. Write client code (imports from `./module_bindings` now resolve) +9. Implement ONLY the features listed in the prompt +10. Do NOT add features not explicitly requested + +**Phase 4: Verify** +11. Install client dependencies: `cd && npm install` +12. Type-check: `npx tsc --noEmit` +13. Build: `npm run build` +14. **Both must pass before proceeding** + +**Phase 5: Deploy** +15. Ask to deploy when verification passes + +### For PostgreSQL Apps + +1. Read the prompt file completely +2. Read relevant documentation (never existing implementations) +3. Create timestamped folder as specified in the prompt +4. Implement ONLY the features listed in the prompt +5. Do NOT add features not explicitly requested +6. Do NOT add "nice to have" improvements +7. Ask to deploy when done + +## Feature Scope + +- Implement exactly what the prompt requests — nothing more, nothing less +- Do not carry over patterns from other sessions or existing code +- Each generation must be independent and uncontaminated +- If uncertain about a feature, implement the minimal interpretation + +## Implementation Patterns + +### Database Time in Containers +When using containerized databases (Docker), calculate time-based expiration entirely within the database using its native functions (e.g., `NOW() + INTERVAL '60 seconds'`) rather than passing timestamps from JavaScript. Container and host clocks may differ significantly. + +--- + +## Running the Test Harness + +**After deploying a benchmark app, ALWAYS ask:** + +> "The app is running. Would you like me to run the benchmark test harness?" + +**If yes:** +```bash +cd apps//test-harness +npm install +npx playwright install chromium + +# Use --level=N matching the prompt (path is in staging folder) +CLIENT_URL=http://localhost:5173 npm run benchmark -- ../staging////-YYYYMMDD-HHMMSS/ --level=N +``` + +**Prompt level mapping (for chat-app):** +| Prompt | Level | +|--------|-------| +| `01_*_basic` | `--level=1` | +| `05_*_edit_history` | `--level=5` | +| `08_*_threading` | `--level=8` | +| `12_*_anonymous` | `--level=12` | + +## After Grading + +After grading is complete, ask to promote: + +> "Grading complete! Would you like me to promote this app from staging to the final location?" + +```bash +npm run promote -- ../staging////-YYYYMMDD-HHMMSS/ +``` diff --git a/tools/llm-oneshot/.cursor/rules/deployment.mdc b/tools/llm-oneshot/.cursor/rules/deployment.mdc new file mode 100644 index 00000000000..34f78eb8414 --- /dev/null +++ b/tools/llm-oneshot/.cursor/rules/deployment.mdc @@ -0,0 +1,376 @@ +--- +description: Deployment commands and workflow for SpacetimeDB and PostgreSQL apps. +globs: +--- + +# Deployment Guide + +> **Last updated:** 2026-01-08 + +This file covers deployment workflows for benchmark apps in this repository (SpacetimeDB and PostgreSQL). + +--- + +## 0) Folder Structure & Staging Workflow + +All new benchmark apps should be created in the **staging** folder: + +``` +apps/chat-app/ +├── staging/ # ← CREATE NEW APPS HERE +│ ├── typescript/ +│ │ └── / # e.g., opus-4-5, gpt-5, gemini-3-pro +│ │ └── spacetime|postgres/ +│ │ └── chat-app-YYYYMMDD-HHMMSS/ +│ ├── rust/ +│ └── csharp/ +│ +├── typescript/ # ← GRADED apps get promoted here +├── rust/ +├── csharp/ +├── prompts/ +└── test-harness/ +``` + +### Creating New Projects + +Use the test harness to scaffold new projects: + +```bash +cd apps/chat-app/test-harness +npm run create -- --lang=typescript --llm=opus-4-5 --backend=spacetime +``` + +This creates: `staging/typescript/opus-4-5/spacetime/chat-app-YYYYMMDD-HHMMSS/` + +### After Grading: Promote to Final Location + +```bash +npm run promote -- ../staging/typescript/opus-4-5/spacetime/chat-app-YYYYMMDD-HHMMSS/ +``` + +This verifies `GRADING_RESULTS.md` exists and moves to `typescript/opus-4-5/spacetime/...` + +--- + +## 1) Agent Workflow Rules + +| Wrong | Right | +|-------|-------| +| Create app in `typescript/llm/...` directly | Create app in `staging/typescript/llm/...` first | +| Write backend AND client, then deploy | Write backend, publish, generate bindings, THEN write client | +| Create files, list commands, stop | Create files, verify, **ASK** to deploy, **RUN** commands if yes | +| Deploy benchmark app, stop | Deploy, **ASK** to run benchmark test harness | +| `spacetime publish` without `npm install` | Run `npm install` in backend first | +| Write client code before bindings exist | Generate bindings first so imports resolve correctly | + +**After creating AND VERIFYING project files, ALWAYS ask:** + +> "Would you like me to deploy this? +> - **Local** — Deploy locally +> - **Cloud/Docker** — Deploy to cloud or containers +> - **Skip** — Skip deployment" + +**Then execute if they say yes.** Don't just list commands. + +--- + +## 2) Running Dev Servers (CRITICAL for Cursor AI) + +**Run dev servers with `is_background: true`** in Cursor AI context. Long-running foreground commands will time out. + +Check the terminals folder files to verify servers started successfully (look for "ready" messages). + +--- + +## 3) SpacetimeDB Development Workflow (5 Phases) + +SpacetimeDB apps MUST follow this phased workflow to ensure type safety: + +``` +Phase 1: Backend → Phase 2: Bindings → Phase 3: Client → Phase 4: Verify → Phase 5: Deploy +``` + +### Why This Order Matters + +Client code imports from generated `module_bindings/`. If you write client code BEFORE generating bindings: +- Imports won't resolve +- Type errors won't be caught +- You're guessing at generated types + +**Generate bindings FIRST, then write client code against real types.** + +--- + +### Phase 1: Write and Publish Backend + +1. Create backend files (`schema.ts`, reducers, `index.ts`) +2. Install dependencies and publish: + +```bash +cd && npm install +spacetime publish --project-path +``` + +**Module naming:** Use the timestamped folder name (e.g., `chat-app-20260106-180327`) for unique module names. + +--- + +### Phase 2: Generate Bindings + +```bash +spacetime generate --lang typescript --out-dir /src/module_bindings --project-path +``` + +This creates the `module_bindings/` folder with: +- `DbConnection` class +- Table types (e.g., `User`, `Message`) +- `tables` object for `useTable()` +- Reducer type signatures + +--- + +### Phase 3: Write Client Code + +NOW write client code. Imports from `./module_bindings` will resolve correctly: + +```ts +import { DbConnection, tables, User, Message } from './module_bindings'; +``` + +TypeScript will catch type errors because bindings actually exist. + +--- + +### Phase 4: Verify Client Compiles + +Before deployment, verify the client builds successfully: + +```bash +cd && npm install +npx tsc --noEmit # Type-check only +npm run build # Full production build +``` + +**Both must pass.** If either fails, fix the errors before deploying. + +--- + +### Phase 5: Deploy (Run Dev Servers) + +Only after verification passes: + +```bash +cd && npm run dev +``` + +**Note:** If bindings were generated in Phase 2, skip regeneration in deployment. + +--- + +## 4) SpacetimeDB Deployment Commands + +### 4.1 Port Configuration (CRITICAL) + +**SpacetimeDB local server uses port 3000 by default.** + +| Service | Port | +|---------|------| +| SpacetimeDB server | 3000 (WebSocket) | +| Vite client | 5173 (or 5174 if 5173 in use) | + +**NEVER set Vite to port 3000** — it will conflict with SpacetimeDB. + +In `vite.config.ts`: +```ts +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, // NOT 3000! + }, +}); +``` + +### 4.2 Docker Deployment (RECOMMENDED) + +```bash +# 1. Create docker-compose.yml +version: '3.8' +services: + spacetimedb: + image: clockworklabs/spacetimedb-standalone:latest + ports: + - "3000:3000" + volumes: + - spacetimedb-data:/stdb +volumes: + spacetimedb-data: + +# 2. Clean up and start fresh +docker-compose down -v +docker-compose up -d + +# 3. Wait for ready, then add server +spacetime server add docker http://localhost:3000 --no-fingerprint +spacetime server set-default docker + +# 4. Run Phase 1-4 if not already done (see workflow above) +# If bindings already exist from Phase 2, skip step 6 + +# 5. Publish module (use echo y to auto-confirm) +echo y | spacetime publish --clear-database --project-path + +# 6. Generate bindings (skip if already done in Phase 2) +spacetime generate --lang typescript --out-dir /src/module_bindings --project-path + +# 7. Install and run client (background) +cd && npm install && npm run dev +``` + +### 4.3 Local Deployment (Alternative) + +```bash +# 1. Check local server +spacetime server ping local + +# If not running, start in background: +spacetime start + +# 2. Publish module (use echo y to auto-confirm) +echo y | spacetime publish --clear-database --project-path + +# 3. Generate bindings (skip if already done in Phase 2) +spacetime generate --lang typescript --out-dir /src/module_bindings --project-path + +# 4. Install and run client (background) +cd && npm install && npm run dev +``` + +### 4.4 Maincloud Deployment + +Use URI: `wss://maincloud.spacetimedb.com` + +Same commands as local, but publishing goes to maincloud automatically if configured. + +### 4.5 Kill Port Before Dev Server + +Add to client `package.json`: +```json +"scripts": { + "kill-port": "npx kill-port 5173 2>nul || true", + "dev": "npm run kill-port && vite" +} +``` + +--- + +## 5) PostgreSQL Deployment + +### 5.1 Docker Deployment (Recommended) + +1. **Clean up previous deployments** — Stop containers and remove volumes: + ```bash + docker-compose down -v + ``` + +2. **Use unique JWT secret per deployment** — Include timestamp: + ```yaml + JWT_SECRET: my-app-YYYYMMDD-HHMMSS-secret + ``` + +3. **Build and run:** + ```bash + docker-compose up --build -d + ``` + +4. **Verify:** Check logs for schema push: + ```bash + docker-compose logs server + ``` + +### 5.2 Local Deployment + +```bash +# 1. Check PostgreSQL is running +pg_isready + +# 2. Create database if needed + +# 3. Server (background) +cd && npm install && npm run db:push && npm run dev + +# 4. Client (background, separate terminal) +cd && npm install && npm run dev +``` + +### 5.3 Kill Port Before Dev Server + +Add to client `package.json`: +```json +"scripts": { + "kill-port": "npx kill-port 5174 2>nul || true", + "dev": "npm run kill-port && vite" +} +``` + +### 5.4 Default Ports + +- PostgreSQL: 5432 +- Server API: 3001 +- Client: 5174 + +--- + +## 6) C# SpacetimeDB Deployment + +### 6.1 GUI Framework + +**Use .NET MAUI for C# GUI clients** — Microsoft's recommended cross-platform framework. + +| Framework | Use Case | +|-----------|----------| +| **.NET MAUI** | ✅ Desktop/mobile GUI apps (RECOMMENDED) | +| Console | Backend tools, CLI utilities only | +| WPF/WinForms | ❌ DO NOT USE (legacy, Windows-only) | + +### 6.2 Backend Module Build + +C# modules require WASI-WASM compilation: + +```bash +# 1. Add global.json for .NET 8 +echo '{"sdk":{"version":"8.0.319","rollForward":"latestFeature"}}' > backend/global.json + +# 2. Build WASM module +cd backend && dotnet publish -c Release + +# 3. Publish to SpacetimeDB +spacetime publish -c=always -y --bin-path bin/Release/net8.0/wasi-wasm/AppBundle/backend.wasm +``` + +### 6.3 Generate C# Bindings + +```bash +spacetime generate --lang csharp --out-dir client/module_bindings --bin-path backend/bin/Release/net8.0/wasi-wasm/AppBundle/backend.wasm +``` + +### 6.4 MAUI Client Setup + +```bash +# Create MAUI project +dotnet new maui -n client +cd client +dotnet add package SpacetimeDB.ClientSDK +``` + +### 6.5 Run MAUI Client + +```bash +# Windows +dotnet build -f net8.0-windows10.0.19041.0 +dotnet run -f net8.0-windows10.0.19041.0 + +# macOS +dotnet build -f net8.0-maccatalyst +dotnet run -f net8.0-maccatalyst +``` diff --git a/tools/llm-oneshot/.cursor/rules/frontend-csharp.mdc b/tools/llm-oneshot/.cursor/rules/frontend-csharp.mdc new file mode 100644 index 00000000000..356ddbaa6b2 --- /dev/null +++ b/tools/llm-oneshot/.cursor/rules/frontend-csharp.mdc @@ -0,0 +1,140 @@ +--- +description: "GUI framework choice for C# applications in this project" +globs: **/*.cs, **/*.xaml +alwaysApply: false +--- + +# C# GUI Framework — Use MAUI + +When building a **GUI application** in C# for this project, use **.NET MAUI**. + +## Setup + +```bash +# Install MAUI workload +dotnet workload install maui-windows + +# Create MAUI app +dotnet new maui -n MyClient +cd MyClient +dotnet add package SpacetimeDB.ClientSDK +``` + +## Project File (Windows-only) + +```xml + + + net8.0-windows10.0.19041.0 + WinExe + MyClient + true + true + None + false + true + enable + enable + + + + + + + + +``` + +## MAUI XAML Gotchas + +```xml + + + + + + + +