Engineering case study

A local-first point of sale, written in Rust

DatioLabs Retail is a Windows desktop POS for Venezuelan retail: fully offline, exact decimal money, every record on the client's machine. What follows is the engineering: real functions, real invariants, and how each one is verified.

Built by Miguel Cordero Pamphile · Rust engineer · open to work

Rust Tauri Sled rust_decimal TypeScript Playwright · CDP GitHub Actions
Open the live web demo Read the code
165
automated tests
73/73
commands over IPC
107
internal E2E scenarios, happy + adverse
2.97 MB
CI build (smoke test)
01 — Context

Built for shops that cannot go down

Retail shops in Venezuela run on modest Windows PCs with unreliable connectivity. Prices are anchored in USD while customers pay in bolivars (Venezuela's currency) at the official BCV rate (Banco Central de Venezuela, the country's central bank), which changes daily. A server, an internet connection or a subscription is not an option: the shop must keep selling when the network is down, and the data must stay on the shop's machine.

02 — Architecture

Three layers, one deliberate boundary

Frontend SPA
TypeScript · Vite · Tailwind
→
Tauri IPC
in-process, no network
→
datiolabs-core
Rust domain engine
→
Sled
embedded DB · SHA-256 signed
03 — The engineering, in code

Seven decisions, with the code that proves them

Short excerpts from the codebase — trimmed for space, not simplified. Each block is the function that does the work the text describes.

Money that cannot drift models.rs · lib.rs

Every amount in the core is a Decimal, and it travels as text, not as a binary float — sale signatures hash Decimal::to_string(), so a SHA-256 stays reproducible across platforms and versions. A malformed amount never becomes a silent zero: the backend has exactly one parser, and it fails loudly.

pub fn total_bs(&self) -> Decimal {
    self.cantidades
        .iter()
        .zip(self.precios_usd.iter())
        .zip(self.tasas_bloqueadas.iter())
        .fold(Decimal::ZERO, |acc, ((q, p), t)| acc + *q * *p * *t)
}

fn decimal_de(texto: &str) -> Result<Decimal, UIError> {
    Decimal::from_str(texto.trim().replace(',', ".").as_str())
        .map_err(|_| UIError::new("monto invalido", "Se esperaba un numero decimal"))
}

Settlement sums per line with each line's locked rate, so one ticket can mix rates and still reconcile to the cent. The TypeScript side mirrors it with integer cents (Money.toCents, sum, usdToBsCents), and property tests assert exact sums and ≤1-cent reversibility.

Stock without database transactions db.rs · datiolabs-ui/src/lib.rs

The embedded store gives no multi-tree ACID, so inventory doesn't pretend otherwise: one writer at a time (con_ledger on a global Mutex<Ledger>), validate before write, an append-only movements ledger, and flush() as the durability point.

pub fn aplicar_movimiento(
    &self,
    mut movimiento: MovimientoStock,
    delta_catalogo: impl FnOnce(&str, Decimal),
) -> Result<MovimientoStock, DbError> {
    // stock + delta < 0 is rejected here, before anything is written
}

fn clave_cronologica(fecha_unix: i64, id: &[u8]) -> Vec<u8> {
    let mut clave = Vec::with_capacity(8 + id.len());
    clave.extend_from_slice(&(fecha_unix as u64).to_be_bytes());
    clave.extend_from_slice(id);
    clave
}

Movements are keyed chronologically, so iter().rev() is recency order without sorting. IDs and names are fixed-size, zero-heap types (struct Sku([u8; 32], u8)), so scans don't allocate.

FEFO, packaging and one source of price modulos/panaderia.rs · datiolabs-ui/src/lib.rs

Perishables consume from lots in expiry order, and the reverse path puts units back into the nearest valid lot. Packaging is not a second inventory: "2 boxes" is converted to base units once, by a single conversion shared by the preview and the charge.

pub fn descontar_fefo(
    &mut self,
    sku: &str,
    mut cantidad: Decimal,
    ahora_unix: i64,
) -> Result<Vec<(String, Decimal)>, ErrorNegocio> {
    let mut tocados = Vec::new();
    while cantidad > Decimal::ZERO {
        // take from the next valid lot, record (lote, cantidad)
    }
}

fn resolver_precio_presentacion(catalogo: &Catalogo, idx: usize, modo: &str) -> (Decimal, u32)

resolver_precio_presentacion is the only place that decides price and factor, and construir_lineas_venta is the only line factory — shared by previsualizar_cobro and registrar_venta. What the screen confirms is exactly what gets written.

A schema you cannot break, backups that outlive versions db.rs

Records use positional bincode: adding, removing or reordering a field would corrupt every installed client. Persisted structs are therefore frozen, new features go to new trees, and backups are versioned and checksummed end to end.

pub struct BackupMetadata {
    pub version: u32,
    pub timestamp_unix: i64,
    pub arboles: Vec<String>,
    pub total_registros: usize,
    pub checksum_sha256: String,
}

fn importar_backup(&self, ruta, licencia_esperada) -> Result<(), DbError> {
    // SHA-256 and the embedded license are verified before anything is written
}

A fixture from the client's installed build is imported in CI and byte-compared against HEAD (compat_backup_cliente.rs). Restoring also clears trees that an older backup didn't have, so no residue survives.

Contracts enforced by reading the source scripts/auditar_contrato_tauri.py

A real bug motivated this: the UI called eliminar_consumo with an extra cantidad argument, Tauri silently ignores unknown arguments, and removing one unit deleted the whole line. The fix was structural — an audit that parses #[tauri::command] signatures and every invocar() call.

GUARDAS_ESPERADAS = {
    "abonar_cuenta", "abrir_cuenta", "agregar_consumo", "cerrar_cuenta",
    "editar_abono_cuenta", "eliminar_consumo", "eliminar_venta", "registrar_venta",
}

# plus: every command registered, no unknown keys sent,
# no required argument missing, 10 license keys mirrored Rust<->TS

The audit asserts that the shift guard appears in exactly these eight commands — add a ninth without updating the audit, and CI fails.

A desktop app that heals itself datiolabs-ui/src/lib.rs · tasa_bcv.rs

The UI sends a heartbeat every 5 seconds; the shell stores it in an AtomicI64, and a watchdog thread reloads the window if it goes quiet for 30 seconds — with a 120-second cooldown so it cannot loop.

std::thread::spawn(move || loop {
    std::thread::sleep(Duration::from_secs(10));
    let visto = ultimo.load(Ordering::Relaxed);
    if visto > 0 && ahora - visto > 30 && ahora - ultimo_reload > 120 {
        if let Some(ventana) = handle.get_webview_window("main") {
            let _ = ventana.reload();
        }
        ultimo.store(ahora, Ordering::Relaxed);
        ultimo_reload = ahora;
    }
});

The E2E suite validates it by freezing the renderer for 45 seconds and asserting the window comes back. The same discipline applies to state: the manual exchange rate is re-checked inside the same write lock that refreshes from BCV, so a stale scrape can never overwrite an operator's decision.

The only external input tasa_bcv.rs · db.rs

The BCV (Banco Central de Venezuela) publishes the rate as an HTML page, not an API. The shell fetches it hourly with a 30-second timeout, selects the USD box, normalizes the number (1.234,56 and 36,92 both parse), rejects zero and out-of-range values, and writes a signed rate event plus a cache. If the page changes or the network fails, the last rate stays. A manual rate set by the operator wins until they explicitly return to BCV, and the decision is re-checked under the same write lock as the write, so a scrape in flight cannot overwrite it. A missing rate blocks the sale instead of becoming zero.

async fn scrapear_bcv(cliente: &reqwest::Client) -> Result<Decimal, UIError> {
    let html = cliente.get(URL_BCV).send().await?.error_for_status()?.text().await?;
    extraer_valor_usd(&html).ok_or(UIError { /* keep the last rate */ })
}

// "1.234,56" and "36,92" both parse; zero and out-of-range are rejected
fn normalizar_valor_bcv(texto: &str) -> Option<Decimal>

It is a small pipeline, not a data platform: one source, one value, hourly. The engineering is in the failure contract: untrusted HTML, validation, fallback, operator override and a signed history. Six tests cover USD vs EUR, number formats, manual stickiness and the cache.

04 — Verification

Proof before promises

Three layers, from unit tests to a remote harness that clicks the real desktop application.

L1

Unit & contract

67 Rust tests in the core plus 6 in the desktop shell, and 92 frontend tests across 9 suites. The frontend/backend money contract is enforced by 1:1 contract tests. Rust core coverage: 95.15% of lines (92.59% of functions, 91.13% of regions, measured with cargo-llvm-cov on 2026-09-30).

L2

Property-based

proptest in Rust (property-based integration suites) and fast-check in TypeScript attack the money and inventory invariants with randomized cases.

L3

Real E2E over CDP

No mocks: a test build of the Tauri app exposes the Chrome DevTools Protocol and Playwright-core drives real clicks on the real WebView2. The harness runs from a Linux workstation over SSH/SCP against a Windows Server, with an isolated APPDATA and a scheduled interactive task. The test binary and the debug flag never reach the installer — verified in the final artifact.

Linux workstation scp/ssh → Windows Server CDP :9222 → Real Tauri app (WebView2)
05 — Numbers

Every claim has a number

Rust core tests67 all green
Desktop shell tests (Windows)6
Frontend tests92 · 9 suites
Rust core line coverage95.15% · as a floor, not a proof
internal E2E scenarios107 · internal harness
internal E2E scripts (CDP + Playwright)27 · internal
Tauri commands over IPC73 · all checked by the contract audit
Persistence trees15
Windows installer (CI build)2,973,442 bytes · 2.97 MB
internal E2E binary (never shipped)10,462,208 bytes · 10.0 MB
Source sizeRust 8,012 LOC · TypeScript 14,521 LOC (+1,931 tests)

Method: counts, LOC and the CI build were measured on 2026-10-20. Coverage (95.15%, measured 2026-09-30) and the internal E2E counts are not re-measured in CI; the E2E harness is internal, and its cases are documented in docs/e2e.md in the core repository. The public code is at github.com/miguelcorderopamphile/datiolabs and github.com/miguelcorderopamphile/datiolabs-frontend.

06 — Product

Running today

Real screenshots of the running application (web demo, mock backend).

Checkout screen with catalog, live cart and exact USD/VES totals
Checkout: catalog, live cart, BCV rate and exact totals in USD and VES.
Owner panel with profitability, shift control and operator management
Owner panel: profitability by date range, shift control and operator ledger.
Inventory with stock, lots and packaging
Inventory: stock, lots and packaging, with FEFO rotation for perishables.
Open accounts with consumptions and partial payments
Open accounts: consumptions, partial payments and balances in both currencies.
07 — Status

Shipped. Source public.

Phases 1–4 are implemented, verified and packaged (2026-09-19): product, offline rate, multi-vertical capabilities and the verification stack. The application is actively running in businesses in Venezuela. The source code is public (MIT). The commercial product is the business, not the code.

P1 Product & money
P2 Offline BCV rate
P3 Multi-vertical
P4 Verification stack
Open to work

Available for Rust roles

Backend, systems or edge — remote, or willing to relocate anywhere if needed. The source is public; the code above is real, and I'm happy to walk through it live.