Files
Oxyde/docs/superpowers/plans/2026-04-14-scaffold.md
qdust41 faaea6c729
Some checks failed
Release / release (macos-latest) (push) Has been cancelled
Release / release (ubuntu-22.04) (push) Has been cancelled
Release / release (windows-latest) (push) Has been cancelled
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.
2026-04-15 23:11:48 -04:00

23 KiB

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:

[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
cd src-tauri && cargo check

Expected: no errors (warnings about unused imports are fine at this stage).

  • Step 3: Commit
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:

#[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
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:

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
cd src-tauri && cargo test error

Expected: 2 tests pass.

  • Step 5: Commit
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:

#[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
cd src-tauri && cargo test models

Expected: FAIL — Message not defined.

  • Step 3: Implement models.rs
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
cd src-tauri && cargo test models

Expected: 1 test passes.

  • Step 5: Commit
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

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
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
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

pub mod chat;
pub mod user;
  • Step 2: Verify
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
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

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
cd src-tauri && cargo check

Expected: no new errors from user.rs.

  • Step 3: Commit
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

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", &notification.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]:

futures-util = "0.3"
  • Step 3: Verify it compiles
cd src-tauri && cargo check

Expected: no new errors from chat.rs.

  • Step 4: Commit
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

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
cd src-tauri && cargo check

Expected: clean — no errors.

  • Step 3: Commit
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

-- 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
-- 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
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
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)
# 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
git add -p  # review any remaining unstaged changes
git commit -m "chore: verify scaffold compiles and boots"