Architecture & Decisions

Finia is a Leptos + Tauri 2 + SQLite desktop application with an optional, end-to-end-encrypted sync layer.

Data flow: frontend to backend

Leptos views read from reactive stores (crates/app/src/stores/), which call typed invoke wrappers in crates/tauri-ipc/. The command handlers (src-tauri/src/commands/) acquire a tokio::sync::Mutex lock on the single shared rusqlite::Connection held in AppState, execute SQL, and return serialized structs.

All monetary values are stored and passed as integer cents (amount_cents: i64), never floats.

A convention worth knowing: mutation handlers split into a synchronous _impl(&mut Connection, …) → Outcome core plus a thin async wrapper that emits Tauri events. This keeps the SQL testable and the event side-effects in one place.

CRDT sync

The sync engine lives in src-tauri/src/sync/crdt.rs:

  • Conflict resolution is Last-Write-Wins per field using LwwRegister, keyed by a Hybrid Logical Clock (HLC): (wall_ms, counter, node_id).

  • Events are append-only; the merge function is commutative, associative, and idempotent.

Outbound events are generated in the command handlers. Every mutation to a synced entity calls sync::emit::emit / emit_tombstone inside the same rusqlite transaction as the row write — a transactional outbox into sync_events. The device HLC lives in AppState.hlc. On the receive side, sync::client::apply_entity_to_db upserts (ensure-row, then per-field UPDATE) so peer-created rows materialize, and applies a __deleted tombstone as a hard delete.

What replicates

src-tauri/src/sync/registry.rs is the single source of truth for what replicates — entity to table to fields to required columns. It is consumed by emit, apply, parse_entity_type, and backfill.

Phase-1 scope is the six core entities: account, transaction, budget, recurring_transaction, contact, and category. Loans and schedules, assets and mortgages, portfolios/trades/lots, and reconciliation linking are deliberately not synced yet. Receiving an event for an unregistered entity is safe — it is logged, not materialized — so promoting an entity later is additive.

Sync roles

The app can act in two roles simultaneously:

Hub / server

An embedded Axum server spawned inside the Tauri process (sync::server::spawn).

Spoke / client

A background push/pull loop (sync::client::spawn) that connects to an upstream server.

Both are started and stopped via Tauri commands (commands/sync.rs) and persist their settings in SQLite.

Encryption

Secret columns are encrypted at rest with ChaCha20-Poly1305; the master key lives in the OS keyring with a 0600 file fallback, swept idempotently at startup. Sync payloads are additionally encrypted client-side (zero-knowledge): a relay only ever sees ciphertext. The encryption key is wrapped by the user’s master passphrase.

i18n

The default locale is de. Translations live under crates/app/src/i18n/ (de.json, en.json, ru.json) and are loaded at compile time via load_locales!(). The active locale is persisted in the PrefsStore (SQLite).

Reserved Rust keywords cannot be i18n keys — type breaks load_locales!, so the codebase uses kind instead (e.g. trades.kind, transactions.filter.kind).

Workspace layout

crates/ui

UI primitives + chart components; no IPC dependency; builds for wasm32.

crates/tauri-ipc

Typed invoke() wrappers; no SQL or business logic; builds for wasm32.

crates/app

The Leptos frontend; wasm32 only.

crates/csv-import

Pure-Rust CSV parsers for ING and Postbank.

src-tauri/

Tauri shell + SQLite handlers; does not build for wasm32 (pulls tokio/mio).

sync-server/

Standalone Axum relay; a separate Cargo.toml.

The embedded hub server inside src-tauri/ is a lighter variant wired directly to the app’s SQLite; it is not the same binary as the standalone sync-server/.