Jump to content

Building “Thuney”: Budgeting With Rust, Tauri, and SurrealDB

From JOHNWICK

Hello everyone 👋 I recently built a little desktop app to manage expenses and budgets called Thuney. It uses Rust for the core logic, Tauri for the desktop shell, and SurrealDB for storage. Along the way I learned a bunch — what felt magical, what felt sharp, and where I’d do things differently next time. If you’re curious about shipping a lean, fast, cross-platform app with a modern stack, this post is for you.


Why This Stack?

  • Rust for safety and performance without GC pauses.
  • Tauri for a tiny footprint and native-feeling desktop builds, without stuffing a whole browser into every app.
  • SurrealDB because it promises a flexible, multi-model database where data feels like JSON and schema can evolve smoothly.

That combination let me move fast without giving up correctness. And in personal finance apps, correctness matters.


SurrealDB: The Database That Feels Like JSON

The best part of SurrealDB is how natural it feels to model and tweak data as you build. When you’re iterating on a budget app, your objects change constantly: categories get tags, transactions get metadata, recurring rules grow options. SurrealDB’s document-style vibe means you can evolve structures in place without ceremony.

Instant Switch Between Backends

SurrealDB supports different storage engines behind the same interface. I ran:

  • RocksDB for the real app (on-disk, durable).
  • In-memory for tests (no cleanup, blazing fast).

It’s incredibly convenient: same queries, same shape of data, zero teardown headaches. Are the engines perfectly identical? No — RocksDB and in-memory differ under the hood. For integration tests that touch persistence semantics, I still spot-check with RocksDB. But for the bulk of unit and flow tests, in-memory keeps the feedback loop tight.

The Serialization/Querying Rough Spots

I did run into places where SurrealDB nudged my design:

  • Serialization friction. Getting some nested Rust types to round-trip cleanly required compromises and a few “why am I doing this?” moments.
  • Nested record IDs limitation. In my case, records that referenced other records inside nested structures couldn’t be queried as directly as I expected. Practically this means: instead of pulling “parent with embedded children” and then filtering by the children’s record IDs in one go, I sometimes had to store parent IDs on the children and perform a second query. It’s a known bug, and I’m hopeful it’ll be resolved, but it did shape my data architecture.

A Tiny Example

-- Transactions live under a user, with a category reference. CREATE transaction:tx1 CONTENT {

 amount: 19.99,
 currency: "EUR",
 category: category:groceries,  -- record link
 occurred_at: time::now(),
 notes: "Milk and veggies"

}; -- Later, pulling transactions by category might be a two-step if nested paths are involved. SELECT * FROM transaction WHERE category = category:groceries;


That said, day to day I still felt very productive. Modeling in SurrealDB fits the way UI and product code evolve.


Tauri: Rust-Powered Desktop Without the Bloat

I started desktop work with Electron years ago. It’s great for getting something on screen quickly, but I didn’t love writing JS/TS for everything, and the memory footprint adds up. Tauri gives you the best of both worlds: a webview for the UI if you want it, with Rust on the backend (and optionally on the frontend too, via projects like Yew).

The DX: Surprisingly Smooth

  • Scaffold with the CLI.
  • Pick your backend language (Rust), frontend tech (React/Vue/Svelte — or none).
  • Ship commands from Rust to the UI with a couple of macros.

It’s hard to overstate how nice it is when the glue “just works.” Commands expose backend features to the UI without big ceremony:

// src-tauri/src/main.rs

  1. [tauri::command]

fn add_transaction(amount: f64, category: String, notes: Option<String>) -> String {

   // Business logic here...
   format!("Added {amount} to {category} ({:?})", notes)

} fn main() {

   tauri::Builder::default()
       .invoke_handler(tauri::generate_handler![add_transaction])
       .run(tauri::generate_context!())
       .expect("error while running tauri application");

}

From the frontend, you just invoke('add_transaction', {...}) and you’re done.

Keeping Rust and TypeScript In Sync (ts_rs)

One sharp edge in hybrid stacks is type drift between Rust and TypeScript. I used the ts_rs crate to export Rust types to TS automatically. Highly recommended.

