appdb is a lightweight SurrealDB helper library for embedded Rust applications,
especially local-first desktop apps. It gives domain models a small, derive-driven
API for persistence, typed projections, graph relations, encrypted fields,
schema registration, raw SurrealQL, and explicit transaction work.
The workspace publishes two crates:
appdb: the runtime, model APIs, query helpers, and public re-exportsappdb-macros: the procedural macros re-exported byappdb
The current workspace targets Rust 2024 and requires Rust 1.95.0 or newer.
Application crates usually need appdb, serde, surrealdb, tokio, and an
error type such as anyhow:
cargo add appdb
cargo add serde --features derive
cargo add surrealdb@3.3.4 --features kv-surrealkv
cargo add tokio --features macros,rt-multi-thread
cargo add anyhowappdb re-exports its derive macros, so application code can import Store,
View, Sensitive, Bridge, and Relation from appdb.
use appdb::prelude::*;
use appdb::Store;
use serde::{Deserialize, Serialize};
use surrealdb::types::SurrealValue;
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store)]
struct User {
id: Id,
name: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_local_app_db("data/appdb".into()).await?;
let saved = User {
id: Id::from("u1"),
name: "alice".to_owned(),
}
.save()
.await?;
let loaded = User::get("u1").await?;
let all = User::list().await?;
assert_eq!(saved.name, loaded.name);
assert_eq!(all.len(), 1);
Ok(())
}The generated model methods are the intended application-facing API. Repo<T>
remains public for advanced integration seams, but normal application code should
stay on the model type.
For desktop data that should open by reading pages on demand, use the public
SQLite adapter instead of the Surreal repository path. It reuses serde model
values and appdb's Id domain while storing one WITHOUT ROWID row per key;
save updates that row in place and page uses an indexed keyset cursor.
use appdb::{Id, SqliteKey, SqliteModel, SqliteOrderKey, SqlitePageOrder, SqliteStore};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
id: Id,
created_at: i64,
body: String,
}
fn open_store() -> anyhow::Result<SqliteStore<Entry>> {
let model = SqliteModel::json("entry", |entry: &Entry| SqliteKey::from(&entry.id))?
.with_order(|entry: &Entry| Ok(SqliteOrderKey::Integer(entry.created_at)));
Ok(SqliteStore::open("data/app.sqlite", model)?)
}
let store = open_store()?;
store.save(&entry)?;
let page = store.page(50, None, SqlitePageOrder::Desc)?;The default payload is JSON. SqliteModel::with_codec is the explicit seam for
encrypted or binary payloads; relation hydration and Surreal record links are
not silently emulated by this backend. Keep the SqliteStore on a dedicated
worker when a synchronous connection would block an async runtime.
init_local_app_db(path) opens an embedded SurrealKV database with appdb's
recommended storage policy for local interactive apps, selects the app/app
namespace and database, applies registered schema items, and installs the handle
used by model, graph, view, query, and transaction helpers.
Use init_db(path) when the caller wants appdb's bare default storage policy.
Use init_db_with_options(path, InitDbOptions::default()...) only when an
advanced integration needs explicit versioning, retention, query timeouts,
transaction timeouts, AST payload storage, or the embedded disk-sync policy.
The options map only to the public SurrealDB SDK configuration surface.
Use DbRuntime::open* when a caller needs to own a runtime and install it later
with DbRuntime::install_global().
#[derive(Store)] turns a struct with named fields into a persisted model. It
generates table metadata, id helpers, stored-shape conversion, lookup metadata,
and model-level methods such as:
save,save_many,get,get_recordlist,list_limit,list().order_by(...)create_at,upsert_at,update_atdelete,delete_all,existsfind_one_id,list_record_ids
use appdb::prelude::*;
use appdb::Store;
use serde::{Deserialize, Serialize};
use surrealdb::types::SurrealValue;
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store)]
struct Post {
id: Id,
#[unique]
slug: String,
#[pagin]
created_at: i64,
title: String,
}
async fn example() -> anyhow::Result<()> {
// Startup and scrolling: one bounded indexed keyset query.
let page = Post::pagin_desc(20, None).await?;
// Full-table work is explicit and should stay off the startup path.
let ordered = Post::list().order_by("created_at", Order::Desc).await?;
let post_id = Post::find_one_id("slug", "hello").await?;
Ok(())
}#[unique] registers schema indexes for automatic lookup. appdb can resolve a
record from the declared lookup fields, including foreign-backed lookup fields
that first resolve to child RecordId values.
#[pagin] registers a stable keyset-pagination index and enables pagin_desc /
pagin_asc, which return Page<T> with items and an optional PageCursor.
Ordering a full list is limited to id and the declared pagination field, which
keeps list ordering explicit instead of accepting arbitrary field names.
Explicit-id writes use full RecordId values. create_at fails on conflict,
while upsert_at and save update the same addressed record.
AutoFill is an appdb-managed scalar for fields that should be filled by the
write path. Application code passes AutoFill::pending() instead of manually
writing the value. The Store write path resolves pending values before
persistence and returns the resolved value in the saved model.
Today the supported fill policy is #[fill(now)]. It writes the current UTC
timestamp as a string, normalized with a fixed nine-digit fractional second:
YYYY-MM-DDTHH:MM:SS.nnnnnnnnnZ. It is not an integer millisecond timestamp.
use appdb::prelude::*;
use appdb::{AutoFill, Store};
use serde::{Deserialize, Serialize};
use surrealdb::types::SurrealValue;
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store)]
struct Entry {
id: Id,
#[pagin]
#[fill(now)]
created_at: AutoFill,
title: String,
}
async fn example() -> anyhow::Result<()> {
let saved = Entry {
id: Id::from("entry-1"),
created_at: AutoFill::pending(),
title: "created by appdb".to_owned(),
}
.save()
.await?;
assert!(!saved.created_at.is_pending());
Ok(())
}Pending values are resolved on save and save_many; already resolved values
are preserved. Use AutoFill::resolved(value) only when importing or replaying a
known timestamp that should not be replaced.
Use #[foreign] when a model field should store record links but hydrate back
into full caller-facing values.
use appdb::prelude::*;
use appdb::Store;
use serde::{Deserialize, Serialize};
use surrealdb::types::SurrealValue;
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store)]
struct Author {
id: Id,
#[unique]
handle: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store)]
struct Article {
id: Id,
title: String,
#[foreign]
authors: Vec<Author>,
}Supported shapes are recursive Option<_> and Vec<_> wrappers whose leaf type
implements appdb's foreign bridge. Store models implement that bridge
automatically. Use #[derive(Bridge)] on an enum when one foreign field can point
to multiple Store model types.
#[table_as(Target)] lets an alias model reuse another Store model's table and
lookup metadata. That is useful when a caller needs a narrower Rust shape over an
existing persisted table.
For explicit-id writes where related records already exist, call
model.foreign().field_name(record_id_shape).upsert_at(...). The generated
foreign-write builder can also return a View directly with
create_at_returning::<View>, upsert_at_returning::<View>, and
update_at_returning::<View>.
Use #[relate("edge_table")] or #[back_relate("edge_table")] when a field
should live in a SurrealDB relation table instead of being stored inline on the
parent row.
use appdb::prelude::*;
use appdb::Store;
use serde::{Deserialize, Serialize};
use surrealdb::types::SurrealValue;
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store)]
struct Tag {
id: Id,
label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store)]
struct Note {
id: Id,
title: String,
#[relate("note_tags")]
tags: Vec<Tag>,
}Relation-backed fields support direct, Option<T>, Vec<T>, and
Option<Vec<T>> shapes. save, save_many, create, get, list, and
list_limit synchronize and hydrate those relation fields.
For direct graph work, use #[derive(Relation)], GraphRepo, the free graph
functions, or generated model methods such as relate_by_name,
back_relate_by_name, unrelate_by_name, outgoing_ids, incoming_ids,
outgoing, incoming, outgoing_count, incoming_count,
outgoing_count_as, and incoming_count_as.
use appdb::{Relation, Store};
#[derive(Debug, Clone, Copy, Relation)]
#[relation(name = "note_links")]
struct NoteLinks;#[derive(View)] defines a typed read projection. A table-backed View reads only
its declared fields from a Store source, so callers can expose list or detail
surfaces without loading every field from the source model.
use appdb::prelude::*;
use appdb::{Store, View};
use serde::{Deserialize, Serialize};
use surrealdb::types::SurrealValue;
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store)]
struct Post {
id: Id,
#[pagin]
created_at: i64,
title: String,
body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, View)]
#[view(source = Post)]
struct PostListItem {
id: Id,
title: String,
}
async fn example() -> anyhow::Result<()> {
let items = PostListItem::list()
.order_by("created_at", Order::Desc)
.await?;
let one = PostListItem::get("post-1").await?;
Ok(())
}View list ordering accepts id, declared View fields, and the source model's
#[pagin] field even when that field is not exposed by the View.
Nested Views are declared with #[view(nested)] and can use direct, Option<_>,
Vec<_>, or recursive Option / Vec wrappers:
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, View)]
#[view(source = Article)]
struct ArticleConfig {
id: Id,
title: String,
#[view(nested)]
authors: Vec<AuthorListItem>,
}Table-backed Views expose list, list().order_by(...), get, get_record,
find_one, find_one_id, list_records, outgoing_records,
incoming_records, and batch relation queries that preserve each owner record.
Views can also be backed by a custom SurrealQL statement. SQL-backed Views use
typed parameters and are queried through View::query(params).
use appdb::model::meta::{ModelMeta, ViewParams};
use appdb::query::RawSqlStmt;
use appdb::prelude::*;
use appdb::View;
use serde::{Deserialize, Serialize};
use surrealdb::types::{SurrealValue, Table};
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, View)]
#[view(
sql = "SELECT record::id(id) AS id, created_at, title FROM $table WHERE created_at <= $before ORDER BY created_at DESC, id DESC LIMIT $limit;",
params = RecentPosts
)]
struct RecentPost {
id: Id,
created_at: i64,
title: String,
}
struct RecentPosts {
before: i64,
limit: i64,
}
impl ViewParams for RecentPosts {
fn bind_view_params(self, stmt: RawSqlStmt) -> anyhow::Result<RawSqlStmt> {
Ok(stmt
.bind("table", Table::from(Post::storage_table()))
.bind("before", self.before)
.bind("limit", self.limit))
}
}
async fn example() -> anyhow::Result<()> {
let recent = RecentPost::query(RecentPosts {
before: 1_900_000_000,
limit: 20,
})
.await?;
Ok(())
}SQL-backed Views do not support table-source methods such as list() and
get(), because their source is the custom query itself.
#[derive(Sensitive)] encrypts fields marked with #[secure] before
persistence and decrypts them on read.
use appdb::prelude::*;
use appdb::{Sensitive, Store};
use serde::{Deserialize, Serialize};
use surrealdb::types::SurrealValue;
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue, Store, Sensitive)]
struct Profile {
id: Id,
alias: String,
#[secure]
secret: String,
#[secure]
note: Option<String>,
}Supported secure shapes include String, Option<String>, nested
Sensitive children, Option<Child>, Vec<Child>, and
SensitiveValueOf<T> for enum-bearing payloads inside an approved secure
container.
Sensitive models auto-register crypto metadata on first runtime use. Override
defaults globally with set_default_crypto_service,
set_default_crypto_account, or set_default_crypto_config, and refine models
or fields with #[crypto(...)].
By default, auto-registered crypto contexts use the OS keyring and a local
protected backup for the symmetric key. Secure field ciphertext is therefore
bound to the machine/user environment that generated that key. Copying the
database to another machine does not make #[secure] fields decryptable there;
only non-secure fields remain portable by themselves. Cross-machine decryption
requires explicitly registering a crypto context backed by the same externally
managed key.
#[unique], #[pagin], impl_schema!, and impl_hnsw_index! register schema
items through inventory. Runtime initialization hashes the sorted generated plan
in a sidecar next to the SurrealKV directory; an unchanged plan performs no
DEFINE calls, while a changed or missing plan applies the destructive index
definitions once and records the new fingerprint.
For interactive startup, use the generated Model::pagin_asc or
Model::pagin_desc methods with a cursor. They issue one indexed keyset
SELECT ... ORDER BY field, id LIMIT ... and do not materialize the table.
Reserve list() for an explicit full-table operation such as export or batch
processing. An index declaration alone cannot make a full-table list cheap.
use appdb::model::schema::{VectorDistance, VectorIndexType};
struct EventEmbedding;
appdb::impl_hnsw_index!(
EventEmbedding,
name: "event_embedding_hnsw",
table: "event_embedding",
field: "embedding",
dimension: 64,
vector_type: VectorIndexType::F32,
distance: VectorDistance::Cosine,
ef_construction: 150,
m: 12,
concurrently: true,
);HNSW index definitions validate plain identifiers, support nested field paths
such as items.embedding, and render SurrealDB DEFINE INDEX ... HNSW DDL.
For query shapes outside the derive-driven API, use RawSqlStmt and the raw
query helpers. Unbound helpers include query_raw, query_checked,
query_take, and query_return; bound helpers include query_bound,
query_bound_checked, query_bound_take, and query_bound_return.
use appdb::prelude::*;
async fn example() -> anyhow::Result<()> {
let stmt = RawSqlStmt::new("RETURN $value;").bind("value", 42);
let value: Option<i64> = query_bound_return(stmt).await?;
Ok(())
}Use run_tx and TxStmt when several statements must run inside one explicit
SurrealDB transaction.
Errors are normalized through DBError / DBErrorKind for common cases such as
not found, missing table, conflict, decode failure, invalid models, and query
response errors.
core/: source for the publishedappdbcratemacros/: source for the publishedappdb-macroscrate
Run the workspace checks from the repository root:
cargo check --workspace --all-targets
cargo test --workspace -- --test-threads 12
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check