Open source
The open-source Rust crates behind Vantage UI — a stack of thin abstractions for data across databases, APIs and infrastructure.
The Vantage framework is the open-source Rust data layer that powers Vantage UI.
It is shipping on crates.io at 0.5.x, with per-crate docs on docs.rs and a full mdBook.
The API is still evolving — expected at 0.x — but it is production code: it builds Vantage UI itself.
A stack of thin abstractions, built from the foundation up. They're not independent — Table stands on the DataSource and DataSet crates beneath it — but you climb only as far as your app needs and ignore the rest. In plenty of cases, Table is the whole job.
Any backend — SQL, SurrealDB, MongoDB, a REST or GraphQL API, CSV, an append-only log, the AWS CLI — implements a small set of source traits.
call_splitRoll your own. A comprehensive guide walks you through building a data source for any new datasource (e.g. Oracle, a message queue or your internal store). Code it yourself, or hand the guide to your AI coding agent.
Readable, Writable, Insertable and ActiveEntity — the contract for listing, reading and changing rows. Underneath sit the shared foundations: the error model, the persistence-aligned type system, and the database-agnostic expression layer.
Table<Source, Entity> plugs an arbitrary data source into those traits, then adds typed columns, composable conditions, relations and computed expressions.
call_splitRoll your own. This is where you model your business entities as a compile-time type system — typed columns, relations and expressions. It's what earns you real compile-time safety in Rust across a large codebase.
Erases both the backend and the entity type into one universal, schema-bearing handle. Everything below this line talks to a Vista — never to your database directly.
Build a Vista straight from a typed Table, or from a YAML spec. Go through the YAML root and the factory catalog resolves references across different persistences — a Postgres row linked to a Mongo collection.
call_splitRoll your own. The YAML path is itself the no-Rust route — declare a source, its schema and references entirely in config, and the factory builds the Vista.
A bare Vista has only the capabilities of its source. Diorama wraps it with a local cache so a read-only CSV can count, paginate, search and sort — and it pushes real-time updates out to the UI elements bound to it (and to connected live clients, once a WebSocket connector lands).
The Lens decides caching strategy: which backend holds the cache (usually ReDB), how much to pre-fetch, and how to invalidate it.
call_splitRoll your own. The Lens is where you own that strategy — set the pre-fetch window and invalidation rules, and route writes and events through your own callbacks.
Table, Record and Value sceneries drive a reactive UI. Each bumps a generation counter when its data moves, and the interface re-renders — no manual state-sync.
The top of the stack is yours — an API server, a command-line tool, a bespoke UI. You write it, but you don't start cold: Vantage ships connectors, including an axum server integration, DataGrid adapters for six Rust UI toolkits, and CLI helpers.
call_splitRoll your own. Wrap any layer below in your own API or CLI. The connectors are a head start, not a cage — your surface, your logic.
It's all modular — depend only on the crates you need. Most data-source crates expose an optional vista feature, and an optional rhai feature layers scripting on top. Rolling your own tables? You generally need neither.
Real snippets from the book. Step through them.
The minimum Vantage gives you — construct a SQL query, execute it, and map the rows. It also shows VantageError handling in practice: should the query fail, e.report() prints a lovely, readable error message.
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
struct Product {
name: String,
price: i64,
}
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
e.report();
}
}
async fn run() -> VantageResult<()> {
let db = SqliteDB::connect("sqlite:products.db?mode=ro")
.await
.context("Failed to connect to products.db")?;
let select = SqliteSelect::new()
.with_source("product")
.with_field("name")
.with_field("price")
.with_condition(Column::<bool>::new("is_deleted").eq(false));
let raw = db.execute(&select.expr()).await?;
let records = Vec::<Record<AnySqliteType>>::try_from(raw)?;
for rec in records {
let p = Product::from_record(rec)?;
println!("{:<12} {:>3} cents", p.name, p.price);
}
Ok(())
}
In Vantage a business entity is a first-class citizen, so Table<MysqlDB, Client> is a legit type too. Here are two constructors for the same Client — one over SQL on MySQL, the other over SurrealDB's own, incompatible SurrealQL.
pub fn mysql_table(db: MysqlDB) -> Table<MysqlDB, Client> {
Table::new("client", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<String>("email")
.with_column_of::<String>("contact_details")
.with_column_of::<bool>("is_paying_client")
.with_column_of::<String>("bakery_id")
.with_one("bakery", "bakery_id", Bakery::mysql_table)
.with_many("orders", "client_id", Order::mysql_table)
.with_expression("order_count", |t| {
let orders = t.get_subquery_as::<Order>("orders").unwrap();
orders.get_count_query()
})
}
pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Client> {
Table::new("client", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<String>("email")
.with_column_of::<String>("contact_details")
.with_column_of::<bool>("is_paying_client")
.with_one("bakery", "bakery", Bakery::surreal_table)
.with_many("orders", "client", Order::surreal_table)
.with_expression("order_count", |t| {
let orders = t.get_subquery_as::<Order>("orders").unwrap();
orders.get_count_query()
})
}
The full query builder is scriptable. Drive joins, aggregates and ordering from a Rhai script evaluated at runtime — same primitives as Rust, no recompile. Here, an order-detail view across three tables.
// Q7: Order Detail View (all orders with totals)
// INNER JOIN + LEFT JOIN, COUNT/SUM aggregates, GROUP BY
let o = table("client_order").alias("o");
let c = table("client").alias("c");
let ol = table("order_line").alias("ol");
select()
.from(o)
.expression(o["id"].alias("order_id"))
.expression(c["name"].alias("client"))
.expression(o["created_at"].alias("placed"))
.expression(count(ol["id"]).alias("lines"))
.expression(sum(mul(ol["quantity"], ol["price"])).alias("order_total"))
.expression(sum(ol["quantity"]).alias("total_items"))
.inner_join("client", "c", c["id"] == o["client_id"])
.left_join("order_line", "ol", ol["order_id"] == o["id"])
.group_by(o["id"])
.group_by(c["name"])
.group_by(o["created_at"])
.order_by(o["created_at"], "asc")
A whole datasource in one YAML file: declare the columns, then let a Rhai cmd block shell out to the AWS CLI. Vantage pushes your WHERE conditions and limit straight into the command's arguments.
name: s3.objects
columns:
Key: { type: string, flags: [id, title, searchable] }
Bucket: { type: string, flags: [hidden] }
Size: { type: int }
LastModified: { type: string }
StorageClass: { type: string }
cmd:
rhai: |
let args = ["s3api", "list-objects-v2", "--output", "json"];
let bucket = "";
for c in conditions {
if c.field == "Bucket" { args += ["--bucket", c.value]; bucket = c.value; }
}
if type_of(limit) != "()" {
args += ["--max-items", limit.to_string()];
}
let out = run(args);
if out.exit_code != 0 { throw out.stderr; }
let parsed = parse_json(out.stdout);
// `Contents` is absent for an empty bucket.
let contents = if type_of(parsed.Contents) == "array" { parsed.Contents } else { [] };
for r in contents { r.Bucket = bucket; }
contents
A Table<SqliteDB, User> is your own type — so a plain trait lets you bolt domain logic straight onto it. Define reset_password once and every User table in your codebase has it.
// A domain trait, implemented directly on the typed table.
#[async_trait]
pub trait UserOps {
async fn reset_password(&self, id: &str, new_password: &str) -> VantageResult<()>;
}
#[async_trait]
impl UserOps for Table<SqliteDB, User> {
async fn reset_password(&self, id: &str, new_password: &str) -> VantageResult<()> {
let mut user = self
.get_record(id)
.await?
.ok_or_else(|| vantage_error!("no such user: {id}"))?;
// `Record` derefs to `User`, so mutate it like the struct it is.
user.password_hash = hash_password(new_password);
user.reset_token = None;
user.save().await
}
}
// ...then anywhere in your code, it reads like a method on the entity:
let users = User::sqlite_table(db);
users.reset_password("42", "hunter2").await?;
A Vista hides both the entity type and the database. This summary printer runs against SQLite, MongoDB or AWS — unchanged.
use vantage_vista::Vista;
// A typed Table<SqliteDB, Product>
// collapses into a backend-blind Vista:
let vista = db
.vista_factory()
.from_table(Product::sqlite_table(db))?;
print_vista(&vista).await?;
// ...and print_vista neither knows nor cares what's underneath:
async fn print_vista(vista: &Vista) -> VantageResult<()> {
let columns = vista.get_column_names();
let caps = vista.capabilities();
print!(" {:>12} ", vista.get_id_column().unwrap_or("id"));
for col in &columns {
print!("{:>16} ", col);
}
println!();
if caps.can_count {
println!(" ({} rows)", vista.get_count().await?);
}
let rows = vista.list_values().await?;
for (id, record) in &rows {
print!(" {:>12} ", id);
for col in &columns {
let val = record.get(col)
.map(|v| format!("{:?}", v))
.unwrap_or_default();
print!("{:>16} ", val);
}
println!();
}
Ok(())
}
CBOR records — the form a Python or TypeScript SDK would consume. Want your Table reachable from another language? Wrap it in a Vista first.Lens and Diorama implement sophisticated local caching capabilities that you control. Here is a simple local cache that refreshes itself automatically.
use std::sync::Arc;
use std::time::Duration;
use vantage_diorama::Lens;
let lens = Arc::new(
Lens::new()
.cache_at("./cache.redb")
.on_start(|dio| {
let dio = dio.clone();
async move {
let rows = dio.master().list_values().await?;
dio.cache().insert_values(rows).await?;
Ok(())
}
})
.refresh_every(Duration::from_secs(300))
.build()?,
);
Lens and Diorama run on a separate local thread, coordinating data in and out of your datasource. A Scenery is a "window" into that local cache.
use vantage_diorama::scenery::SortDir;
// Table view — ordered, paginated
let table = dio
.table_scenery()
.sort("name", SortDir::Asc)
.page_size(50)
.open()
.await?;
println!("{} rows loaded", table.row_count());
// Single record
let rec = dio.record_scenery("42").await?;
// Live counter
let count = dio.value_scenery().count().open().await?;
let mut rx = count.subscribe();
loop {
rx.changed().await?;
println!("count: {:?}", count.value());
}
Dashboards need real aggregation. You don't hand-write SQL for it — you build on a table you already have. This "top products by units sold" comes straight off the order table.
name: product_units
datasource: bakery-surreal
id_column: product
columns:
product: { type: string, flags: [id, title] }
units: { type: int }
surreal:
rhai: |
select()
.from( # base: the order table
select()
.expression(ident("lines").alias("line"))
.from("order")
.split("lines") # one row per order line
.subquery())
.expression(ident("line")["product"].alias("product"))
.expression(sum(ident("line")["quantity"]).alias("units"))
.group_by(ident("product"))
.order_by(ident("units"), "desc")
.limit(10, 0)
Vantage UI takes all of this beautiful Vantage code and wraps it in a GPUI interface. Table definitions are parsed by the Vista factory, while the UI is driven by similarly structured page files.
title: Orders
elements:
- kind: crud
spot: body
table: bakery-surreal/order
params:
columns:
id: { pin: true, label: ID }
created_at: { label: Created }
status:
label: Status
color:
success: [paid]
yellow: [in_production, ready]
red: [cancelled]
labels:
in_production: In production
ready: Ready
paid: Paid
cancelled: Cancelled
row_actions:
- label: Open
icon: ChevronRight
primary: true
navigate: order-detail # drill down to another page
args: { id: row.id }
- label: "Start production"
icon: Factory
when: 'row.status == "confirmed"' # only show the legal next step
action: |
row.status = "in_production";
row.save();
- label: "Mark paid"
icon: DollarSign
when: 'row.status == "delivered" || row.status == "picked_up"'
action: |
row.status = "paid";
row.save();
Every published crate, all at 0.5.x. Pull in only the layers you use.
Record types.DataSet / ValueSet traits and idempotency rules.Table, columns and operation traits over any backend.Today the framework consumes HTTP backends — vantage-api-client maps REST and GraphQL into tables, and vantage-api-pool handles paginated, rate-limited fetches.
The reverse is in progress: a generic, open-source crate that exports your typed entities as a clean REST/GraphQL endpoint from minimal boilerplate, without losing the framework's capabilities. A genuinely generic API layer is rare in Rust — and it's the foundation the Vantage UI API-generation feature will build on.
Vantage UI's reach is bounded by the framework's connector list. Adding a backend is a well-defined six-step process in the book's Adding a New Persistence chapter.
TableBringing a new database, message broker or infrastructure source into Vantage UI? That's the path. Pull requests welcome on github.com/romaninsh/vantage.