Repository files navigation

somnia

somnia — type-safe SurrealDB ORM for Rust

crates.ioDownloadsdocs.rsCIMSRVlicenseRead the write-upStarsFollow @vbasky

A type-safe SurrealDB ORM for Rust — a typed query builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style migrations.

somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.

[dependencies]
somnia = "0.8"

Why

Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names, unescaped values, record-link mistakes, and projection drift. somnia lets your Rust types describe the schema once and gives you:

  • Typed query buildingPost::table().select(...).filter(Post::title().eq("hello"))
  • Graph traversal — query across RELATE edges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive @.{..} paths.
  • #[derive(SurrealRecord)] — typed column accessors, table metadata, and schema DDL generated from the struct.
  • Schema as codeup() / down() emit DEFINE TABLE / DEFINE FIELD / DEFINE INDEX / REMOVE TABLE from the Rust type.
  • Diesel-style migrations — a Migrator that applies up.surql / reverts down.surql from timestamped folders, with applied-state tracking.
  • The rest of SurrealQL, typed — atomic transactions, $param binding, subqueries, IF/FOR control flow, and DEFINE EVENT/FUNCTION/ANALYZER/ PARAM — so you rarely drop to Raw(...).

somniainlines literals (with proper escaping) rather than relying on bind parameters — to_surrealql() returns a ready-to-run statement string, which keeps generated queries transparent and easy to log.

Quick start

Define a record

use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}

Build queries

use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();

For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE, string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch — the builder still owns the statement structure, table names, and record links.

Schema as code

#[derive(SurrealRecord)] also implements SurrealSchema:

use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;

Field attributes: #[field(thing)] (record id), record = "table" (record<table>), default = "…", value = "…", ty = "…" (full type override), flexible, name = "…", skip. Table attributes: #[table("name")], #[table("name", schemaless, permissions = "NONE")].

Field types are mapped from the Rust type — including typed arrays (Vec<T>array<…>), Option<…>, records, duration, and decimal.

Add indexes with a repeatable container attribute; they're emitted by up() (after the fields) and exposed via SurrealSchema::define_indexes():

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}

For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the DefineIndex builder directly.

Migrations

Lay out migrations Diesel-style — one timestamped folder per migration with up.surql and down.surql:

migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surql
use somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}

Applied migrations are tracked in a _somnia_migrations table, so re-running only applies what's pending.

Connecting and authentication

SomniaClient::connect signs in as a root user and selects a namespace/database:

let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;

For other auth levels, connect_with takes a Credentials enum (root, namespace, database, or a pre-issued token):

use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;

Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD method with arbitrary serializable params and returns the issued token; a JWT can be re-attached with authenticate, and invalidate clears the session:

let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's auth

connect_anonymous(endpoint, ns, db) connects without signing in — handy for the embedded mem:// / rocksdb:// engines or deferred auth.

Live queries

live_select::<T>() starts a LIVE SELECT on T's table and streams typed change notifications; dropping the stream issues KILL server-side:

use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.

More query power

somnia models much of SurrealDB's surface as typed builders, so you rarely fall back to raw strings. Every builder renders to a plain SurrealQL string via to_surrealql() — shown in the // comments below — so nothing is hidden. The examples build on the Post and Comment structs from Quick start.

Parameters and LET

By default somnia inlines values as escaped literals. For statement reuse or binary-safe values, switch to $param binding with to_surrealql_with_params(), which returns the SQL plus a map of bound values; execute it with client.query_with_params(...):

let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,&params).await?;

Bind one value to a name and reuse it across a query with Param, and declare a session variable with LetVar:

use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"

Transactions

Transaction wraps statements in BEGIN … COMMIT so they apply atomically — SurrealDB rolls the whole block back if any statement errors. .cancel() ends with CANCEL TRANSACTION to roll back explicitly:

use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothing

Subqueries and IN

A Select is itself an expression, so you can nest one inside a WHERE … IN, use it as a scalar, or read FROM it. Columns and idents gain in_expr / not_in_expr:

use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)

SELECT modifiers

VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and EXPLAIN:

let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAIN

SurrealDB 3.1 doesn't accept PARALLEL as a SELECT clause, so somnia doesn't emit it.

Control flow — IF and FOR

IfExpr is an expression you can drop into a projection, a SET value, a RETURN, or a WHERE. For builds an iterating block:

use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }

Schema DDL beyond tables, fields, and indexes

Builders for the remaining DEFINE statements — events, functions, analyzers, and params — each with a matching ::remove(...) inverse:

use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5

Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes, which flow into the generated DEFINE FIELD:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;

Full-text and vector search

Pair a FULLTEXT or HNSW index (see Schema as code) with the search / nearest builders:

// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"

Closures and record references

Build anonymous functions with Closure, and mark record links as tracked REFERENCEs in the derive:

use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}

Graph edges

Define an edge record once and derive its SurrealEdge impl (the edge name comes from #[table(...)]) — no hand-written impl SurrealEdge:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}

Then create edges with RELATE and query across them with typed paths — see the Path::out::<Wrote>()… examples under Build queries above, including recursive @.{..} traversal.

Crates

CrateDescription
somniaUmbrella crate: client, migrator, re-exports. Start here.
somnia-coreQuery builder, expression tree, SurrealRecord/SurrealSchema traits.
somnia-derive#[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros.
somnia-cliDiesel-cli-style migration runner (the somnia binary).

CLI

A standalone migration runner, modeled on diesel-cli. Install it with Cargo or Homebrew (both provide the somnia binary):

cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)

Then:

somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pending

Connection settings are read from flags or environment variables (--help for the full list).

Status

0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema generation, migrator, typed auth, live queries, and full-text/vector search helpers all covered by integration tests that run on an in-memory engine). The API may evolve before 1.0. See the roadmap for what's covered today and what's planned on the way to 1.0.

MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.

License

Licensed under the Apache License, Version 2.0.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

A type-safe SurrealDB ORM for Rust — typed query builder, #[derive(SurrealRecord)], schema generation, and Diesel-style migrations. "Somnia" is Latin for "dreams" (SurrealDB is surreal/dreamlike)

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

