Initial commit
I asked claude to scaffold a project. I also made changes to it afterwards but they were mostly in getting workflows and testing stuff.
This commit is contained in:
907
docs/superpowers/plans/2026-04-14-scaffold.md
Normal file
907
docs/superpowers/plans/2026-04-14-scaffold.md
Normal file
@@ -0,0 +1,907 @@
|
||||
# Oxyde Scaffold Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Scaffold the full Rust backend structure for the Oxyde Tauri app — SurrealDB state, error handling, models, and command stubs for user auth and chat with LIVE query support.
|
||||
|
||||
**Architecture:** Single `Arc<Surreal<Client>>` connection held in Tauri managed state (`AppState`). Commands are thin wrappers over internal async fns that return `Result<T, AppError>`, converted to `Result<T, String>` at the command boundary. LIVE query subscriptions are spawned as `tokio` tasks, tracked in `AppState.subscriptions`, and cancelled on demand.
|
||||
|
||||
**Tech Stack:** Rust, Tauri v2, SurrealDB 2.x (WebSocket), `thiserror`, `tokio`, `uuid`, SvelteKit (Svelte 5), pnpm
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `src-tauri/Cargo.toml` | Modify | Add surrealdb, tokio, thiserror, uuid deps |
|
||||
| `src-tauri/src/error.rs` | Create | `AppError` enum + `into_err` helper |
|
||||
| `src-tauri/src/models.rs` | Create | `User`, `Room`, `Message`, `Contact` structs |
|
||||
| `src-tauri/src/db.rs` | Create | `AppState` struct + `init_db()` |
|
||||
| `src-tauri/src/commands/mod.rs` | Create | `pub mod user; pub mod chat;` |
|
||||
| `src-tauri/src/commands/user.rs` | Create | Auth + profile + contacts command stubs |
|
||||
| `src-tauri/src/commands/chat.rs` | Create | Chat + room + LIVE subscription command stubs |
|
||||
| `src-tauri/src/lib.rs` | Modify | Wire `AppState`, register all commands, remove `greet` |
|
||||
| `surreal/schema.surql` | Create | `DEFINE TABLE` + `DEFINE FIELD` for all tables |
|
||||
| `surreal/auth.surql` | Create | `DEFINE ACCESS account` Record Auth |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Cargo dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/Cargo.toml`
|
||||
|
||||
- [ ] **Step 1: Add dependencies**
|
||||
|
||||
Replace the `[dependencies]` section in `src-tauri/Cargo.toml` with:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
surrealdb = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
Expected: no errors (warnings about unused imports are fine at this stage).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/Cargo.toml src-tauri/Cargo.lock
|
||||
git commit -m "chore: add surrealdb, tokio, thiserror, uuid deps"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `error.rs`
|
||||
|
||||
**Files:**
|
||||
- Create: `src-tauri/src/error.rs`
|
||||
- Test: `src-tauri/src/error.rs` (inline `#[cfg(test)]` module)
|
||||
|
||||
- [ ] **Step 1: Write failing test first**
|
||||
|
||||
Create `src-tauri/src/error.rs` with the test only:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_error_converts_to_string() {
|
||||
let e = AppError::Auth("bad credentials".to_string());
|
||||
let s: String = into_err(e);
|
||||
assert_eq!(s, "Auth error: bad credentials");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_error_converts_to_string() {
|
||||
let e = AppError::NotFound("room".to_string());
|
||||
let s: String = into_err(e);
|
||||
assert_eq!(s, "Not found: room");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to confirm it fails**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo test error
|
||||
```
|
||||
|
||||
Expected: FAIL — `AppError` and `into_err` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement `error.rs`**
|
||||
|
||||
Add the full implementation above the test module:
|
||||
|
||||
```rust
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Database error: {0}")]
|
||||
Db(#[from] surrealdb::Error),
|
||||
|
||||
#[error("Auth error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Subscription error: {0}")]
|
||||
Subscription(String),
|
||||
}
|
||||
|
||||
impl From<AppError> for String {
|
||||
fn from(e: AppError) -> Self {
|
||||
e.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert any error that's Into<AppError> into the String Tauri commands require.
|
||||
/// Usage: `.map_err(into_err)`
|
||||
pub fn into_err<E: Into<AppError>>(e: E) -> String {
|
||||
e.into().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_error_converts_to_string() {
|
||||
let e = AppError::Auth("bad credentials".to_string());
|
||||
let s: String = into_err(e);
|
||||
assert_eq!(s, "Auth error: bad credentials");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_error_converts_to_string() {
|
||||
let e = AppError::NotFound("room".to_string());
|
||||
let s: String = into_err(e);
|
||||
assert_eq!(s, "Not found: room");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo test error
|
||||
```
|
||||
|
||||
Expected: 2 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/error.rs
|
||||
git commit -m "feat: add AppError with thiserror and into_err helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `models.rs`
|
||||
|
||||
**Files:**
|
||||
- Create: `src-tauri/src/models.rs`
|
||||
- Test: `src-tauri/src/models.rs` (inline `#[cfg(test)]` module)
|
||||
|
||||
- [ ] **Step 1: Write failing test first**
|
||||
|
||||
Create `src-tauri/src/models.rs` with only the test:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn message_serializes_body() {
|
||||
let json = serde_json::json!({
|
||||
"id": { "tb": "message", "id": { "String": "abc" } },
|
||||
"room": { "tb": "room", "id": { "String": "r1" } },
|
||||
"author": { "tb": "user", "id": { "String": "u1" } },
|
||||
"body": "hello",
|
||||
"created": "2026-01-01T00:00:00Z"
|
||||
});
|
||||
let msg: Result<Message, _> = serde_json::from_value(json);
|
||||
// We only check it can round-trip; SurrealDB Thing format may vary.
|
||||
// This test confirms the struct compiles and has expected fields.
|
||||
assert!(msg.is_ok() || msg.is_err()); // structural compile check
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to confirm it fails**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo test models
|
||||
```
|
||||
|
||||
Expected: FAIL — `Message` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement `models.rs`**
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
use surrealdb::sql::{Datetime, Thing};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: Thing,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub avatar: Option<String>,
|
||||
pub created: Datetime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Room {
|
||||
pub id: Thing,
|
||||
pub name: String,
|
||||
pub created: Datetime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub id: Thing,
|
||||
pub room: Thing,
|
||||
pub author: Thing,
|
||||
pub body: String,
|
||||
pub created: Datetime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Contact {
|
||||
pub id: Thing,
|
||||
pub owner: Thing,
|
||||
pub target: Thing,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn message_serializes_body() {
|
||||
let json = serde_json::json!({
|
||||
"id": { "tb": "message", "id": { "String": "abc" } },
|
||||
"room": { "tb": "room", "id": { "String": "r1" } },
|
||||
"author": { "tb": "user", "id": { "String": "u1" } },
|
||||
"body": "hello",
|
||||
"created": "2026-01-01T00:00:00Z"
|
||||
});
|
||||
let msg: Result<Message, _> = serde_json::from_value(json);
|
||||
assert!(msg.is_ok() || msg.is_err()); // structural compile check
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to confirm it passes**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo test models
|
||||
```
|
||||
|
||||
Expected: 1 test passes.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/models.rs
|
||||
git commit -m "feat: add User, Room, Message, Contact models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create `db.rs`
|
||||
|
||||
**Files:**
|
||||
- Create: `src-tauri/src/db.rs`
|
||||
|
||||
- [ ] **Step 1: Create `db.rs`**
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use surrealdb::engine::remote::ws::{Client, Ws};
|
||||
use surrealdb::Surreal;
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
pub struct AppState {
|
||||
/// Long-lived authenticated WebSocket connection to SurrealDB.
|
||||
pub db: Arc<Surreal<Client>>,
|
||||
/// JWT token from Record Auth signin. Used to re-authenticate on reconnect.
|
||||
pub token: Mutex<Option<String>>,
|
||||
/// Active LIVE query tasks keyed by their SurrealDB LIVE query UUID.
|
||||
/// Abort a handle + KILL the query to clean up.
|
||||
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// Connect to SurrealDB over WebSocket and select namespace/database.
|
||||
/// Call once at app startup before managing state.
|
||||
pub async fn init_db(url: &str, ns: &str, db: &str) -> Result<Surreal<Client>, AppError> {
|
||||
let client = Surreal::new::<Ws>(url).await?;
|
||||
client.use_ns(ns).use_db(db).await?;
|
||||
Ok(client)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
Expected: no errors. (lib.rs will have errors about missing modules until Task 8 — that is fine.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/db.rs
|
||||
git commit -m "feat: add AppState and init_db for SurrealDB WS connection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create `commands/mod.rs`
|
||||
|
||||
**Files:**
|
||||
- Create: `src-tauri/src/commands/mod.rs`
|
||||
|
||||
- [ ] **Step 1: Create the commands directory and `mod.rs`**
|
||||
|
||||
```rust
|
||||
pub mod chat;
|
||||
pub mod user;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
Expected: errors about `chat` and `user` modules not found — that is expected until Tasks 6 and 7.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/commands/mod.rs
|
||||
git commit -m "chore: add commands module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create `commands/user.rs`
|
||||
|
||||
**Files:**
|
||||
- Create: `src-tauri/src/commands/user.rs`
|
||||
|
||||
- [ ] **Step 1: Create `commands/user.rs`**
|
||||
|
||||
```rust
|
||||
use tauri::State;
|
||||
|
||||
use crate::db::AppState;
|
||||
use crate::error::{into_err, AppError};
|
||||
use crate::models::{Contact, User};
|
||||
|
||||
/// Create a new user account via SurrealDB Record Auth SIGNUP.
|
||||
/// Returns the created User record.
|
||||
#[tauri::command]
|
||||
pub async fn signup(
|
||||
state: State<'_, AppState>,
|
||||
email: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<User, String> {
|
||||
let credentials = surrealdb::opt::auth::Record {
|
||||
access: "account",
|
||||
namespace: "oxyde",
|
||||
database: "oxyde",
|
||||
params: serde_json::json!({
|
||||
"email": email,
|
||||
"username": username,
|
||||
"password": password,
|
||||
}),
|
||||
};
|
||||
let token: surrealdb::opt::auth::Jwt = state.db.signup(credentials).await.map_err(into_err)?;
|
||||
*state.token.lock().unwrap() = Some(token.as_insecure_token().to_string());
|
||||
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT * FROM user WHERE email = $email")
|
||||
.bind(("email", email))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("user after signup".into())))
|
||||
}
|
||||
|
||||
/// Authenticate an existing user via SurrealDB Record Auth SIGNIN.
|
||||
/// Returns the JWT token string.
|
||||
#[tauri::command]
|
||||
pub async fn signin(
|
||||
state: State<'_, AppState>,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<String, String> {
|
||||
let credentials = surrealdb::opt::auth::Record {
|
||||
access: "account",
|
||||
namespace: "oxyde",
|
||||
database: "oxyde",
|
||||
params: serde_json::json!({
|
||||
"email": email,
|
||||
"password": password,
|
||||
}),
|
||||
};
|
||||
let token: surrealdb::opt::auth::Jwt = state.db.signin(credentials).await.map_err(into_err)?;
|
||||
let token_str = token.as_insecure_token().to_string();
|
||||
*state.token.lock().unwrap() = Some(token_str.clone());
|
||||
Ok(token_str)
|
||||
}
|
||||
|
||||
/// Clear the current session. Invalidates the token in state.
|
||||
#[tauri::command]
|
||||
pub async fn signout(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.db.invalidate().await.map_err(into_err)?;
|
||||
*state.token.lock().unwrap() = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the currently authenticated user record.
|
||||
/// Relies on the DB connection being authenticated (token set via signin/signup).
|
||||
#[tauri::command]
|
||||
pub async fn get_me(state: State<'_, AppState>) -> Result<User, String> {
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT * FROM $auth")
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||
}
|
||||
|
||||
/// Update mutable profile fields. Only provided fields are changed.
|
||||
#[tauri::command]
|
||||
pub async fn update_profile(
|
||||
state: State<'_, AppState>,
|
||||
username: Option<String>,
|
||||
avatar: Option<String>,
|
||||
) -> Result<User, String> {
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query(
|
||||
"UPDATE $auth SET
|
||||
username = $username ?? username,
|
||||
avatar = $avatar ?? avatar
|
||||
RETURN AFTER",
|
||||
)
|
||||
.bind(("username", username))
|
||||
.bind(("avatar", avatar))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("user".into())))
|
||||
}
|
||||
|
||||
/// Return the contacts list for the current user.
|
||||
/// Contacts are `contact` records where `owner = $auth`.
|
||||
#[tauri::command]
|
||||
pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, String> {
|
||||
let result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT target.* FROM contact WHERE owner = $auth FETCH target")
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Add a user to the current user's contact list. Stub — returns the Contact record.
|
||||
#[tauri::command]
|
||||
pub async fn add_contact(
|
||||
state: State<'_, AppState>,
|
||||
user_id: String,
|
||||
) -> Result<Contact, String> {
|
||||
let mut result: Vec<Contact> = state
|
||||
.db
|
||||
.query("CREATE contact SET owner = $auth, target = type::thing('user', $uid)")
|
||||
.bind(("uid", user_id))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
Expected: no new errors from `user.rs`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/commands/user.rs
|
||||
git commit -m "feat: add user commands (signup, signin, signout, get_me, update_profile, contacts)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Create `commands/chat.rs`
|
||||
|
||||
**Files:**
|
||||
- Create: `src-tauri/src/commands/chat.rs`
|
||||
|
||||
- [ ] **Step 1: Create `commands/chat.rs`**
|
||||
|
||||
```rust
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use crate::db::AppState;
|
||||
use crate::error::{into_err, AppError};
|
||||
use crate::models::{Message, Room};
|
||||
|
||||
/// Create a new chat room.
|
||||
#[tauri::command]
|
||||
pub async fn create_room(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
) -> Result<Room, String> {
|
||||
let mut result: Vec<Room> = state
|
||||
.db
|
||||
.query("CREATE room SET name = $name, created = time::now()")
|
||||
.bind(("name", name))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("room after create".into())))
|
||||
}
|
||||
|
||||
/// Fetch all rooms.
|
||||
#[tauri::command]
|
||||
pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String> {
|
||||
let result: Vec<Room> = state
|
||||
.db
|
||||
.query("SELECT * FROM room ORDER BY created DESC")
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Send a message to a room.
|
||||
#[tauri::command]
|
||||
pub async fn send_message(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
body: String,
|
||||
) -> Result<Message, String> {
|
||||
let mut result: Vec<Message> = state
|
||||
.db
|
||||
.query(
|
||||
"CREATE message SET
|
||||
room = type::thing('room', $room_id),
|
||||
author = $auth,
|
||||
body = $body,
|
||||
created = time::now()",
|
||||
)
|
||||
.bind(("room_id", room_id))
|
||||
.bind(("body", body))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
||||
}
|
||||
|
||||
/// Fetch all messages in a room, oldest first.
|
||||
#[tauri::command]
|
||||
pub async fn get_messages(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<Vec<Message>, String> {
|
||||
let result: Vec<Message> = state
|
||||
.db
|
||||
.query("SELECT * FROM message WHERE room = type::thing('room', $room_id) ORDER BY created ASC")
|
||||
.bind(("room_id", room_id))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Delete a message by its ID string (e.g. "message:abc123").
|
||||
#[tauri::command]
|
||||
pub async fn delete_message(
|
||||
state: State<'_, AppState>,
|
||||
message_id: String,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.query("DELETE type::thing($id)")
|
||||
.bind(("id", message_id))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a LIVE query for new messages in a room.
|
||||
/// Spawns a background tokio task that emits "chat:message" Tauri events.
|
||||
///
|
||||
/// Returns a local subscription UUID — pass it to `unsubscribe_room` on cleanup.
|
||||
/// Aborting the JoinHandle drops the stream, which closes the LIVE query automatically.
|
||||
#[tauri::command]
|
||||
pub async fn subscribe_room(
|
||||
state: State<'_, AppState>,
|
||||
app_handle: AppHandle,
|
||||
room_id: String,
|
||||
) -> Result<String, String> {
|
||||
let db = state.db.clone();
|
||||
|
||||
let mut stream = db
|
||||
.query("LIVE SELECT * FROM message WHERE room = type::thing('room', $room_id)")
|
||||
.bind(("room_id", room_id))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.stream::<surrealdb::Notification<Message>>(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
let sub_id = Uuid::new_v4();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Some(Ok(notification)) = stream.next().await {
|
||||
let _ = app_handle.emit("chat:message", ¬ification.data);
|
||||
}
|
||||
});
|
||||
|
||||
state.subscriptions.lock().unwrap().insert(sub_id, handle);
|
||||
|
||||
Ok(sub_id.to_string())
|
||||
}
|
||||
|
||||
/// Stop a LIVE query subscription.
|
||||
/// Aborts the background task — dropping the stream closes the LIVE query.
|
||||
#[tauri::command]
|
||||
pub async fn unsubscribe_room(
|
||||
state: State<'_, AppState>,
|
||||
sub_id: String,
|
||||
) -> Result<(), String> {
|
||||
let uuid = sub_id
|
||||
.parse::<Uuid>()
|
||||
.map_err(|e| into_err(AppError::Subscription(e.to_string())))?;
|
||||
|
||||
if let Some(handle) = state.subscriptions.lock().unwrap().remove(&uuid) {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `futures-util` dependency** (needed for `StreamExt` in subscribe_room)
|
||||
|
||||
Add to `src-tauri/Cargo.toml` `[dependencies]`:
|
||||
|
||||
```toml
|
||||
futures-util = "0.3"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify it compiles**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
Expected: no new errors from `chat.rs`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/commands/chat.rs src-tauri/Cargo.toml src-tauri/Cargo.lock
|
||||
git commit -m "feat: add chat commands with LIVE query subscription support"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update `lib.rs`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Replace `lib.rs` entirely**
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
mod commands;
|
||||
mod db;
|
||||
mod error;
|
||||
mod models;
|
||||
|
||||
use db::{init_db, AppState};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let surreal = init_db("localhost:8000", "oxyde", "oxyde")
|
||||
.await
|
||||
.expect("Failed to connect to SurrealDB");
|
||||
|
||||
let state = AppState {
|
||||
db: Arc::new(surreal),
|
||||
token: Mutex::new(None),
|
||||
subscriptions: Mutex::new(HashMap::new()),
|
||||
};
|
||||
|
||||
app_handle.manage(state);
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::user::signup,
|
||||
commands::user::signin,
|
||||
commands::user::signout,
|
||||
commands::user::get_me,
|
||||
commands::user::update_profile,
|
||||
commands::user::get_contacts,
|
||||
commands::user::add_contact,
|
||||
commands::chat::create_room,
|
||||
commands::chat::get_rooms,
|
||||
commands::chat::send_message,
|
||||
commands::chat::get_messages,
|
||||
commands::chat::delete_message,
|
||||
commands::chat::subscribe_room,
|
||||
commands::chat::unsubscribe_room,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify full project compiles**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
Expected: clean — no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/lib.rs
|
||||
git commit -m "feat: wire AppState and all commands into Tauri builder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Create SurrealQL schema files
|
||||
|
||||
**Files:**
|
||||
- Create: `surreal/schema.surql`
|
||||
- Create: `surreal/auth.surql`
|
||||
|
||||
- [ ] **Step 1: Create `surreal/` directory and `schema.surql`**
|
||||
|
||||
```sql
|
||||
-- surreal/schema.surql
|
||||
-- Run once against your SurrealDB instance to define all tables and fields.
|
||||
|
||||
DEFINE TABLE user SCHEMAFULL;
|
||||
DEFINE FIELD username ON user TYPE string;
|
||||
DEFINE FIELD email ON user TYPE string;
|
||||
DEFINE FIELD password ON user TYPE string;
|
||||
DEFINE FIELD avatar ON user TYPE option<string>;
|
||||
DEFINE FIELD created ON user TYPE datetime DEFAULT time::now();
|
||||
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
|
||||
|
||||
DEFINE TABLE room SCHEMAFULL;
|
||||
DEFINE FIELD name ON room TYPE string;
|
||||
DEFINE FIELD created ON room TYPE datetime DEFAULT time::now();
|
||||
|
||||
DEFINE TABLE message SCHEMAFULL;
|
||||
DEFINE FIELD room ON message TYPE record<room>;
|
||||
DEFINE FIELD author ON message TYPE record<user>;
|
||||
DEFINE FIELD body ON message TYPE string;
|
||||
DEFINE FIELD created ON message TYPE datetime DEFAULT time::now();
|
||||
|
||||
DEFINE TABLE contact SCHEMAFULL;
|
||||
DEFINE FIELD owner ON contact TYPE record<user>;
|
||||
DEFINE FIELD target ON contact TYPE record<user>;
|
||||
DEFINE INDEX unique_contact ON contact FIELDS owner, target UNIQUE;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `surreal/auth.surql`**
|
||||
|
||||
```sql
|
||||
-- surreal/auth.surql
|
||||
-- Run after schema.surql.
|
||||
-- Set SURREAL_JWT_SECRET env var before running SurrealDB,
|
||||
-- then substitute the value here. Never hardcode in source control.
|
||||
|
||||
DEFINE ACCESS account ON DATABASE TYPE RECORD
|
||||
SIGNUP (
|
||||
CREATE user SET
|
||||
email = $email,
|
||||
username = $username,
|
||||
password = crypto::argon2::generate($password)
|
||||
)
|
||||
SIGNIN (
|
||||
SELECT * FROM user
|
||||
WHERE email = $email
|
||||
AND crypto::argon2::compare(password, $password)
|
||||
)
|
||||
WITH JWT ALGORITHM HS512 KEY env::get("SURREAL_JWT_SECRET");
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add surreal/
|
||||
git commit -m "feat: add SurrealQL schema and Record Auth definitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Final compile + run check
|
||||
|
||||
**Files:** none new
|
||||
|
||||
- [ ] **Step 1: Run all Rust tests**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo test
|
||||
```
|
||||
|
||||
Expected: all tests pass (error.rs and models.rs unit tests).
|
||||
|
||||
- [ ] **Step 2: Check Tauri dev build starts** (requires SurrealDB running locally)
|
||||
|
||||
```bash
|
||||
# In a separate terminal, start SurrealDB:
|
||||
surreal start --user root --pass root file://oxyde.db
|
||||
|
||||
# Then:
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
Expected: app window opens without a panic. SurrealDB connection errors will surface in the terminal if the server isn't running — that is expected in CI without a DB. The build itself must succeed.
|
||||
|
||||
- [ ] **Step 3: Final commit**
|
||||
|
||||
```bash
|
||||
git add -p # review any remaining unstaged changes
|
||||
git commit -m "chore: verify scaffold compiles and boots"
|
||||
```
|
||||
Reference in New Issue
Block a user