Rust (Client)
The reifydb-client crate talks to a running ReifyDB server. Where Rust (Embedded) hands you the engine in-process, this crate speaks the wire protocol: WebSocket for long-lived connections with live subscriptions, HTTP for plain request/response. Both transports run the same three statement kinds as every other surface - queries read, commands write, admin statements change schema and identities - and both return results as the same Frame type the embedded API uses. The client is async and runs on tokio.
Installation
Transports are opt-in Cargo features and the default feature set is empty, so a bare reifydb-client = "0.8.1" compiles but exposes no client at all. Pick at least one transport:
[dependencies]
reifydb-client = { version = "0.8.1", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
# Or request/response only:
# reifydb-client = { version = "0.8.1", features = ["http"] }
# Both work together:
# reifydb-client = { version = "0.8.1", features = ["ws", "http"] }The ws feature provides WsClient, the http feature provides HttpClient. A grpc feature with a GrpcClient exists as well, but the stock reifydb server binary only wires up HTTP and WebSocket, so gRPC is only relevant if you assemble your own server with the gRPC subsystem enabled. The crate is versioned in lockstep with the engine: pair a 0.8.x client with a 0.8.x server.
Connecting
Both clients connect with a URL and a WireFormat (Json is the readable default, Rbcf the binary alternative - see below). Against the default server the WebSocket endpoint is ws://localhost:8091 and the HTTP endpoint is http://localhost:8090; Connect covers the port layout and the loopback-only admin listeners:
use reifydb_client::{WireFormat, WsClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = WsClient::connect("ws://localhost:8091", WireFormat::Json).await?;
let frames = client.query("MAP { answer: 42 }", None).await?;
for frame in frames {
println!("{}", frame);
}
client.close().await?;
Ok(())
}WsClient::connect opens one persistent socket and spawns a background task that owns it, so it must be created inside a tokio runtime. That one connection carries everything: queries, commands, binding calls, and subscription pushes. close() shuts it down gracefully; dropping the client attempts the same best-effort shutdown.
HttpClient is the request/response equivalent - each statement is one HTTP POST, which suits scripts, serverless functions, and anywhere a long-lived socket is awkward. It carries the same query, command, admin, and login surface, but no subscriptions and no binding calls:
use reifydb_client::{HttpClient, WireFormat};
let client = HttpClient::connect("http://localhost:8090", WireFormat::Json).await?;
let frames = client.query("MAP { answer: 42 }", None).await?;HttpClient builds its own connection pool with a 30 second request timeout; HttpClient::with_client(reqwest_client, url, format) reuses an existing reqwest::Client if your application already has one.
Authentication
A fresh connection runs as the anonymous identity; what that identity may do is decided by policies on the server. To act as a real user, log in with whichever method the user has (creating users and their credentials is an admin operation, shown on Connect):
// Password login: returns a session token plus the identity UUID.
let login = client.login_with_password("alice", "alice-pass").await?;
println!("token: {}, identity: {}", login.token, login.identity);
// Token login for users with token credentials:
let login = client.login_with_token("bob-secret-token").await?;
// Resume an existing session on a new connection:
client.authenticate(&login.token).await?;
// Revoke the session token server-side:
client.logout().await?;Both login methods return a LoginResult with the session token and the identity UUID; every subsequent statement on the connection runs as that identity. The lower-level login(method, credentials) takes the method name and a HashMap of credential fields directly, and is_authenticated() reports the current state. One transport difference: on WsClient, authenticate(token) is async and validates the token with the server immediately; on HttpClient it is a plain setter that records the bearer token, which is then sent (and checked) with every request.
Queries and commands
query(rql, params) reads, command(rql, params) writes. Both take an Option<Params> and return Result<Vec<Frame>, Error> - the same columnar Frame the embedded API returns, with Display rendering the familiar ASCII table:
client.command(
r#"INSERT app::users [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
]"#,
None,
).await?;
let frames = client.query("FROM app::users", None).await?;
for frame in frames {
println!("{}", frame);
}Each method has a _with_meta twin - query_with_meta, command_with_meta, admin_with_meta - that returns the frames together with server-reported metadata: a request fingerprint and the server-side execution duration (over HTTP these arrive as x-fingerprint and x-duration response headers).
WsClient additionally has call(name, params), which invokes a WS binding - a procedure published under a globally-unique name with CREATE WS BINDING - without shipping any RQL text from the client.
Admin statements
The application endpoints (8090/8091) accept queries and commands only; admin statements such as CREATE TABLE are rejected there. The client's admin(rql, params) method works when the connection points at an admin listener, which the default server keeps on loopback (ws://127.0.0.1:9091 for WebSocket, http://127.0.0.1:9090 for HTTP - see Connect):
use reifydb_client::{WireFormat, WsClient};
// Same client type, admin listener. Loopback only by default.
let mut admin = WsClient::connect("ws://127.0.0.1:9091", WireFormat::Json).await?;
admin.authenticate(&token).await?;
admin.admin("CREATE NAMESPACE app", None).await?;
admin.admin("CREATE TABLE app::users { id: Int4, name: Utf8 }", None).await?;
admin.close().await?;Admin listeners accept all three statement kinds, so a migration script can run its schema changes and seed data over one connection. The split exists so frontends can face the public ports while schema and identity changes stay reachable only from the machine itself.
Parameters
Params is the same type the embedded API uses, re-exported by this crate. The client methods take Option<Params>: None means no parameters, positional parameters are referenced as $1, $2, ... and built from a Vec<Value>, named parameters as $name from a HashMap<String, Value>:
use std::collections::HashMap;
use reifydb_client::Value;
// Positional: $1, $2, ...
let frames = client.query(
"MAP { total: $1 + $2 }",
Some(vec![Value::Int4(40), Value::Int4(2)].into()),
).await?;
// Named: $name
let mut params = HashMap::new();
params.insert("name".to_string(), Value::Utf8("Alice".to_string()));
let frames = client.query(
"FROM app::users FILTER name == $name",
Some(params.into()),
).await?;On the wire, each parameter travels as a typed {type, value} pair, never as spliced RQL text - use parameters for anything that originates outside your program, exactly as you would to prevent SQL injection.
Mapping rows to structs
The crate re-exports the same FromFrame derive the embedded crate ships, so typed row extraction works identically on both sides of the wire:
use reifydb_client::FromFrame;
#[derive(FromFrame, Debug)]
struct User {
id: i32,
name: String,
}
let frames = client.query("FROM app::users", None).await?;
for frame in &frames {
let users: Vec<User> = User::from_frame(frame).unwrap();
for user in &users {
println!("{}: {}", user.id, user.name);
}
}Field names and types must match the frame's columns, and the #[frame(...)] attributes (column, optional, coerce, skip) adjust the mapping. The full mapping rules are documented on Rust (Embedded) and apply unchanged here.
Subscriptions
Subscriptions need the persistent connection, so they are WsClient only. subscribe(rql, config) registers a live subscription over an RQL body and returns its id; changes are then pushed to the client and drained with recv():
use reifydb_client::{ChangeKind, SubscriptionConfig, WireFormat, WsClient};
let mut client = WsClient::connect("ws://localhost:8091", WireFormat::Json).await?;
client.authenticate(&token).await?;
let sub_id = client
.subscribe("from app::users | map { id, name }", SubscriptionConfig::default())
.await?;
// recv() waits for the next change; try_recv() polls without blocking.
while let Some(change) = client.recv().await {
match change.kind {
ChangeKind::Insert => print!("insert: "),
ChangeKind::Update => print!("update: "),
ChangeKind::Remove => print!("remove: "),
}
if let Some(frames) = change.frames {
for frame in frames {
println!("{}", frame);
}
}
}
client.unsubscribe(&sub_id).await?;Each ChangePayload carries the subscription_id it belongs to, the change kind (insert, update, or remove), and the affected rows as decoded frames. The client derives the kind from the _op column the server attaches and strips that column before handing you the frames, so the rows look exactly like query results.
SubscriptionConfig controls delivery. hydration.enabled defaults to true: the current contents of the source are delivered first, so subscribing to a populated table observes every existing row before forward changes; hydration.max_rows caps that snapshot. throttle sets a minimum interval between pushes, with changes buffered and delivered together when the interval elapses; linger holds a member's changes for a short window so closely-spaced changes coalesce into one envelope, and only takes effect inside a batch subscription. Both take Duration values:
use reifydb_client::{HydrationConfig, SubscriptionConfig};
// Only changes committed after the subscription exists:
let config = SubscriptionConfig {
hydration: HydrationConfig { enabled: false, max_rows: None },
throttle: None,
linger: None,
};
let sub_id = client.subscribe("from app::users", config).await?;Under the hood the client wraps your query body in a CREATE SUBSCRIPTION WITH { ... } AS { body } statement, so the server-side semantics are exactly those of the subscriptions concept page.
Batch subscriptions
A dashboard that watches ten queries does not want ten interleaved change streams. batch_subscribe registers several subscriptions as one group and returns a dedicated handle whose events arrive as coalesced per-tick envelopes, each containing the entries for every member that changed:
use reifydb_client::{BatchItem, BatchPushEvent, SubscriptionConfig};
let mut batch = client
.batch_subscribe(&[
BatchItem::new("from app::users", SubscriptionConfig::default()),
BatchItem::new("from app::orders", SubscriptionConfig::default()),
])
.await?;
// members() maps each query (by index) to its subscription id.
let batch_id = batch.batch_id().to_string();
while let Some(event) = batch.recv().await {
match event {
BatchPushEvent::Change(change) => {
for entry in change.entries {
println!("subscription {} changed ({:?})", entry.subscription_id, entry.kind);
for frame in entry.frames.into_iter().flatten() {
println!("{}", frame);
}
}
}
BatchPushEvent::MemberClosed(m) => {
println!("member {} closed", m.subscription_id);
}
BatchPushEvent::Closed(_) => break,
}
}
client.batch_unsubscribe(&batch_id).await?;batch_unsubscribe tears the whole group down server-side; individual members report MemberClosed if their underlying subscription ends first, and the handle's recv() returns None after the batch closes.
Wire formats
The second argument to connect picks the result encoding for the whole connection. WireFormat::Json asks the server for frames-shaped JSON - readable on the wire and the right default. WireFormat::Rbcf switches results (including subscription pushes over WebSocket) to RBCF, ReifyDB's binary columnar format, which is the compact choice for result-heavy workloads. Either way you receive decoded Frames; the encoding never changes what a statement means or returns. The formats themselves are specified on Wire Formats.
Error handling
Every method returns Result<_, Error> where Error is the same diagnostic wrapper the embedded API uses: a stable code, a message, the offending RQL fragment, and often a help text, all rendered by Display. Server-side failures - bad RQL, policy denials, transaction conflicts - arrive as these diagnostics. Note that unlike the embedded Session, the remote client has no built-in conflict retry: a TXN_001 conflict comes back like any other error and retrying the command is up to you.
Err - most notably failing to reach the server at connect time, a rejected login, and malformed protocol responses. Treat a panic from the client as a connectivity or configuration problem, and expect these paths to become proper errors before 1.0.Frame results are identical, so moving between the two later is mechanical.