somnia

somnia — type-safe SurrealDB ORM for Rust

crates.ioDownloadsdocs.rsCIMSRVlicenseRead the write-upStarsFollow @vbasky

A type-safe SurrealDB ORM for Rust — a typed query builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style migrations.

somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.

[dependencies]
somnia = "0.8"

Why

Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names, unescaped values, record-link mistakes, and projection drift. somnia lets your Rust types describe the schema once and gives you:

  • Typed query buildingPost::table().select(...).filter(Post::title().eq("hello"))
  • Graph traversal — query across RELATE edges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive @.{..} paths.
  • #[derive(SurrealRecord)] — typed column accessors, table metadata, and schema DDL generated from the struct.
  • Schema as codeup() / down() emit DEFINE TABLE / DEFINE FIELD / DEFINE INDEX / REMOVE TABLE from the Rust type.
  • Diesel-style migrations — a Migrator that applies up.surql / reverts down.surql from timestamped folders, with applied-state tracking.
  • The rest of SurrealQL, typed — atomic transactions, $param binding, subqueries, IF/FOR control flow, and DEFINE EVENT/FUNCTION/ANALYZER/ PARAM — so you rarely drop to Raw(...).

somniainlines literals (with proper escaping) rather than relying on bind parameters — to_surrealql() returns a ready-to-run statement string, which keeps generated queries transparent and easy to log.

Quick start

Define a record

use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}

Build queries

use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();

For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE, string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch — the builder still owns the statement structure, table names, and record links.

Schema as code

#[derive(SurrealRecord)] also implements SurrealSchema:

use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;

Field attributes: #[field(thing)] (record id), record = "table" (record<table>), default = "…", value = "…", ty = "…" (full type override), flexible, name = "…", skip. Table attributes: #[table("name")], #[table("name", schemaless, permissions = "NONE")].

Field types are mapped from the Rust type — including typed arrays (Vec<T>array<…>), Option<…>, records, duration, and decimal.

Add indexes with a repeatable container attribute; they're emitted by up() (after the fields) and exposed via SurrealSchema::define_indexes():

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}

For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the DefineIndex builder directly.

Migrations

Lay out migrations Diesel-style — one timestamped folder per migration with up.surql and down.surql:

migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surql
use somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}

Applied migrations are tracked in a _somnia_migrations table, so re-running only applies what's pending.

Connecting and authentication

SomniaClient::connect signs in as a root user and selects a namespace/database:

let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;

For other auth levels, connect_with takes a Credentials enum (root, namespace, database, or a pre-issued token):

use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;

Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD method with arbitrary serializable params and returns the issued token; a JWT can be re-attached with authenticate, and invalidate clears the session:

let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's auth

connect_anonymous(endpoint, ns, db) connects without signing in — handy for the embedded mem:// / rocksdb:// engines or deferred auth.

Live queries

live_select::<T>() starts a LIVE SELECT on T's table and streams typed change notifications; dropping the stream issues KILL server-side:

use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.

More query power

somnia models much of SurrealDB's surface as typed builders, so you rarely fall back to raw strings. Every builder renders to a plain SurrealQL string via to_surrealql() — shown in the // comments below — so nothing is hidden. The examples build on the Post and Comment structs from Quick start.

Parameters and LET

By default somnia inlines values as escaped literals. For statement reuse or binary-safe values, switch to $param binding with to_surrealql_with_params(), which returns the SQL plus a map of bound values; execute it with client.query_with_params(...):

let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,&params).await?;

Bind one value to a name and reuse it across a query with Param, and declare a session variable with LetVar:

use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"

Transactions

Transaction wraps statements in BEGIN … COMMIT so they apply atomically — SurrealDB rolls the whole block back if any statement errors. .cancel() ends with CANCEL TRANSACTION to roll back explicitly:

use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothing

Subqueries and IN

A Select is itself an expression, so you can nest one inside a WHERE … IN, use it as a scalar, or read FROM it. Columns and idents gain in_expr / not_in_expr:

use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)

SELECT modifiers

VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and EXPLAIN:

let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAIN

SurrealDB 3.1 doesn't accept PARALLEL as a SELECT clause, so somnia doesn't emit it.

Control flow — IF and FOR

IfExpr is an expression you can drop into a projection, a SET value, a RETURN, or a WHERE. For builds an iterating block:

use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }

Schema DDL beyond tables, fields, and indexes

Builders for the remaining DEFINE statements — events, functions, analyzers, and params — each with a matching ::remove(...) inverse:

use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5

Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes, which flow into the generated DEFINE FIELD:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;

Full-text and vector search

Pair a FULLTEXT or HNSW index (see Schema as code) with the search / nearest builders:

// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"

Closures and record references

Build anonymous functions with Closure, and mark record links as tracked REFERENCEs in the derive:

use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}

Graph edges

Define an edge record once and derive its SurrealEdge impl (the edge name comes from #[table(...)]) — no hand-written impl SurrealEdge:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}

Then create edges with RELATE and query across them with typed paths — see the Path::out::<Wrote>()… examples under Build queries above, including recursive @.{..} traversal.

Crates

CrateDescription
somniaUmbrella crate: client, migrator, re-exports. Start here.
somnia-coreQuery builder, expression tree, SurrealRecord/SurrealSchema traits.
somnia-derive#[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros.
somnia-cliDiesel-cli-style migration runner (the somnia binary).

CLI

A standalone migration runner, modeled on diesel-cli. Install it with Cargo or Homebrew (both provide the somnia binary):

cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)

Then:

somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pending

Connection settings are read from flags or environment variables (--help for the full list).

Status

0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema generation, migrator, typed auth, live queries, and full-text/vector search helpers all covered by integration tests that run on an in-memory engine). The API may evolve before 1.0. See the roadmap for what's covered today and what's planned on the way to 1.0.

MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.

License

Licensed under the Apache License, Version 2.0.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

A type-safe SurrealDB ORM for Rust — typed query builder, #[derive(SurrealRecord)], schema generation, and Diesel-style migrations. "Somnia" is Latin for "dreams" (SurrealDB is surreal/dreamlike)

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

