Local-first Database
Introduction
Db is a local-first key/value + index store. The local store holds a
working set of your data (a file under the app's data directory on desktop,
IndexedDB in the browser); every write lands there immediately and is queued
in a row-level oplog; sync_now pushes the queued rows to an optional backup
endpoint and pulls the remote changes back. Being offline is a normal state
(Queued), never an error.
The surface is identical on every target and it is not SQL: desktop keeps a
pure-Rust SQLite engine as a hidden implementation detail and the browser
uses its own database, so no engine is ever shipped to the web. Reads are
requests that resume a callback (the browser's storage is asynchronous),
writes are fire-and-forget, and every result struct has a static
downcast(result) accessor. Like Pdf and AudioSink the handle is a
C-ABI value you keep in your own State; it is reference counted, so clones
share the store.
Opening + reading
use azul::db::{Db, DbConfig, DbKeyRange, DbOpenResult, DbRowsResult, DbValue, DbValueResult};
use azul::error::ResultDbDbError;
extern "C" fn on_start(data: RefAny, _info: CallbackInfo) -> Update {
// A local-only store called "notes"; `:memory:` would be throwaway.
let config = DbConfig::create("notes")
.with_backup_sync_url("https://sync.example.org/notes") // optional
.with_auth_token("...");
let _request = Db::open(config, data, on_opened);
Update::DoNothing
}
extern "C" fn on_opened(mut data: RefAny, _info: CallbackInfo, result: RefAny) -> Update {
let Some(opened) = DbOpenResult::downcast(result).into_option() else {
return Update::DoNothing;
};
let db = match opened.result {
ResultDbDbError::Ok(db) => db,
ResultDbDbError::Err(e) => {
eprintln!("open failed: {}", e.message.as_str());
return Update::DoNothing;
}
};
// Fire-and-forget writes: visible to the next read immediately.
db.set("notes", DbValue::Integer(1), DbValue::Text("hello".into()));
// Reads resume a callback with a result struct.
let _request = db.iterate("notes", DbKeyRange::all(), 0, data.clone(), on_rows);
if let Some(mut state) = data.downcast_mut::<MyState>() {
state.db = Some(db);
}
Update::DoNothing
}
extern "C" fn on_rows(_data: RefAny, _info: CallbackInfo, result: RefAny) -> Update {
let Some(answer) = DbRowsResult::downcast(result).into_option() else {
return Update::DoNothing;
};
// `rows` is a two-column grid: `key`, `value`.
for r in 0..answer.rows.num_rows() {
let _key = answer.rows.get(r, 0);
let _value = answer.rows.get(r, 1);
}
Update::RefreshDom
}
The request functions and the result struct each one resumes with:
| request | result struct |
|---|---|
Db::open(config, data, on_open) |
DbOpenResult { result: ResultDbDbError } |
db.get(store, key, data, on_result) |
DbValueResult { value: OptionDbValue, error } |
db.iterate(store, range, limit, data, on_result) |
DbRowsResult { rows: DbRows, error } |
db.query_index(store, index, range, limit, data, on_result) |
DbRowsResult |
db.subscribe(scope, data, on_change) |
DbChangeResult { store, key, value }, repeatedly |
db.sync_now(scope, data, on_result) |
DbSyncStatusResult { status: DbSyncStatus } |
db.set(store, key, value) and db.remove(store, key) return bool and
never block; db.sync_status() is a synchronous snapshot.
Values, keys and ranges
[DbValue] is Null, Integer(i64), Real(f64), Text(AzString) or
Blob(U8Vec), with is_null / as_integer / as_real / as_text
accessors. Keys are any non-Null value and order the way SQLite orders
values: Null < numbers < text < blobs. A [DbKeyRange] bounds a scan:
DbKeyRange::all(), between(lower, upper), only(key), or the struct
itself with lower_open / upper_open for exclusive bounds.
Results come back as a [DbRows] grid with the two columns key and
value: num_rows(), num_columns() and get(row, col).
Schema and indexes
Stores are created on first write. To get secondary indexes and per-store conflict policies, declare them up front:
use azul::db::{DbConflictPolicy, DbIndexSchema, DbSchema, DbStoreSchema};
let schema = DbSchema {
stores: vec![DbStoreSchema {
name: "notes".into(),
indexes: vec![DbIndexSchema {
name: "by_tag".into(),
key_path: "tag".into(), // a top-level field of a JSON object value
unique: false,
}].into(),
conflict_policy: DbConflictPolicy::LastWriteWins,
}].into(),
};
let config = DbConfig::create("notes").with_schema(schema);
Values are indexed when they are JSON objects: key_path names the field
whose value becomes the index key. db.query_index("notes", "by_tag", DbKeyRange::only(DbValue::Text(„work“.into())), 0, data, on_rows) then
returns the matching rows in index-key order.
Sync, scope and budget
Sync is explicit: sync_now pushes every dirty row, then refreshes the
given (or the configured) scope from the endpoint. The [DbSyncStatus] it
resumes with reports state (Disconnected without an endpoint, Idle,
Pushing, Pulling, Queued while offline, Error), how many operations
still wait to be pushed, when the last sync completed, how many bytes the
local store uses against its budget, and how much of the scope the working
set covers. set_on_sync_status delivers the same struct after every sync.
Local stores are size-constrained (browser quotas), so the portable model is working-set replication rather than full copies:
with_scope(DbScope { collections })limits what is pulled to a set of [DbCollectionScope]s (a store, optionally a key range or an index predicate).subscribe(scope, ...)extends the working set and reports every change inside it, local or pulled.with_local_budget_bytes(n)caps the local store. Under pressure clean rows are evicted oldest-first; dirty (unpushed) rows are never evicted before a successful push.- Conflicts (a key changed locally and remotely since the last sync) follow
the store's [
DbConflictPolicy]:LastWriteWins(default),ServerWins,ClientWins, orMergewith a merge function installed viaset_on_conflict(store, data, on_merge). The merge function is a purefn(RefAny, DbConflict) -> DbValue(returnNullto delete) invoked while the sync is applied; it must not touch the UI.
The wire protocol is a row-level oplog over HTTPS (POST <url>/push with
the queued operations as JSON, GET <url>/pull?db=..&since=<cursor> for
the remote changes), so a thin server in front of any store can serve it;
the browser host speaks the same protocol through fetch().
Feature gating
The Db handle is always present, but the desktop engine is opt-in via the
db-sqlite Cargo feature (part of build-dll) and sync needs the http
feature. Without an engine Db::open resumes with
DbErrorKind::NoEngine; without http, sync_now reports Error naming
the missing feature. PlatformCapability::sql() and ::sync() answer these
questions at runtime.
See also
- background-tasks - timers, for driving
sync_nowperiodically on desktop. - architecture - keeping the handle in your
State.