Offline-First with Embedded SQLite
CrateStack’s embedded backend,cratestack-sqlite (backed by the cratestack-rusqlite implementation crate), runs the same .cstack schemas you use on the server against a local SQLite database. Since 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-unknownwith OPFS-backed persistence
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.40.
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-pg) and the embedded target (SQLite via cratestack-sqlite, implemented on top of 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 barecratestack crate on crates.io is a documentation-only landing page with no exports — it does not provide RusqliteRuntime, DDL helpers, or anything else you need. The schema macros hardcode ::cratestack::* paths in their generated code, so embedded consumers must rename the cratestack-sqlite facade crate to cratestack via Cargo’s package = field. Depend on all of the following:
0.14 requirements float across the whole 0.14.x line rather than pinning a patch — every
cratestack crate is released in lockstep off one workspace version, so mixing patch levels is never
what you want, and a pinned patch here goes stale on the next release. Pin exactly only if you have
a specific reason to.
cratestack-macros and cratestack-rusqlite are the crates you import from directly (schema macro and runtime/delegate types, respectively); the cratestack = { package = "cratestack-sqlite", ... } line exists purely so the macro-generated ::cratestack::* paths resolve — it isn’t optional, even though you won’t use cratestack::... anywhere in your own code. serde is required for the same reason: the generated code names serde directly, so without it the build fails with cannot find module or crate `serde` .
Browser-specific build prerequisites
sqlite-wasm-rs (used by rusqlite 0.40 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.
- macOS
- Linux
- Emscripten
Schema
Useprovider = "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.Nothing on the device fills an
@default(auth().field) or a database-generated default, so the embedded Create…Input keeps those fields: auth() ones as required, dbgenerated() and registered-function ones as an Option (since 0.17.0, cratestack#1234). Literal defaults and now()/uuid()/cuid() are filled by the runtime. See Defaults on the embedded runtime.Minimal Setup — Native
Minimal Setup — Browser (OPFS)
OPFSSyncAccessHandle 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.
Filtering, Ordering, Paging
The fluent delegate API mirrors the server side. Field accessors are emitted byinclude_embedded_schema! per model:
where_expr, and, or, and relation filters from cratestack-sql all work here.
Relation filters and sorts here read the raw related table. On the server role they apply the
related model’s read policy and @@soft_delete filter (since 0.13.0); the embedded role enforces
no policy by design, so it ignores that scope, sees tombstoned related rows, and evaluates a
filter or sort over a self-relation (a model related to its own table) uncorrelated, as before.
A hand-built relation filter or sort still has to pass a RelatedReadScope, which every
relation-hop constructor takes since 0.13.0: <RELATED>_MODEL.related_read_scope() or
RelatedReadScope::Unscoped. The embedded backend ignores it either way. See
Relation filters and sorts.
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.
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. Relation filters and sorts do not: they still see tombstoned related rows. @@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:
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-sqlite in the framework repository for a complete template.
Examples in the Framework Repo
Cargo-native examples (run withcargo run --example <name> -p cratestack-sqlite):
sqlite_quickstart— smallest working program: open in-memory DB, bootstrap one table, CRUD a row.sqlite_offline_first— file-backed DB, two models,Decimalmoney preserved exactly, filtering and partial updates.sqlite_ffi_dispatch— the JSON-bytes FFI dispatcher you’d wrap withflutter_rust_bridge.
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 as of 0.7.15:- policy enforcement at the storage layer (by design — see above)
@@auditand@@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-hocALTER TABLEmigrations)
Read Next
- Field Attributes for the schema surface available on every target
- Scalars including the
Decimalprecision contract that round-trips through SQLite TEXT storage - Quickstart for the server-side path