somnia

somnia — type-safe SurrealDB ORM for Rust

crates.ioDownloadsdocs.rsCIMSRVlicenseRead the write-upStarsFollow @vbasky

A type-safe SurrealDB ORM for Rust — a typed query builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style migrations.

somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.

[dependencies]
somnia = "0.8"

Why

Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names, unescaped values, record-link mistakes, and projection drift. somnia lets your Rust types describe the schema once and gives you:

  • Typed query buildingPost::table().select(...).filter(Post::title().eq("hello"))
  • Graph traversal — query across RELATE edges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive @.{..} paths.
  • #[derive(SurrealRecord)] — typed column accessors, table metadata, and schema DDL generated from the struct.
  • Schema as codeup() / down() emit DEFINE TABLE / DEFINE FIELD / DEFINE INDEX / REMOVE TABLE from the Rust type.
  • Diesel-style migrations — a Migrator that applies up.surql / reverts down.surql from timestamped folders, with applied-state tracking.
  • The rest of SurrealQL, typed — atomic transactions, $param binding, subqueries, IF/FOR control flow, and DEFINE EVENT/FUNCTION/ANALYZER/ PARAM — so you rarely drop to Raw(...).

somniainlines literals (with proper escaping) rather than relying on bind parameters — to_surrealql() returns a ready-to-run statement string, which keeps generated queries transparent and easy to log.

Quick start

Define a record

use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}

Build queries

use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();

For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE, string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch — the builder still owns the statement structure, table names, and record links.

Schema as code

#[derive(SurrealRecord)] also implements SurrealSchema:

use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;

Field attributes: #[field(thing)] (record id), record = "table" (record<table>), default = "…", value = "…", ty = "…" (full type override), flexible, name = "…", skip. Table attributes: #[table("name")], #[table("name", schemaless, permissions = "NONE")].

Field types are mapped from the Rust type — including typed arrays (Vec<T>array<…>), Option<…>, records, duration, and decimal.

Add indexes with a repeatable container attribute; they're emitted by up() (after the fields) and exposed via SurrealSchema::define_indexes():

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}

For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the DefineIndex builder directly.

Migrations

Lay out migrations Diesel-style — one timestamped folder per migration with up.surql and down.surql:

migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surql
use somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}

Applied migrations are tracked in a _somnia_migrations table, so re-running only applies what's pending.

Connecting and authentication

SomniaClient::connect signs in as a root user and selects a namespace/database:

let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;

For other auth levels, connect_with takes a Credentials enum (root, namespace, database, or a pre-issued token):

use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;

Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD method with arbitrary serializable params and returns the issued token; a JWT can be re-attached with authenticate, and invalidate clears the session:

let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's auth

connect_anonymous(endpoint, ns, db) connects without signing in — handy for the embedded mem:// / rocksdb:// engines or deferred auth.

Live queries

live_select::<T>() starts a LIVE SELECT on T's table and streams typed change notifications; dropping the stream issues KILL server-side:

use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.

More query power

somnia models much of SurrealDB's surface as typed builders, so you rarely fall back to raw strings. Every builder renders to a plain SurrealQL string via to_surrealql() — shown in the // comments below — so nothing is hidden. The examples build on the Post and Comment structs from Quick start.

Parameters and LET

By default somnia inlines values as escaped literals. For statement reuse or binary-safe values, switch to $param binding with to_surrealql_with_params(), which returns the SQL plus a map of bound values; execute it with client.query_with_params(...):

let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,&params).await?;

Bind one value to a name and reuse it across a query with Param, and declare a session variable with LetVar:

use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"

Transactions

Transaction wraps statements in BEGIN … COMMIT so they apply atomically — SurrealDB rolls the whole block back if any statement errors. .cancel() ends with CANCEL TRANSACTION to roll back explicitly:

use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothing

Subqueries and IN

A Select is itself an expression, so you can nest one inside a WHERE … IN, use it as a scalar, or read FROM it. Columns and idents gain in_expr / not_in_expr:

use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)

SELECT modifiers

VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and EXPLAIN:

let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAIN

SurrealDB 3.1 doesn't accept PARALLEL as a SELECT clause, so somnia doesn't emit it.

Control flow — IF and FOR

IfExpr is an expression you can drop into a projection, a SET value, a RETURN, or a WHERE. For builds an iterating block:

use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }

Schema DDL beyond tables, fields, and indexes

Builders for the remaining DEFINE statements — events, functions, analyzers, and params — each with a matching ::remove(...) inverse:

use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5

Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes, which flow into the generated DEFINE FIELD:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;

Full-text and vector search

Pair a FULLTEXT or HNSW index (see Schema as code) with the search / nearest builders:

// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"

Closures and record references

Build anonymous functions with Closure, and mark record links as tracked REFERENCEs in the derive:

use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}

Graph edges

Define an edge record once and derive its SurrealEdge impl (the edge name comes from #[table(...)]) — no hand-written impl SurrealEdge:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}

Then create edges with RELATE and query across them with typed paths — see the Path::out::<Wrote>()… examples under Build queries above, including recursive @.{..} traversal.

Crates

CrateDescription
somniaUmbrella crate: client, migrator, re-exports. Start here.
somnia-coreQuery builder, expression tree, SurrealRecord/SurrealSchema traits.
somnia-derive#[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros.
somnia-cliDiesel-cli-style migration runner (the somnia binary).

CLI

A standalone migration runner, modeled on diesel-cli. Install it with Cargo or Homebrew (both provide the somnia binary):

cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)

Then:

somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pending

Connection settings are read from flags or environment variables (--help for the full list).

Status

0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema generation, migrator, typed auth, live queries, and full-text/vector search helpers all covered by integration tests that run on an in-memory engine). The API may evolve before 1.0. See the roadmap for what's covered today and what's planned on the way to 1.0.

MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.

License

Licensed under the Apache License, Version 2.0.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

A type-safe SurrealDB ORM for Rust — typed query builder, #[derive(SurrealRecord)], schema generation, and Diesel-style migrations. "Somnia" is Latin for "dreams" (SurrealDB is surreal/dreamlike)

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

