Building “Thuney”: Budgeting With Rust, Tauri, and SurrealDB
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
- [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;
- [derive(Serialize, Deserialize, TS)]
- [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