use serde::{Deserialize, Serialize}; use ts_rs::TS;

  1. [derive(Serialize, Deserialize, TS)]
  2. [ts(export)]

pub struct Transaction {

   pub id: String,
   pub amount: f64,
   pub currency: String,
   pub category_id: String,
   pub occurred_at: String,
   pub notes: Option<String>,

}

This emits a Transaction.d.ts you can import in your frontend. Result: fewer mismatches, less guesswork, happier refactors.


Rust: Safety, Speed, and Confidence

Rust is the connective tissue that keeps the app honest. A few takeaways:

  • Great for core domain logic. Budgets, recurring rules, and validation read cleanly in Rust and are easy to test.
  • FFI-free happiness. With Tauri, you don’t need to reach for C shims or complicated bindings — commands are simple and composable.
  • Performance headroom. UI latency feels low because the heavy work stays off the main thread, and Rust makes concurrency approachable without fear.


Testing Philosophy: Fast First, Real Enough Later

  • Unit & flow tests: use in-memory SurrealDB. It’s instant, deterministic, and your test data vanishes when the run ends.
  • A handful of integration tests: run against RocksDB to catch persistence quirks and ensure migrations don’t regress.

Is using different engines for prod and tests risky? A little. But the trade-off (speed vs. exactness) paid off. My rule of thumb: if a test asserts business logic, use in-memory. If a test asserts durability, indexes, or migration behavior, run it on RocksDB.


Packaging & Footprint

Tauri produces installers that are refreshingly small compared to Electron apps, and memory usage is lower. For a simple budgeting tool, that matters more than you’d think; it feels respectful to the user’s machine. Shipping updates stays straightforward as the app grows.


What I’d Do Differently Next Time

  • Flatten relationships earlier. Given the nested record ID limitation I hit, I’d bias toward explicit parent/child refs and avoid deep nesting for queriable fields. Keep the shape ergonomic for the UI, but don’t fight the query engine.
  • Document type exports as part of CI. ts_rs was a win; automating the export/verification step in CI prevents “works on my machine” type mismatches.
  • Codify fixture builders. Test data factories (in Rust) made scenario setup much clearer — worth investing in from day one.


A Few Practical Snippets

Querying by Parent ID (Workaround Style)

-- Store parent references on children CREATE category:groceries CONTENT { name: "Groceries" }; CREATE transaction:tx2 CONTENT {

 amount: 42.10,
 category_id: "category:groceries",
 occurred_at: time::now()

}; -- Query children by parent id instead of nested record filters SELECT * FROM transaction WHERE category_id = "category:groceries";

Minimal Command From UI (Type-Safe)

import { invoke } from "@tauri-apps/api"; type AddTx = {

 amount: number;
 category: string;
 notes?: string;

}; export async function addTransaction(data: AddTx) {

 return await invoke<string>("add_transaction", data);

}

Developer Experience Summary SurrealDB

  • ✅ Feels like JSON; schema evolution is painless
  • ✅ Swappable backends (RocksDB vs. in-memory) with one interface
  • ⚠️ Serialization can be fiddly; nested record ID querying pushed design changes

Tauri

  • ✅ Macros make backend APIs dead simple
  • ✅ Small binaries, low memory, native feel
  • ✅ Play nicely with Rust or a Rust-frontend (Yew)
  • 💡 Tip: Use ts_rs to keep Rust/TS types synced

Rust

  • ✅ Confidence, speed, and clean domain logic
  • ✅ Great testing story when paired with in-memory DB
  • ✅ Concurrency without drama


Final Thoughts

If you’re a Rust-curious developer who wants to ship a real, cross-platform app without the heft of Electron, Tauri + SurrealDB + Rust is a compelling trio. You’ll get fast compile-run cycles, a surprisingly friendly DX, and the freedom to evolve your data model as your product takes shape. A few sharp edges exist — particularly around SurrealDB querying with nested record IDs — but the overall experience was productive and fun.

GithubLink: Nrapendra786/thuni Got questions about the stack, testing setup, or data modeling choices? Drop a comment — I’m happy to share more details.

Read the full article here: https://medium.com/@trivajay259/building-thunes-budgeting-with-rust-tauri-and-surrealdb-298a5d972fd9