somnia

somnia — type-safe SurrealDB ORM for Rust

crates.ioDownloadsdocs.rsCIMSRVlicenseRead the write-upStarsFollow @vbasky

A type-safe SurrealDB ORM for Rust — a typed query builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style migrations.

somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.

[dependencies]
somnia = "0.8"

Why

Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names, unescaped values, record-link mistakes, and projection drift. somnia lets your Rust types describe the schema once and gives you:

  • Typed query buildingPost::table().select(...).filter(Post::title().eq("hello"))
  • Graph traversal — query across RELATE edges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive @.{..} paths.
  • #[derive(SurrealRecord)] — typed column accessors, table metadata, and schema DDL generated from the struct.
  • Schema as codeup() / down() emit DEFINE TABLE / DEFINE FIELD / DEFINE INDEX / REMOVE TABLE from the Rust type.
  • Diesel-style migrations — a Migrator that applies up.surql / reverts down.surql from timestamped folders, with applied-state tracking.
  • The rest of SurrealQL, typed — atomic transactions, $param binding, subqueries, IF/FOR control flow, and DEFINE EVENT/FUNCTION/ANALYZER/ PARAM — so you rarely drop to Raw(...).

somniainlines literals (with proper escaping) rather than relying on bind parameters — to_surrealql() returns a ready-to-run statement string, which keeps generated queries transparent and easy to log.

Quick start

Define a record

use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}

Build queries

use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();

For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE, string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch — the builder still owns the statement structure, table names, and record links.

Schema as code

#[derive(SurrealRecord)] also implements SurrealSchema:

use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;

Field attributes: #[field(thing)] (record id), record = "table" (record<table>), default = "…", value = "…", ty = "…" (full type override), flexible, name = "…", skip. Table attributes: #[table("name")], #[table("name", schemaless, permissions = "NONE")].

Field types are mapped from the Rust type — including typed arrays (Vec<T>array<…>), Option<…>, records, duration, and decimal.

Add indexes with a repeatable container attribute; they're emitted by up() (after the fields) and exposed via SurrealSchema::define_indexes():

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}

For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the DefineIndex builder directly.

Migrations

Lay out migrations Diesel-style — one timestamped folder per migration with up.surql and down.surql:

migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surql
use somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}

Applied migrations are tracked in a _somnia_migrations table, so re-running only applies what's pending.

Connecting and authentication

SomniaClient::connect signs in as a root user and selects a namespace/database:

let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;

For other auth levels, connect_with takes a Credentials enum (root, namespace, database, or a pre-issued token):

use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;

Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD method with arbitrary serializable params and returns the issued token; a JWT can be re-attached with authenticate, and invalidate clears the session:

let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's auth

connect_anonymous(endpoint, ns, db) connects without signing in — handy for the embedded mem:// / rocksdb:// engines or deferred auth.

Live queries

live_select::<T>() starts a LIVE SELECT on T's table and streams typed change notifications; dropping the stream issues KILL server-side:

use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.

More query power

somnia models much of SurrealDB's surface as typed builders, so you rarely fall back to raw strings. Every builder renders to a plain SurrealQL string via to_surrealql() — shown in the // comments below — so nothing is hidden. The examples build on the Post and Comment structs from Quick start.

Parameters and LET

By default somnia inlines values as escaped literals. For statement reuse or binary-safe values, switch to $param binding with to_surrealql_with_params(), which returns the SQL plus a map of bound values; execute it with client.query_with_params(...):

let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,&params).await?;

Bind one value to a name and reuse it across a query with Param, and declare a session variable with LetVar:

use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"

Transactions

Transaction wraps statements in BEGIN … COMMIT so they apply atomically — SurrealDB rolls the whole block back if any statement errors. .cancel() ends with CANCEL TRANSACTION to roll back explicitly:

use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothing

Subqueries and IN

A Select is itself an expression, so you can nest one inside a WHERE … IN, use it as a scalar, or read FROM it. Columns and idents gain in_expr / not_in_expr:

use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)

SELECT modifiers

VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and EXPLAIN:

let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAIN

SurrealDB 3.1 doesn't accept PARALLEL as a SELECT clause, so somnia doesn't emit it.

Control flow — IF and FOR

IfExpr is an expression you can drop into a projection, a SET value, a RETURN, or a WHERE. For builds an iterating block:

use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }

Schema DDL beyond tables, fields, and indexes

Builders for the remaining DEFINE statements — events, functions, analyzers, and params — each with a matching ::remove(...) inverse:

use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5

Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes, which flow into the generated DEFINE FIELD:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;

Full-text and vector search

Pair a FULLTEXT or HNSW index (see Schema as code) with the search / nearest builders:

// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"

Closures and record references

Build anonymous functions with Closure, and mark record links as tracked REFERENCEs in the derive:

use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}

Graph edges

Define an edge record once and derive its SurrealEdge impl (the edge name comes from #[table(...)]) — no hand-written impl SurrealEdge:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}

Then create edges with RELATE and query across them with typed paths — see the Path::out::<Wrote>()… examples under Build queries above, including recursive @.{..} traversal.

Crates

CrateDescription
somniaUmbrella crate: client, migrator, re-exports. Start here.
somnia-coreQuery builder, expression tree, SurrealRecord/SurrealSchema traits.
somnia-derive#[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros.
somnia-cliDiesel-cli-style migration runner (the somnia binary).

CLI

A standalone migration runner, modeled on diesel-cli. Install it with Cargo or Homebrew (both provide the somnia binary):

cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)

Then:

somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pending

Connection settings are read from flags or environment variables (--help for the full list).

Status

0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema generation, migrator, typed auth, live queries, and full-text/vector search helpers all covered by integration tests that run on an in-memory engine). The API may evolve before 1.0. See the roadmap for what's covered today and what's planned on the way to 1.0.

MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.

License

Licensed under the Apache License, Version 2.0.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

A type-safe SurrealDB ORM for Rust — typed query builder, #[derive(SurrealRecord)], schema generation, and Diesel-style migrations. "Somnia" is Latin for "dreams" (SurrealDB is surreal/dreamlike)

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

