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
| Aspect | Server (cratestack-sqlx) | Embedded native (cratestack-rusqlite) | Embedded browser (cratestack-rusqlite, wasm32) |
|---|
| API style | async, tokio | sync, no tokio | sync SQL ops, with one async init step |
| Storage handle | sqlx::PgPool | rusqlite::Connection behind a Mutex | rusqlite::Connection behind a Mutex |
| Persistence | server-managed Postgres | bundled SQLite to a file on disk | OPFS (Origin Private File System) via SAH pool |
| Build prereq | cargo build | cargo build | cargo build --target wasm32-unknown-unknown + wasm-capable clang (brew install llvm) |
| Worker requirement | n/a | n/a | Dedicated Worker only (OPFS SyncAccessHandle is worker-only) |
| Policy enforcement | full — rendered into WHERE | none — client is single-user | none — client is untrusted |
RETURNING on writes | yes | yes (SQLite ≥ 3.35) | yes |
Decimal round-trip | exact | exact (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
sudo apt-get install clang lld # Clang 14+
Install the Emscripten SDK and point CC at emcc.
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 variant | Stored as | Notes |
|---|
String, Cuid | TEXT | as-is |
Int | INTEGER | |
Float | REAL | |
Bool | INTEGER (0/1) | decoder coerces back to bool |
Bytes | BLOB | |
Uuid | TEXT | canonical hyphenated lowercase form |
DateTime<Utc> | TEXT | RFC 3339 UTC, microsecond precision |
Json | TEXT | compact serde JSON |
Decimal | TEXT | canonical 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):
sqlite_quickstart — smallest working program: open in-memory DB, bootstrap one table, CRUD a row.
sqlite_offline_first — file-backed DB, two models, Decimal money preserved exactly, filtering and partial updates.
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:
| Example | Host | Notes |
|---|
embedded-cli | clap-driven CLI | Smallest end-to-end shape; no async. |
embedded-browser-vite | Vite + wasm in a Dedicated Worker | OPFS persistence. Sibling: embedded-browser-webpack, embedded-browser-vite-pwa. |
react-vite-daisyui | React + Tailwind 4 + DaisyUI 5 | Same wasm/OPFS shape with a real component library on top. |
react-nextjs-daisyui | Next.js App Router | All 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-native | Tauri 2 desktop | Wasm-in-webview vs. native-Rust shell variants. |
embedded-flutter | Flutter + flutter_rust_bridge | macOS / Android / iOS. |
embedded-expo | React Native + Expo native module | JSON FFI envelope across the JS↔native boundary. |
embedded-daemon | tokio + notify daemon | Demonstrates the async-around-sync pattern — see the companion guide. |
embedded-webhook | axum HTTP server with its own SQLite | Same 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)
Read Next
- Field Attributes for the schema surface available on every target
- Scalars including the
Decimal precision contract that round-trips through SQLite TEXT storage
- Quickstart for the server-side path