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

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:
For a binary-size-sensitive embedded build that doesn’t need the server stack, depend directly on the leaf crates:

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.
Then:

Schema

Use provider = "sqlite" on the datasource block. Everything else in your .cstack schema works the same:
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

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.
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:
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: 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:
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: 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