somnia

somnia — type-safe SurrealDB ORM for Rust

crates.ioDownloadsdocs.rsCIMSRVlicenseRead the write-upStarsFollow @vbasky

A type-safe SurrealDB ORM for Rust — a typed query builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style migrations.

somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.

[dependencies]
somnia = "0.8"

Why

Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names, unescaped values, record-link mistakes, and projection drift. somnia lets your Rust types describe the schema once and gives you:

  • Typed query buildingPost::table().select(...).filter(Post::title().eq("hello"))
  • Graph traversal — query across RELATE edges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive @.{..} paths.
  • #[derive(SurrealRecord)] — typed column accessors, table metadata, and schema DDL generated from the struct.
  • Schema as codeup() / down() emit DEFINE TABLE / DEFINE FIELD / DEFINE INDEX / REMOVE TABLE from the Rust type.
  • Diesel-style migrations — a Migrator that applies up.surql / reverts down.surql from timestamped folders, with applied-state tracking.
  • The rest of SurrealQL, typed — atomic transactions, $param binding, subqueries, IF/FOR control flow, and DEFINE EVENT/FUNCTION/ANALYZER/ PARAM — so you rarely drop to Raw(...).

somniainlines literals (with proper escaping) rather than relying on bind parameters — to_surrealql() returns a ready-to-run statement string, which keeps generated queries transparent and easy to log.

Quick start

Define a record

use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}

Build queries

use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();

For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE, string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch — the builder still owns the statement structure, table names, and record links.

Schema as code

#[derive(SurrealRecord)] also implements SurrealSchema:

use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;

Field attributes: #[field(thing)] (record id), record = "table" (record<table>), default = "…", value = "…", ty = "…" (full type override), flexible, name = "…", skip. Table attributes: #[table("name")], #[table("name", schemaless, permissions = "NONE")].

Field types are mapped from the Rust type — including typed arrays (Vec<T>array<…>), Option<…>, records, duration, and decimal.

Add indexes with a repeatable container attribute; they're emitted by up() (after the fields) and exposed via SurrealSchema::define_indexes():

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}

For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the DefineIndex builder directly.

Migrations

Lay out migrations Diesel-style — one timestamped folder per migration with up.surql and down.surql:

migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surql
use somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}

Applied migrations are tracked in a _somnia_migrations table, so re-running only applies what's pending.

Connecting and authentication

SomniaClient::connect signs in as a root user and selects a namespace/database:

let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;

For other auth levels, connect_with takes a Credentials enum (root, namespace, database, or a pre-issued token):

use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;

Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD method with arbitrary serializable params and returns the issued token; a JWT can be re-attached with authenticate, and invalidate clears the session:

let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's auth

connect_anonymous(endpoint, ns, db) connects without signing in — handy for the embedded mem:// / rocksdb:// engines or deferred auth.

Live queries

live_select::<T>() starts a LIVE SELECT on T's table and streams typed change notifications; dropping the stream issues KILL server-side:

use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.

More query power

somnia models much of SurrealDB's surface as typed builders, so you rarely fall back to raw strings. Every builder renders to a plain SurrealQL string via to_surrealql() — shown in the // comments below — so nothing is hidden. The examples build on the Post and Comment structs from Quick start.

Parameters and LET

By default somnia inlines values as escaped literals. For statement reuse or binary-safe values, switch to $param binding with to_surrealql_with_params(), which returns the SQL plus a map of bound values; execute it with client.query_with_params(...):

let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,&params).await?;

Bind one value to a name and reuse it across a query with Param, and declare a session variable with LetVar:

use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"

Transactions

Transaction wraps statements in BEGIN … COMMIT so they apply atomically — SurrealDB rolls the whole block back if any statement errors. .cancel() ends with CANCEL TRANSACTION to roll back explicitly:

use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothing

Subqueries and IN

A Select is itself an expression, so you can nest one inside a WHERE … IN, use it as a scalar, or read FROM it. Columns and idents gain in_expr / not_in_expr:

use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)

SELECT modifiers

VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and EXPLAIN:

let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAIN

SurrealDB 3.1 doesn't accept PARALLEL as a SELECT clause, so somnia doesn't emit it.

Control flow — IF and FOR

IfExpr is an expression you can drop into a projection, a SET value, a RETURN, or a WHERE. For builds an iterating block:

use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }

Schema DDL beyond tables, fields, and indexes

Builders for the remaining DEFINE statements — events, functions, analyzers, and params — each with a matching ::remove(...) inverse:

use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5

Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes, which flow into the generated DEFINE FIELD:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;

Full-text and vector search

Pair a FULLTEXT or HNSW index (see Schema as code) with the search / nearest builders:

// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"

Closures and record references

Build anonymous functions with Closure, and mark record links as tracked REFERENCEs in the derive:

use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}

Graph edges

Define an edge record once and derive its SurrealEdge impl (the edge name comes from #[table(...)]) — no hand-written impl SurrealEdge:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}

Then create edges with RELATE and query across them with typed paths — see the Path::out::<Wrote>()… examples under Build queries above, including recursive @.{..} traversal.

Crates

CrateDescription
somniaUmbrella crate: client, migrator, re-exports. Start here.
somnia-coreQuery builder, expression tree, SurrealRecord/SurrealSchema traits.
somnia-derive#[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros.
somnia-cliDiesel-cli-style migration runner (the somnia binary).

CLI

A standalone migration runner, modeled on diesel-cli. Install it with Cargo or Homebrew (both provide the somnia binary):

cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)

Then:

somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pending

Connection settings are read from flags or environment variables (--help for the full list).

Status

0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema generation, migrator, typed auth, live queries, and full-text/vector search helpers all covered by integration tests that run on an in-memory engine). The API may evolve before 1.0. See the roadmap for what's covered today and what's planned on the way to 1.0.

MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.

License

Licensed under the Apache License, Version 2.0.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

A type-safe SurrealDB ORM for Rust — typed query builder, #[derive(SurrealRecord)], schema generation, and Diesel-style migrations. "Somnia" is Latin for "dreams" (SurrealDB is surreal/dreamlike)

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

