Offline-First with Embedded SQLite

CrateStack’s embedded backend, cratestack-rusqlite, runs the same .cstack schemas you use on the server against a local SQLite database. As of 0.3.0 it ships from one source to three deployment targets:
  • Native mobile (iOS, Android via FFI / flutter_rust_bridge)
  • Native desktop (Windows, macOS, Linux)
  • Browser via wasm32-unknown-unknown with OPFS-backed persistence
The same RusqliteRuntime, the same delegate API, the same .cstack schema — only the FFI backend swaps per target (libsqlite3-sys on native, sqlite-wasm-rs on wasm32). This is handled transparently by rusqlite 0.39.
The architecture is “Rust as real frontend, UI as UI-only”: Rust owns state, persistence, and business logic on the device or in the browser tab; the UI (Flutter, React, Solid…) talks to Rust over FFI or wasm-bindgen. The same .cstack schema definition compiles to both the server (Postgres via cratestack-sqlx) and the embedded target (SQLite via cratestack-rusqlite).

What Changes Per Target

AspectServer (cratestack-sqlx)Embedded native (cratestack-rusqlite)Embedded browser (cratestack-rusqlite, wasm32)
API styleasync, tokiosync, no tokiosync SQL ops, with one async init step
Storage handlesqlx::PgPoolrusqlite::Connection behind a Mutexrusqlite::Connection behind a Mutex
Persistenceserver-managed Postgresbundled SQLite to a file on diskOPFS (Origin Private File System) via SAH pool
Build prereqcargo buildcargo buildcargo build --target wasm32-unknown-unknown + wasm-capable clang (brew install llvm)
Worker requirementn/an/aDedicated Worker only (OPFS SyncAccessHandle is worker-only)
Policy enforcementfull — rendered into WHEREnone — client is single-usernone — client is untrusted
RETURNING on writesyesyes (SQLite ≥ 3.35)yes
Decimal round-tripexactexact (canonical TEXT)exact (canonical TEXT)
The shared dialect-agnostic primitives (filter AST, order AST, value types, ModelDescriptor) live in cratestack-sql — all backends consume them.

Add the Dependency

The umbrella cratestack crate already re-exports the SQLite backend types you need. For a regular native build:
[dependencies]
cratestack = { version = "0.3", features = ["decimal-rust-decimal"] }
For a binary-size-sensitive embedded build that doesn’t need the server stack, depend directly on the leaf crates:
[dependencies]
cratestack-macros = "0.3"
cratestack-rusqlite = "0.3"

Browser-specific build prerequisites

sqlite-wasm-rs (used by rusqlite 0.39 on wasm32) compiles SQLite’s C source to WebAssembly via cc-rs. That requires a wasm-capable clang on PATH — Apple’s stock Xcode clang does not include the wasm32 backend.
brew install llvm
export CC=/opt/homebrew/opt/llvm/bin/clang   # Apple Silicon
# export CC=/usr/local/opt/llvm/bin/clang    # Intel
Then:
rustup target add wasm32-unknown-unknown
cargo build -p cratestack-rusqlite --target wasm32-unknown-unknown

Schema

Use provider = "sqlite" on the datasource block. Everything else in your .cstack schema works the same:
datasource db {
  provider = "sqlite"
  url = env("DATABASE_URL")
}

auth Anonymous {
  id Int
}

model Note {
  id Uuid @id
  title String
  body String
  pinned Boolean
  createdAt DateTime
}
The embedded runtime ignores auth blocks and @@allow / @@deny policies at SQL render time. They still parse and validate; they just don’t gate reads or writes. Declare them as if you were on the server — your schema stays portable.

Minimal Setup — Native

use cratestack::include_embedded_schema;
use cratestack::{RusqliteRuntime, rusqlite_backend::ddl::create_table_sql};
use cratestack_rusqlite::ModelDelegate;

include_embedded_schema!("schema.cstack");

fn open_store() -> Result<RusqliteRuntime, Box<dyn std::error::Error>> {
    // On a real device, resolve a writable path your platform considers
    // persistent: `Application.documentsDirectory` on iOS,
    // `Context.getFilesDir()` on Android.
    let runtime = RusqliteRuntime::open("app.db")?;

    // Bootstrap each table the schema defines. Idempotent — runs every
    // app start.
    runtime.with_connection(|conn| {
        conn.execute_batch(&create_table_sql(&cratestack_schema::NOTE_MODEL))?;
        Ok(())
    })?;

    Ok(runtime)
}

fn insert_one(runtime: &RusqliteRuntime) -> Result<(), Box<dyn std::error::Error>> {
    let notes = ModelDelegate::new(runtime, &cratestack_schema::NOTE_MODEL);
    let created = notes
        .create(cratestack_schema::CreateNoteInput {
            id: uuid::Uuid::new_v4(),
            title: "First note".into(),
            body: "Hello.".into(),
            pinned: true,
            createdAt: chrono::Utc::now(),
        })
        .run()?;
    println!("created note {}", created.id);
    Ok(())
}

Minimal Setup — Browser (OPFS)

OPFS SyncAccessHandle is only available inside a Dedicated Worker per the W3C spec. Install the VFS once inside the worker before opening the database; subsequent RusqliteRuntime::open(filename) calls automatically route through it.
use cratestack::include_embedded_schema;
use cratestack::{RusqliteRuntime, rusqlite_backend::ddl::create_table_sql};
use cratestack_rusqlite::{opfs, ModelDelegate};

include_embedded_schema!("schema.cstack");

// Run inside a Dedicated Worker. The async step is one-time (per worker).
async fn open_store() -> Result<RusqliteRuntime, Box<dyn std::error::Error>> {
    opfs::install_opfs_vfs(&opfs::OpfsOptions::default()).await?;
    let runtime = RusqliteRuntime::open("app.db")?;
    runtime.with_connection(|conn| {
        conn.execute_batch(&create_table_sql(&cratestack_schema::NOTE_MODEL))?;
        Ok(())
    })?;
    Ok(runtime)
}
OpfsOptions lets you tune the SAH pool directory, initial capacity (handles allocated up-front, one per open DB file + journal), and the clear_on_init flag (useful for “logged out, clear data” flows). Main-thread code can still use RusqliteRuntime::open_in_memory() for ephemeral state when OPFS isn’t available.
Calling opfs::install_opfs_vfs on the main thread returns OpfsInstallError::NotSupported. Spawn a Dedicated Worker, run the runtime there, and postMessage between main thread and worker.

Filtering, Ordering, Paging

The fluent delegate API mirrors the server side. Field accessors are emitted by include_embedded_schema! per model:
let pinned_first = notes
    .find_many()
    .where_(cratestack_schema::note::pinned().is_true())
    .order_by(cratestack_schema::note::createdAt().desc())
    .limit(20)
    .run()?;
where_expr, and, or, and relation filters from cratestack-sql all work here.

Storage Class Choices

Embedded apps live or die by data fidelity, so the binding layer commits to canonical representations rather than relying on SQLite’s loose typing:
SqlValue variantStored asNotes
String, CuidTEXTas-is
IntINTEGER
FloatREAL
BoolINTEGER (0/1)decoder coerces back to bool
BytesBLOB
UuidTEXTcanonical hyphenated lowercase form
DateTime<Utc>TEXTRFC 3339 UTC, microsecond precision
JsonTEXTcompact serde JSON
DecimalTEXTcanonical string — exact precision, no float coercion
The DDL generator declares every column with BLOB affinity to preserve those storage classes. TEXT or NUMERIC affinities would silently convert numeric-looking text and lose Decimal precision; BLOB is the only affinity that respects what you bind.
Because columns use BLOB affinity, integer primary keys do not alias the SQLite rowid. Production schemas typically use UUID PKs anyway. If you specifically need auto-increment, apply your own CREATE TABLE statement via RusqliteRuntime::with_connection instead of the generic DDL helper.