somnia

somnia — type-safe SurrealDB ORM for Rust

crates.ioDownloadsdocs.rsCIMSRVlicenseRead the write-upStarsFollow @vbasky

A type-safe SurrealDB ORM for Rust — a typed query builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style migrations.

somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.

[dependencies]
somnia = "0.8"

Why

Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names, unescaped values, record-link mistakes, and projection drift. somnia lets your Rust types describe the schema once and gives you:

  • Typed query buildingPost::table().select(...).filter(Post::title().eq("hello"))
  • Graph traversal — query across RELATE edges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive @.{..} paths.
  • #[derive(SurrealRecord)] — typed column accessors, table metadata, and schema DDL generated from the struct.
  • Schema as codeup() / down() emit DEFINE TABLE / DEFINE FIELD / DEFINE INDEX / REMOVE TABLE from the Rust type.
  • Diesel-style migrations — a Migrator that applies up.surql / reverts down.surql from timestamped folders, with applied-state tracking.
  • The rest of SurrealQL, typed — atomic transactions, $param binding, subqueries, IF/FOR control flow, and DEFINE EVENT/FUNCTION/ANALYZER/ PARAM — so you rarely drop to Raw(...).

somniainlines literals (with proper escaping) rather than relying on bind parameters — to_surrealql() returns a ready-to-run statement string, which keeps generated queries transparent and easy to log.

Quick start

Define a record

use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}

Build queries

use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();

For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE, string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch — the builder still owns the statement structure, table names, and record links.

Schema as code

#[derive(SurrealRecord)] also implements SurrealSchema:

use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;

Field attributes: #[field(thing)] (record id), record = "table" (record<table>), default = "…", value = "…", ty = "…" (full type override), flexible, name = "…", skip. Table attributes: #[table("name")], #[table("name", schemaless, permissions = "NONE")].

Field types are mapped from the Rust type — including typed arrays (Vec<T>array<…>), Option<…>, records, duration, and decimal.

Add indexes with a repeatable container attribute; they're emitted by up() (after the fields) and exposed via SurrealSchema::define_indexes():

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}

For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the DefineIndex builder directly.

Migrations

Lay out migrations Diesel-style — one timestamped folder per migration with up.surql and down.surql:

migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surql
use somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}

Applied migrations are tracked in a _somnia_migrations table, so re-running only applies what's pending.

Connecting and authentication

SomniaClient::connect signs in as a root user and selects a namespace/database:

let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;

For other auth levels, connect_with takes a Credentials enum (root, namespace, database, or a pre-issued token):

use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;

Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD method with arbitrary serializable params and returns the issued token; a JWT can be re-attached with authenticate, and invalidate clears the session:

let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's auth

connect_anonymous(endpoint, ns, db) connects without signing in — handy for the embedded mem:// / rocksdb:// engines or deferred auth.

Live queries

live_select::<T>() starts a LIVE SELECT on T's table and streams typed change notifications; dropping the stream issues KILL server-side:

use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.

More query power

somnia models much of SurrealDB's surface as typed builders, so you rarely fall back to raw strings. Every builder renders to a plain SurrealQL string via to_surrealql() — shown in the // comments below — so nothing is hidden. The examples build on the Post and Comment structs from Quick start.

Parameters and LET

By default somnia inlines values as escaped literals. For statement reuse or binary-safe values, switch to $param binding with to_surrealql_with_params(), which returns the SQL plus a map of bound values; execute it with client.query_with_params(...):

let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,&params).await?;

Bind one value to a name and reuse it across a query with Param, and declare a session variable with LetVar:

use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"

Transactions

Transaction wraps statements in BEGIN … COMMIT so they apply atomically — SurrealDB rolls the whole block back if any statement errors. .cancel() ends with CANCEL TRANSACTION to roll back explicitly:

use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothing

Subqueries and IN

A Select is itself an expression, so you can nest one inside a WHERE … IN, use it as a scalar, or read FROM it. Columns and idents gain in_expr / not_in_expr:

use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)

SELECT modifiers

VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and EXPLAIN:

let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAIN

SurrealDB 3.1 doesn't accept PARALLEL as a SELECT clause, so somnia doesn't emit it.

Control flow — IF and FOR

IfExpr is an expression you can drop into a projection, a SET value, a RETURN, or a WHERE. For builds an iterating block:

use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }

Schema DDL beyond tables, fields, and indexes

Builders for the remaining DEFINE statements — events, functions, analyzers, and params — each with a matching ::remove(...) inverse:

use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5

Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes, which flow into the generated DEFINE FIELD:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;

Full-text and vector search

Pair a FULLTEXT or HNSW index (see Schema as code) with the search / nearest builders:

// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"

Closures and record references

Build anonymous functions with Closure, and mark record links as tracked REFERENCEs in the derive:

use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}

Graph edges

Define an edge record once and derive its SurrealEdge impl (the edge name comes from #[table(...)]) — no hand-written impl SurrealEdge:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}

Then create edges with RELATE and query across them with typed paths — see the Path::out::<Wrote>()… examples under Build queries above, including recursive @.{..} traversal.

Crates

CrateDescription
somniaUmbrella crate: client, migrator, re-exports. Start here.
somnia-coreQuery builder, expression tree, SurrealRecord/SurrealSchema traits.
somnia-derive#[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros.
somnia-cliDiesel-cli-style migration runner (the somnia binary).

CLI

A standalone migration runner, modeled on diesel-cli. Install it with Cargo or Homebrew (both provide the somnia binary):

cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)

Then:

somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pending

Connection settings are read from flags or environment variables (--help for the full list).

Status

0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema generation, migrator, typed auth, live queries, and full-text/vector search helpers all covered by integration tests that run on an in-memory engine). The API may evolve before 1.0. See the roadmap for what's covered today and what's planned on the way to 1.0.

MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.

License

Licensed under the Apache License, Version 2.0.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

A type-safe SurrealDB ORM for Rust — typed query builder, #[derive(SurrealRecord)], schema generation, and Diesel-style migrations. "Somnia" is Latin for "dreams" (SurrealDB is surreal/dreamlike)

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

somnia

somnia — type-safe SurrealDB ORM for Rust

crates.ioDownloadsdocs.rsCIMSRVlicenseRead the write-upStarsFollow @vbasky

A type-safe SurrealDB ORM for Rust — a typed query builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style migrations.

somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.

[dependencies]
somnia = "0.8"

Why

Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names, unescaped values, record-link mistakes, and projection drift. somnia lets your Rust types describe the schema once and gives you:

  • Typed query buildingPost::table().select(...).filter(Post::title().eq("hello"))
  • Graph traversal — query across RELATE edges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive @.{..} paths.
  • #[derive(SurrealRecord)] — typed column accessors, table metadata, and schema DDL generated from the struct.
  • Schema as codeup() / down() emit DEFINE TABLE / DEFINE FIELD / DEFINE INDEX / REMOVE TABLE from the Rust type.
  • Diesel-style migrations — a Migrator that applies up.surql / reverts down.surql from timestamped folders, with applied-state tracking.
  • The rest of SurrealQL, typed — atomic transactions, $param binding, subqueries, IF/FOR control flow, and DEFINE EVENT/FUNCTION/ANALYZER/ PARAM — so you rarely drop to Raw(...).

somniainlines literals (with proper escaping) rather than relying on bind parameters — to_surrealql() returns a ready-to-run statement string, which keeps generated queries transparent and easy to log.

Quick start

Define a record

use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}

Build queries

use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();

For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE, string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch — the builder still owns the statement structure, table names, and record links.

Schema as code

#[derive(SurrealRecord)] also implements SurrealSchema:

use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;

Field attributes: #[field(thing)] (record id), record = "table" (record<table>), default = "…", value = "…", ty = "…" (full type override), flexible, name = "…", skip. Table attributes: #[table("name")], #[table("name", schemaless, permissions = "NONE")].

Field types are mapped from the Rust type — including typed arrays (Vec<T>array<…>), Option<…>, records, duration, and decimal.

Add indexes with a repeatable container attribute; they're emitted by up() (after the fields) and exposed via SurrealSchema::define_indexes():

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}

For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the DefineIndex builder directly.

Migrations

Lay out migrations Diesel-style — one timestamped folder per migration with up.surql and down.surql:

migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surql
use somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}

Applied migrations are tracked in a _somnia_migrations table, so re-running only applies what's pending.

Connecting and authentication

SomniaClient::connect signs in as a root user and selects a namespace/database:

let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;

For other auth levels, connect_with takes a Credentials enum (root, namespace, database, or a pre-issued token):

use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;

Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD method with arbitrary serializable params and returns the issued token; a JWT can be re-attached with authenticate, and invalidate clears the session:

let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's auth

connect_anonymous(endpoint, ns, db) connects without signing in — handy for the embedded mem:// / rocksdb:// engines or deferred auth.

Live queries

live_select::<T>() starts a LIVE SELECT on T's table and streams typed change notifications; dropping the stream issues KILL server-side:

use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.

More query power

somnia models much of SurrealDB's surface as typed builders, so you rarely fall back to raw strings. Every builder renders to a plain SurrealQL string via to_surrealql() — shown in the // comments below — so nothing is hidden. The examples build on the Post and Comment structs from Quick start.

Parameters and LET

By default somnia inlines values as escaped literals. For statement reuse or binary-safe values, switch to $param binding with to_surrealql_with_params(), which returns the SQL plus a map of bound values; execute it with client.query_with_params(...):

let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,&params).await?;

Bind one value to a name and reuse it across a query with Param, and declare a session variable with LetVar:

use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"

Transactions

Transaction wraps statements in BEGIN … COMMIT so they apply atomically — SurrealDB rolls the whole block back if any statement errors. .cancel() ends with CANCEL TRANSACTION to roll back explicitly:

use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothing

Subqueries and IN

A Select is itself an expression, so you can nest one inside a WHERE … IN, use it as a scalar, or read FROM it. Columns and idents gain in_expr / not_in_expr:

use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)

SELECT modifiers

VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and EXPLAIN:

let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAIN

SurrealDB 3.1 doesn't accept PARALLEL as a SELECT clause, so somnia doesn't emit it.

Control flow — IF and FOR

IfExpr is an expression you can drop into a projection, a SET value, a RETURN, or a WHERE. For builds an iterating block:

use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }

Schema DDL beyond tables, fields, and indexes

Builders for the remaining DEFINE statements — events, functions, analyzers, and params — each with a matching ::remove(...) inverse:

use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5

Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes, which flow into the generated DEFINE FIELD:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;

Full-text and vector search

Pair a FULLTEXT or HNSW index (see Schema as code) with the search / nearest builders:

// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"

Closures and record references

Build anonymous functions with Closure, and mark record links as tracked REFERENCEs in the derive:

use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}

Graph edges

Define an edge record once and derive its SurrealEdge impl (the edge name comes from #[table(...)]) — no hand-written impl SurrealEdge:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}

Then create edges with RELATE and query across them with typed paths — see the Path::out::<Wrote>()… examples under Build queries above, including recursive @.{..} traversal.

Crates

CrateDescription
somniaUmbrella crate: client, migrator, re-exports. Start here.
somnia-coreQuery builder, expression tree, SurrealRecord/SurrealSchema traits.
somnia-derive#[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros.
somnia-cliDiesel-cli-style migration runner (the somnia binary).

CLI

A standalone migration runner, modeled on diesel-cli. Install it with Cargo or Homebrew (both provide the somnia binary):

cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)

Then:

somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pending

Connection settings are read from flags or environment variables (--help for the full list).

Status

0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema generation, migrator, typed auth, live queries, and full-text/vector search helpers all covered by integration tests that run on an in-memory engine). The API may evolve before 1.0. See the roadmap for what's covered today and what's planned on the way to 1.0.

MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.

License

Licensed under the Apache License, Version 2.0.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

A type-safe SurrealDB ORM for Rust — typed query builder, #[derive(SurrealRecord)], schema generation, and Diesel-style migrations. "Somnia" is Latin for "dreams" (SurrealDB is surreal/dreamlike)

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