Soft Delete and Audit

@@soft_delete works embedded: DELETE calls become UPDATE of the soft-delete column, and every find_* automatically filters rows where the soft-delete column is non-null. @@audit and @@emit are currently no-ops in include_embedded_schema! — the local-journal / local-event-bus implementations land in a follow-up release (they need a sync-engine design pass first).

The FFI Boundary

The embedded runtime is sync, so it slots into FFI bridges (e.g. flutter_rust_bridge on native, wasm-bindgen in the browser) without an async runtime to drag along. The boundary helpers in cratestack_rusqlite::ffi give you a small envelope to encode requests and responses across the language gap:
use cratestack_rusqlite::ffi::{
    OperationRequest, OperationResponse, json_request_from, json_response_into,
};

// In your cdylib entry point (FFI) or wasm-bindgen module (browser):
fn ffi_call(runtime: &cratestack::RusqliteRuntime, bytes: &[u8]) -> Vec<u8> {
    let request = match json_request_from(bytes) {
        Ok(req) => req,
        Err(err) => return json_response_into(
            &OperationResponse::err("bad_request", err.to_string()),
        ),
    };
    json_response_into(&dispatch(runtime, request))
}
The dispatch(...) function lives in your app crate — it knows the specific model types and routes each (model, kind) pair to the right delegate call. See cargo run --example sqlite_ffi_dispatch -p cratestack in the framework repository for a complete template.

Examples in the Framework Repo

Cargo-native examples (run with cargo run --example <name> -p cratestack):
  1. sqlite_quickstart — smallest working program: open in-memory DB, bootstrap one table, CRUD a row.
  2. sqlite_offline_first — file-backed DB, two models, Decimal money preserved exactly, filtering and partial updates.
  3. sqlite_ffi_dispatch — the JSON-bytes FFI dispatcher you’d wrap with flutter_rust_bridge.
Standalone workspace examples under examples/ — pick the one that matches your deployment shape:
ExampleHostNotes
embedded-cliclap-driven CLISmallest end-to-end shape; no async.
embedded-browser-viteVite + wasm in a Dedicated WorkerOPFS persistence. Sibling: embedded-browser-webpack, embedded-browser-vite-pwa.
react-vite-daisyuiReact + Tailwind 4 + DaisyUI 5Same wasm/OPFS shape with a real component library on top.
react-nextjs-daisyuiNext.js App RouterAll three CrateStack surfaces in one app: wasm in the browser, napi-rs on the Node side, typed HTTP client. PWA + offline-first sync.
tauri-web / tauri-nativeTauri 2 desktopWasm-in-webview vs. native-Rust shell variants.
embedded-flutterFlutter + flutter_rust_bridgemacOS / Android / iOS.
embedded-expoReact Native + Expo native moduleJSON FFI envelope across the JS↔native boundary.
embedded-daemontokio + notify daemonDemonstrates the async-around-sync pattern — see the companion guide.
embedded-webhookaxum HTTP server with its own SQLiteSame spawn_blocking pattern; the “small service with state” shape.
If you’re embedding the SQLite path inside a tokio process (server, daemon, anything async), read Async I/O with Embedded SQLite next — it covers the spawn_blocking seam that the daemon and webhook examples exist to demonstrate.

Limits Today

The embedded backend is intentionally focused. The following are not implemented in 0.3.0:
  • policy enforcement at the storage layer (by design — see above)
  • @@audit and @@emit — directive parses, codegen is no-op, follow-up release will add the local journal + event bus
  • idempotency middleware (HTTP-layer concept; embedded apps already control retries)
  • generated HTTP routes (no transport in the embedded macro)
  • richer migration tooling (apps typically use CREATE TABLE IF NOT EXISTS + ad-hoc ALTER TABLE migrations)
  1. Field Attributes for the schema surface available on every target
  2. Scalars including the Decimal precision contract that round-trips through SQLite TEXT storage
  3. Quickstart for the server-side path