somnia

somnia — type-safe SurrealDB ORM for Rust

crates.ioDownloadsdocs.rsCIMSRVlicenseRead the write-upStarsFollow @vbasky

A type-safe SurrealDB ORM for Rust — a typed query builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style migrations.

somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.

[dependencies]
somnia = "0.8"

Why

Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names, unescaped values, record-link mistakes, and projection drift. somnia lets your Rust types describe the schema once and gives you:

  • Typed query buildingPost::table().select(...).filter(Post::title().eq("hello"))
  • Graph traversal — query across RELATE edges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive @.{..} paths.
  • #[derive(SurrealRecord)] — typed column accessors, table metadata, and schema DDL generated from the struct.
  • Schema as codeup() / down() emit DEFINE TABLE / DEFINE FIELD / DEFINE INDEX / REMOVE TABLE from the Rust type.
  • Diesel-style migrations — a Migrator that applies up.surql / reverts down.surql from timestamped folders, with applied-state tracking.
  • The rest of SurrealQL, typed — atomic transactions, $param binding, subqueries, IF/FOR control flow, and DEFINE EVENT/FUNCTION/ANALYZER/ PARAM — so you rarely drop to Raw(...).

somniainlines literals (with proper escaping) rather than relying on bind parameters — to_surrealql() returns a ready-to-run statement string, which keeps generated queries transparent and easy to log.

Quick start

Define a record

use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}

Build queries

use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();

For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE, string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch — the builder still owns the statement structure, table names, and record links.

Schema as code

#[derive(SurrealRecord)] also implements SurrealSchema:

use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;

Field attributes: #[field(thing)] (record id), record = "table" (record<table>), default = "…", value = "…", ty = "…" (full type override), flexible, name = "…", skip. Table attributes: #[table("name")], #[table("name", schemaless, permissions = "NONE")].

Field types are mapped from the Rust type — including typed arrays (Vec<T>array<…>), Option<…>, records, duration, and decimal.

Add indexes with a repeatable container attribute; they're emitted by up() (after the fields) and exposed via SurrealSchema::define_indexes():

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}

For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the DefineIndex builder directly.

Migrations

Lay out migrations Diesel-style — one timestamped folder per migration with up.surql and down.surql:

migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surql
use somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}

Applied migrations are tracked in a _somnia_migrations table, so re-running only applies what's pending.

Connecting and authentication

SomniaClient::connect signs in as a root user and selects a namespace/database:

let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;

For other auth levels, connect_with takes a Credentials enum (root, namespace, database, or a pre-issued token):

use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;

Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD method with arbitrary serializable params and returns the issued token; a JWT can be re-attached with authenticate, and invalidate clears the session:

let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's auth

connect_anonymous(endpoint, ns, db) connects without signing in — handy for the embedded mem:// / rocksdb:// engines or deferred auth.

Live queries

live_select::<T>() starts a LIVE SELECT on T's table and streams typed change notifications; dropping the stream issues KILL server-side:

use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.

More query power

somnia models much of SurrealDB's surface as typed builders, so you rarely fall back to raw strings. Every builder renders to a plain SurrealQL string via to_surrealql() — shown in the // comments below — so nothing is hidden. The examples build on the Post and Comment structs from Quick start.

Parameters and LET

By default somnia inlines values as escaped literals. For statement reuse or binary-safe values, switch to $param binding with to_surrealql_with_params(), which returns the SQL plus a map of bound values; execute it with client.query_with_params(...):

let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,&params).await?;

Bind one value to a name and reuse it across a query with Param, and declare a session variable with LetVar:

use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"

Transactions

Transaction wraps statements in BEGIN … COMMIT so they apply atomically — SurrealDB rolls the whole block back if any statement errors. .cancel() ends with CANCEL TRANSACTION to roll back explicitly:

use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothing

Subqueries and IN

A Select is itself an expression, so you can nest one inside a WHERE … IN, use it as a scalar, or read FROM it. Columns and idents gain in_expr / not_in_expr:

use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)

SELECT modifiers

VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and EXPLAIN:

let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAIN

SurrealDB 3.1 doesn't accept PARALLEL as a SELECT clause, so somnia doesn't emit it.

Control flow — IF and FOR

IfExpr is an expression you can drop into a projection, a SET value, a RETURN, or a WHERE. For builds an iterating block:

use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }

Schema DDL beyond tables, fields, and indexes

Builders for the remaining DEFINE statements — events, functions, analyzers, and params — each with a matching ::remove(...) inverse:

use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5

Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes, which flow into the generated DEFINE FIELD:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;

Full-text and vector search

Pair a FULLTEXT or HNSW index (see Schema as code) with the search / nearest builders:

// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"

Closures and record references

Build anonymous functions with Closure, and mark record links as tracked REFERENCEs in the derive:

use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}

Graph edges

Define an edge record once and derive its SurrealEdge impl (the edge name comes from #[table(...)]) — no hand-written impl SurrealEdge:

#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}

Then create edges with RELATE and query across them with typed paths — see the Path::out::<Wrote>()… examples under Build queries above, including recursive @.{..} traversal.

Crates

CrateDescription
somniaUmbrella crate: client, migrator, re-exports. Start here.
somnia-coreQuery builder, expression tree, SurrealRecord/SurrealSchema traits.
somnia-derive#[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros.
somnia-cliDiesel-cli-style migration runner (the somnia binary).

CLI

A standalone migration runner, modeled on diesel-cli. Install it with Cargo or Homebrew (both provide the somnia binary):

cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)

Then:

somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pending

Connection settings are read from flags or environment variables (--help for the full list).

Status

0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema generation, migrator, typed auth, live queries, and full-text/vector search helpers all covered by integration tests that run on an in-memory engine). The API may evolve before 1.0. See the roadmap for what's covered today and what's planned on the way to 1.0.

MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.

License

Licensed under the Apache License, Version 2.0.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

A type-safe SurrealDB ORM for Rust — typed query builder, #[derive(SurrealRecord)], schema generation, and Diesel-style migrations. "Somnia" is Latin for "dreams" (SurrealDB is surreal/dreamlike)

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages