Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vantage Framework

Vantage is a data entity persistence and abstraction framework for Rust.

Vantage changes the way you think about your data. Instead of writing queries and loading rows, you work with sets of records — “unpaid invoices older than 30 days”, “customers who ordered today” — narrowing them, combining them, traversing from one to another, and acting on them where they live.

Vantage offers two ways to work with your data. In transactional mode there is no state to manage: load a set, act on it, done — each operation goes straight to the backend. This is the natural shape of a REST endpoint: handle the request, read or update the data, respond. (The name describes that request/response shape, not database transactions — BEGIN/COMMIT is the backend’s business, and Vantage doesn’t wrap your operations in one.) In live mode Vantage maintains a local representation of the data segment you’re working with: you operate on your local copy, and it reconciles with the source over time. That’s how a user interface behaves — any kind. Live mode is built on top of transactional mode, so everything you learn about the first carries into the second.

This documentation tracks the 2.0 line of vantage-table and vantage-vista, and the drivers and vantage-diorama releases built on it.

Ethos

A few principles run through every layer of the framework:

  • Let the backend do the work. If a database or API can filter, join, aggregate, or paginate, Vantage pushes the work there — data is narrowed at the source, not in your process.
  • Fill the gaps client-side, honestly. Where a backend can’t (a CSV file can’t sort; two databases can’t join), the layer above provides it — and every handle advertises exactly what it supports, so a missing capability is an explicit error, never a silent guess.
  • Respect native types. Each backend keeps its own type system end to end — decimals stay precise, dates stay dates, nothing is funnelled through JSON.
  • Business logic lives on the entity. Validation, audit, soft-delete, and domain methods attach to your model once and apply everywhere the entity is used.
  • Fail loudly, retry safely. No panics, no silent zeros or match-alls. Every write is retry-safe and stays inside the set it goes through (Safe writes).
  • Be aware of observers. Data knows who is watching: changes stream to subscribers, and edits reconcile instead of clobbering. (This is live mode — the second half of the guide builds it up.)

Three ways to work with data

Vantage lets you choose how strongly typed your data access is — per component, not per project:

  1. Entity mode — records are plain Rust structs (Table<SqliteDB, Product>). Columns and conditions are compiler-checked, and business logic attaches to the entity: full type safety, the natural choice for hand-written Rust code.
  2. Record mode — each record is an arbitrary structure of named values (Record<V>), with schema introspectable at runtime. No entity struct required — built for generic code: data grids, admin tools, import/export.
  3. Rhai scripts — more generic still: schema declared in YAML, custom expressions and logic in Rhai. Defined, loaded, and sealed at runtime with no recompiling — made for configuration-driven and agent-driven tooling.

The three interoperate: a Rhai-declared table serves records, records deserialize into entities, and all of it sits behind the same capability-checked handles.

What makes Vantage different

Vantage is a framework, not a library. A library solves one concern and hands the rest back to you; Vantage takes over the data layer entirely — modelling, querying, types, caching, reactivity — and gives you well-defined places to plug in what’s yours. The implementation spans 10+ crates (query builders, backend drivers, the entity layer, the reactive stack), all built on the same cohesive, extensible principles: what you learn in one crate applies in the next, and every extension point looks the same wherever you meet it.

Vantage also doesn’t mimic frameworks from other languages. No reflection, no runtime magic, no inheritance hierarchies. It leans on what Rust is uniquely good at — traits for composition, ownership for safe sharing, generics that compile away, async throughout — so the framework feels native rather than translated.

The four layers

Everything above maps onto four layers. Each builds on the one below, and you climb only as far as your application needs:

Table<SqliteDB, Product>   entity mode — typed, compiler-checked, transactional
Vista                      record mode — schema known at runtime, capabilities honest
Dio                        live mode begins — local cache, write queue, change events
Scenery                    reactive views over a Dio — what a UI binds to and watches

A Table is where your model and business logic live. Wrap it into a Vista when generic code needs to consume it (record mode, above). Bind the Vista to a Dio when you want a live local representation — caching, write routing, reconciliation. Open a Scenery over the Dio for an ordered table, a single record, or an aggregate that updates as the data changes. Layers never leak upward: a Table doesn’t know it’s being cached, and a Scenery consumer can’t tell which backend is underneath.

Vantage and Vantage UI

Vantage UI is a native admin console built directly on these crates — point it at your databases, APIs, and tools, and an AI agent configures tables, forms, and dashboards over them. It is a closed-source product (free download), and this framework is its open foundation: Vantage is open-sourced so that you can build your own services, CLIs, and UIs on the same data layer — and extend it, adding persistences and capabilities that custom builds can carry further than the stock app does.

If you want the finished tool, start with Vantage UI. If you want the foundation, read on.

Getting Started

Vantage covers a lot of ground — multiple databases, type systems, entity frameworks, UI adapters — but none of that matters until you’ve seen it do something useful.

This guide introduces Vantage concepts one at a time, each building on the last. We start with something you already know — SQL — and build a shared foundation: a small product catalog that begins as a CLI, becomes an HTTP API, and switches databases without touching its handlers, then erases into a runtime-generic data handle.

From there the guide forks by the shape of your problem. The reactive stack that sits on top — caching, change events, watchable views — is the same whichever backend is underneath, so you follow it down whichever path is yours:

  • A facade over an API you don’t control — a slow, read-only cloud API that can’t sort, search, or paginate. Diorama caches it and fills the gaps.
  • A live view in front of your own database — a relational database you own and write to. Diorama gives it a cached, watchable facade, and the path moves the same app from SQLite to PostgreSQL with a single switch, then on to SurrealDB.

Same abstractions, opposite backends — that contrast is the point.

The foundation

  1. SQLite and the Query Builder — connect to a database, build and execute typed queries, map rows to structs.
  2. Tables and Typed Data Access — define entities and tables, narrow sets with conditions, traverse relationships, add computed fields; CRUD becomes one-liners.
  3. A Standalone Axum Server — put the model behind HTTP with one generic CRUD handler for every entity, then migrate the whole server from SQLite to MongoDB by editing only the model.
  4. Vista — the Universal Data Handle — erase the entity and backend into a schema-bearing runtime handle, with explicit capability contracts.

Choose your path

With a Vista in hand, pick the path that matches your data — a facade over an API you don’t control, or a live view over your own relational database. Both build the same caching, reactive, watch-streaming stack; they differ only in the backend beneath it.

You’ll need basic Rust experience (structs, traits, async/await, cargo). No prior Vantage knowledge required.

Start here: SQLite and the Query Builder

Beyond the guide

The rest of the book is reference material — read it when the guide points at it, or jump straight to what you need:

SQLite and the Query Builder

Vantage is a big framework. It covers SQL databases, SurrealDB, MongoDB, CSV files, REST APIs — and ties them all together with a shared type system, expression engine, and data abstraction layer.

We’ll get to all of that. But right now, let’s start with something familiar: building SQL queries.

What is a Query Builder?

A query builder is a tool that assembles SQL from composable parts instead of string concatenation. You’ve probably seen this pattern before:

  • Knex.js (JavaScript) — knex('users').where('age', '>', 18).select('name')
  • SQLAlchemy Core (Python) — select(users.c.name).where(users.c.age > 18)
  • JOOQ (Java) — dsl.select(USERS.NAME).from(USERS).where(USERS.AGE.gt(18))
  • Diesel (Rust) — users.filter(age.gt(18)).select(name)

Vantage has its own query builder too. Each supported database gets a dedicated builder — SqliteSelect, PostgresSelect, SurrealSelect — so you get the right quoting, parameter binding, and dialect features for your target.

For this chapter we’ll use SQLite. It’s lightweight, needs no server, and works with a plain file on disk.

Goals for this chapter

By the end of this page you’ll be able to:

  1. Connect to an SQLite database from Rust
  2. Build SELECT queries with fields and conditions
  3. Execute queries and read results
  4. Convert results into Vec<Record> with typed field access
  5. Understand how Vantage keeps parameters separate from SQL (no injection risk)

Set up

Create a new project:

cargo init learn-1 && cd learn-1
cargo add vantage-sql --features sqlite
cargo add vantage-expressions
cargo add tokio --features full

Three dependencies — vantage-sql gives us the SQLite query builder and connection pool, vantage-expressions is needed by the sqlite_expr! macro, and tokio provides the async runtime because all database operations are async.

Create and populate a database

We’ll make a small product catalog from scratch. Create seed.sql in your project root:

CREATE TABLE product (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    price INTEGER NOT NULL,
    category_id INTEGER,
    is_deleted BOOLEAN NOT NULL DEFAULT 0
);

INSERT INTO product VALUES (1, 'Cupcake',           120, 1, 0);
INSERT INTO product VALUES (2, 'Doughnut',          135, 1, 0);
INSERT INTO product VALUES (3, 'Tart',              220, 2, 0);
INSERT INTO product VALUES (4, 'Pie',               299, 2, 0);
INSERT INTO product VALUES (5, 'Cookies',           199, 1, 0);
INSERT INTO product VALUES (6, 'A Stale Cake',       80, 1, 1);
INSERT INTO product VALUES (7, 'Sourdough Loaf',    350, 3, 0);

CREATE TABLE category (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL
);

INSERT INTO category VALUES (1, 'Sweet Treats');
INSERT INTO category VALUES (2, 'Pastries');
INSERT INTO category VALUES (3, 'Breads');

Run it:

sqlite3 products.db < seed.sql

You now have products.db — 7 products (6 active, 1 deleted) across 3 categories. Quick check:

sqlite3 products.db "SELECT name, price FROM product WHERE is_deleted = 0"

Start with an async main

All database operations in Vantage are async, so we need a Tokio runtime. Replace src/main.rs with:

use vantage_sql::prelude::*;

#[tokio::main]
async fn main() {
    if let Err(e) = run().await {
        e.report();
    }
}

async fn run() -> VantageResult<()> {
    println!("Ready!");
    Ok(())
}

A few things going on here:

  • use vantage_sql::prelude::* brings in everything we need for this chapter — SqliteDB, SqliteSelect, the sqlite_expr! macro, error types, and the traits that make builder and execution methods work.
  • VantageResult<()> is Vantage’s own Result type. It uses VantageError, which tracks context and error chains for readable diagnostics.
  • e.report() prints the error in a structured format. We call it from main() because Rust’s default Result-returning main uses Debug formatting, which is ugly. This pattern gives us clean error output instead.

Run cargo run to make sure it compiles.


Connect to SQLite

Add this inside run():

use vantage_sql::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db?mode=ro")
    .await
    .context("Failed to connect to products.db")?;
Ok(())
}

SqliteDB wraps an sqlx connection pool. The connection string is an sqlx URL — ?mode=ro opens read-only, which is all we need for now.

Already have an sqlx pool?

If you’re adding Vantage to an existing project that already has a SqlitePool, wrap it directly:

#![allow(unused)]
fn main() {
let db = SqliteDB::new(existing_pool);
}

The reverse works too — db.pool() gives you the underlying SqlitePool, although Vantage expressions will eliminate any need to execute queries directly.

.context() — readable errors

.context() wraps any error with a human-readable message. If the database file doesn’t exist, instead of a raw sqlx error you get:

Error: Failed to connect to products.db
│
╰─▶ error returned from database: (code: 14) unable to open database file

You’ll see .context() used throughout Vantage code. It comes from VantageError and works on any Result with a standard error type.


Build a SELECT

SqliteSelect is the query builder for SQLite. Other persistences have their own — PostgresSelect, MongoSelect — and they all implement the Selectable trait, so the interface is identical apart from vendor-specific extensions. None of them need a database connection — they’re just structs that accumulate query parts. You build them with a chain of .with_*() calls:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
let select = SqliteSelect::new()
    .with_source("product")
    .with_field("name")
    .with_field("price");

println!("{}", select.preview());
// SELECT "name", "price" FROM "product"
assert_eq!(select.preview(), r#"SELECT "name", "price" FROM "product""#);
}

.preview() renders the final SQL as a string — handy for debugging, but never used for execution.

Builder pattern

.with_*() consumes the builder and returns a new one. Call .with_field() as many times as you need; skip it entirely for SELECT *. Every .with_*() method has a corresponding .add_*() that mutates in place instead of consuming. Use whichever fits your code:

#![allow(unused)]
fn main() {
// Builder style
let select = SqliteSelect::new()
    .with_source("product")
    .with_field("name");

// Mutable style
let mut select = SqliteSelect::new();
select.add_source("product", None);
select.add_field("name");
}

Same result. The .with_*() style is nicer for one-shot construction, .add_*() is useful when you’re building a query conditionally in a loop.


Execute it

use vantage_sql::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
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");
let result = db.execute(&select.expr()).await?;
println!("{:?}", result);
Ok(())
}

Two steps here: .expr() turns the builder into an Expression — Vantage’s internal representation that keeps parameters separate from the SQL template. Then db.execute() sends it to the database.

The result is AnySqliteType — a type-tagged wrapper around whatever came back. The Debug output isn’t pretty, but you should see all 6 product rows in there.

When does Vantage hit the database?

Only on .await. Everything before that — with_source, with_field, with_condition — is synchronous struct manipulation. You always know when a database call happens because you typed .await.


Adding conditions

Our database has a soft-delete flag — “A Stale Cake” has is_deleted = 1. Let’s filter it out:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
let condition = sqlite_expr!("\"is_deleted\" = {}", false);

let select = SqliteSelect::new()
    .with_source("product")
    .with_field("name")
    .with_field("price")
    .with_condition(condition);

println!("{}", select.preview());
// SELECT "name", "price" FROM "product" WHERE "is_deleted" = 0
assert_eq!(
    select.preview(),
    r#"SELECT "name", "price" FROM "product" WHERE "is_deleted" = 0"#
);
}

sqlite_expr! creates an Expression — a SQL template with typed, bound parameters. That {} is not Rust’s format!: the value false is stored separately and bound through sqlx’s parameterized query interface at execution time — no injection risk, ever. The preview shows it inline for readability.

In SQL persistences, a condition is just an expression. When you pass it to .with_condition(), it’s nested inside the select’s own expression tree. At execution time the whole tree is flattened into a single template + parameter list that the database driver can bind safely.

Run it — you should see 5 rows, with “A Stale Cake” filtered out.

Types and persistence rendering

Notice that you passed false but the preview shows 0. SQLite has no native boolean — Vantage’s SqliteType implementation for bool converts it to an integer automatically. PostgreSQL would render FALSE instead. Each persistence maps Rust types to the correct native representation.

The {} parameter accepts any type that implements SqliteType: bool, i64, f64, String, chrono::NaiveDate, Option<T>, and more. You can implement SqliteType for your own types too. See Persistence-aligned Type System for details.

Typed columns and operators

Writing \"is_deleted\" in a raw expression works, but there’s a cleaner way. Column<T> creates a typed column reference, then chain an SqliteOperation like .eq() to build the condition. (Column lives in the vantage-table crate, but the vantage_sql prelude re-exports it — no new dependency needed.)

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
let is_deleted = Column::<bool>::new("is_deleted");
let condition = is_deleted.eq(false);
}

The same condition, rendered as is_deleted = 0 (a typed column isn’t quoted), but the type parameter <bool> ensures you can only compare against matching types. Try is_deleted.eq(42) — it won’t compile. Other operators — .gt(), .lt(), .ne(), .in_() — enforce the same type safety.

The result is a SqliteCondition — the backend’s native condition type — ready to be passed directly to .with_condition().

Type safety and backend-specific operations

Each SQL backend has its own operation trait — SqliteOperation, PostgresOperation, MysqlOperation — imported automatically via the prelude. Type safety is enforced at the column level; conditions themselves are expressions, so operations chain further (price.gt(10).eq(false) reads “price > 10 is false”). Operations take ownership of their arguments — .clone() a column you want to reuse. The Expressive trait reference covers the chaining mechanics.

Multiple conditions combine with AND:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
let is_deleted = Column::<bool>::new("is_deleted");
let price = Column::<i64>::new("price");

let select = SqliteSelect::new()
    .with_source("product")
    .with_field("name")
    .with_condition(is_deleted.eq(false))
    .with_condition(price.gt(150));
// ... WHERE is_deleted = 0 AND price > 150
assert_eq!(
    select.preview(),
    r#"SELECT "name" FROM "product" WHERE is_deleted = 0 AND price > 150"#
);
}

Primitives for untyped access

Typed columns aren’t the only way to reference a column. Primitives are reusable building blocks for SQL expressions — they handle quoting, escaping, and vendor-specific syntax. The identifier primitive builds the same condition without declaring a type:

#![allow(unused)]
fn main() {
use vantage_sql::sqlite::sqlite_ident as ident;

let condition = ident("is_deleted").eq(false);
}

Primitives are not part of the prelude — import them when needed. Besides identifiers, you get Fx (function calls), Case, Concat, Interval, and more. The Primitives reference has the full list, including the typed (sqlite_ident(), pg_ident()) versus generic (ident()) identifier variants.

Primitives, query builders, Column, conditions — even native values like i64 and bool — all implement Expressive<T>, where T is your database’s any-type (AnySqliteType for SQLite, AnyMongoType for MongoDB). That shared trait is what makes any of them usable as a parameter inside any expression.


Working with Any-types

Record<V> is an ordered map (IndexMap<String, V>) — one record per row, with column names as keys. It lives in the vantage-types crate, so add that dependency and import its prelude:

cargo add vantage-types --features serde
use vantage_types::prelude::*;
fn main() {}

So far we’ve been printing raw AnySqliteType with Debug. That works for verifying queries, but it’s useless for real work. When db.execute() returns a multi-row result, the AnySqliteType holds an array of maps internally. Convert it to Vec<Record<AnySqliteType>> to work with individual rows:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
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");
let raw = db.execute(&select.expr()).await?;

let records = Vec::<Record<AnySqliteType>>::try_from(raw)
    .context("Failed to convert to records")?;

for rec in &records {
    let name: String = rec["name"].try_get::<String>().unwrap();
    let price: i64 = rec["price"].try_get::<i64>().unwrap();
    println!("{} — {} cents", name, price);
}
// Cupcake — 120 cents
// Doughnut — 135 cents
// ...
Ok(())
}

Access fields by column name with rec["name"]. Each value is still an AnySqliteType, so you call .try_get::<T>() to extract a typed Rust value. If the type doesn’t match (say you call .try_get::<i64>() on a text column), you get None — no panics, no garbage.

serde_json::Value conversion

If you need JSON-friendly records, call .into_record() on each Record<AnySqliteType>:

#![allow(unused)]
fn main() {
let json_rec: Record<serde_json::Value> = rec.into_record();
}

This is convenient for serialization, but serde_json::Value supports a narrower set of types — you’ll lose precision on Decimal and chrono types (dates become strings, decimals become floats). Stick with Record<AnySqliteType> when you need full type fidelity.

Under the hood, each persistence has its own type system for storing values. SQLite uses CBOR — a compact binary format that preserves types like Decimal, NaiveDate, and NaiveDateTime through tagged values. MongoDB uses BSON natively. The AnySqliteType / AnyMongoType wrappers hide these details — you interact with .try_get::<T>() regardless of which persistence you’re using. If you ever need to inspect the raw representation, .value() gives you the underlying CBOR value:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
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");
let records = Vec::<Record<AnySqliteType>>::try_from(db.execute(&select.expr()).await?)?;
let rec = &records[0];
let price_cbor = rec["price"].value();
println!("{:?}", price_cbor);
// Integer(Integer(120))
Ok(())
}

See Persistence-aligned Type System for the full picture.


Mapping rows to structs

Calling .try_get::<T>() on every field gets tedious. The #[entity] macro generates TryFromRecord<AnySqliteType> for your struct, so conversion happens in one call with no type information lost:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
struct Product {
    name: String,
    price: i64,
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
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");
let raw = db.execute(&select.expr()).await?;
let records = Vec::<Record<AnySqliteType>>::try_from(raw)?;

for rec in records {
    let product = Product::from_record(rec)?;
    println!("{} — {} cents", product.name, product.price);
}
Ok(())
}

The macro needs vantage-core as a direct dependency (it’s already a transitive dep through vantage-sql, but the generated code references it in your crate):

cargo add vantage-core

The macro also supports multiple type systems in one attribute — #[entity(SqliteType, PostgresType, MongoType)] generates a separate TryFromRecord impl for each persistence. One struct, all backends.

Serde alternative

#[entity] converts each field directly through the persistence’s type system — your struct fields just need to implement SqliteType. You could instead convert to Record<serde_json::Value> (as above) and derive serde::Deserialize — but that funnels values through JSON, with the same precision loss. Prefer #[entity] when your schema includes decimals or dates.


Putting it together

Here’s the complete src/main.rs — connect, query, convert to entities, print:

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(())
}
cargo run
# Cupcake      120 cents
# Doughnut     135 cents
# Tart         220 cents
# Pie          299 cents
# Cookies      199 cents

What we covered

ConceptWhat it doesMore info
SqliteSelectBuilds SELECT queries via builder patternSelectable
ColumnTyped column reference — enforces matching operand types
SqliteOperationExt trait giving .eq(), .gt(), etc. → SqliteCondition
sqlite_expr!Creates expressions with typed, bound parametersExpression
db.execute()Runs an expression, returns AnySqliteTypeExprDataSource
Record<V>Ordered map of column names to values — row-level access.try_get::<T>()
#[entity(SqliteType)]Generates lossless record-to-struct conversionTryFromRecord

Going deeper

This chapter used the smallest useful slice of the query layer. When you need more, the reference half of the book has it:

  • Expressions & Queries — how expressions nest and flatten, building lists with from_vec (multi-row INSERTs), and defer() — a parameter whose value is fetched from a different database at execution time.
  • SQL Primitives — the building blocks we skipped: or_() / and_() grouping, fx! function calls, Case, ternary(), concat_!, Interval, and portable date_format().
  • SQL: PostgreSQL, MySQL & SQLite — the other two SQL backends (same interface, different dialects) and the type-conversion tables showing exactly how chrono and decimal types round-trip per column type.
  • Three Paths — Query Building — worked examples of the heavy artillery: JOINs, CTEs, window functions, DISTINCT ON.

None of it is required for the next chapter — come back when a query calls for it.

Tables and Typed Data Access

A table is a structure representing a collection of records in a database:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
    name: String,
    price: i64,
}
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db")
    .await
    .context("Failed to connect to products.db")?;
let products = Product::table(db);

for (id, product) in products.list().await? {
    println!("{id}: {} — {} cents", product.name, product.price);
}
Ok(())
}

Think of Table<SqliteDB, Product> as a Vec<Product> — except the records aren’t in memory. They live somewhere else (a database, a file, an API), and you don’t know how many there are or what they contain until you list them.

If you’ve used an ORM before, you might expect Vantage to work with individual records — load one, change it, save it back. Vantage works differently: you always operate on a set of records. A set might contain one record, no records, or millions — you don’t pull them into memory to find out.

Operations like counting, filtering, and updating happen on the database side — Vantage builds the right query and sends it over. Even traversal operations and sub-queries are done by building queries and executing remotely — if the database supports it, of course.

When you .clone() a table, you don’t clone the data — you clone the definition. From there you can narrow it down by adding conditions, turning it into a subset. Some examples of sets you might work with:

  • all user records except those with a soft-delete flag
  • orders placed today
  • orders of paid customers
  • customers who have an unpaid invoice older than 30 days
  • products that sold at least 5 items today

Each of these is a set — defined by conditions, not a list of IDs — and one set can serve as the condition of another (“orders of paid customers”). Acting on a set is a single call: notify those customers, archive those orders, apply that discount. Put together, even complex operations are easy to express in code:

use vantage_sql::prelude::*;
use vantage_sql::primitives;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Customer {
    name: String,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Invoice {
    status: String,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Order {
    total: i64,
}
impl Customer {
    fn table(db: SqliteDB) -> Table<SqliteDB, Customer> {
        Table::new("customer", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("orders", "customer_id", Order::table)
    }
}
impl Invoice {
    fn table(db: SqliteDB) -> Table<SqliteDB, Invoice> {
        Table::new("invoice", db)
            .with_id_column("id")
            .with_column_of::<String>("status")
            .with_column_of::<String>("issued_at")
            .with_one("customer", "customer_id", Customer::table)
    }
}
impl Order {
    fn table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("order", db)
            .with_id_column("id")
            .with_column_of::<i64>("total")
            .with_column_of::<String>("placed_at")
    }
}
fn days_ago(days: i64) -> Expression<AnySqliteType> {
    let offset = format!("-{days} days");
    sqlite_expr!("date('now', {})", offset)
}
trait InvoiceTable {
    fn ref_customer(&self) -> Table<SqliteDB, Customer>;
}
impl InvoiceTable for Table<SqliteDB, Invoice> {
    fn ref_customer(&self) -> Table<SqliteDB, Customer> {
        self.get_ref_as("customer").unwrap()
    }
}
trait CustomerTable {
    fn subquery_orders(&self) -> Table<SqliteDB, Order>;
    async fn send_reminder(&self) -> VantageResult<()>;
}
impl CustomerTable for Table<SqliteDB, Customer> {
    fn subquery_orders(&self) -> Table<SqliteDB, Order> {
        self.get_subquery_as("orders").unwrap()
    }
    async fn send_reminder(&self) -> VantageResult<()> {
        for (_, customer) in self.list().await? {
            println!("reminder sent to {}", customer.name);
        }
        Ok(())
    }
}
trait OrderTable {
    fn only_this_month(self) -> Self;
    fn field_sum(&self, column: &str) -> Expression<AnySqliteType>;
}
impl OrderTable for Table<SqliteDB, Order> {
    fn only_this_month(self) -> Self {
        let placed_at = self["placed_at"].clone();
        self.with_condition(placed_at.gt(sqlite_expr!("date('now', 'start of month')")))
    }
    fn field_sum(&self, column: &str) -> Expression<AnySqliteType> {
        self.get_sum_query(&self[column])
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:shop.db").await.context("Failed to connect")?;
// "notify customers who have an unpaid invoice older than 30 days"
let mut overdue = Invoice::table(db.clone());
overdue.add_condition(overdue["status"].eq("unpaid"));
overdue.add_condition(overdue["issued_at"].lt(days_ago(30)));

overdue.ref_customer().send_reminder().await?;

// "apply a discount to customers who spent more than $500 this month"
let customers = Customer::table(db)
    .with_expression("spent_this_month", |c| {
        c.subquery_orders().only_this_month().field_sum("total")
    })
    .with_expression("discount", |c| {
        let spent = c.get_column_expr("spent_this_month").unwrap();
        primitives::ternary(spent.gt(500), 10, 0).expr()
    });
Ok(())
}

Don’t worry about the exact syntax — we’ll get to all of it. ref_customer, send_reminder, subquery_orders, only_this_month and field_sum aren’t framework calls: they’re methods the model defines on its own tables, and Extension traits below shows how. The point is that sets compose naturally: define one, use it to narrow another, then act on the result.

Defining a Table

The syntax for defining a table looks similar to query building from chapter 1, but there are important differences:

QueryTable
LifetimeBuilt, executed, droppedSticks around, spawns many queries
OperationsOne SQL statement (SELECT, INSERT, …)Higher-level CRUD: list, get, add, patch, delete
ColumnsString field names via .with_field()Typed Column<T> definitions
DatabaseNot bound — just a structHolds a database reference (Arc)
IdempotencyINSERT fails on duplicate keyevery write is retry-safe (Safe writes)
Data sourcesOnly databases with a query languageAny data source: SQL, CSV, APIs, queues, etc.

A Table is typically defined in its own file alongside the entity. Create src/product.rs:

// src/product.rs
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
}

impl Product {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        let is_deleted = Column::<bool>::new("is_deleted");
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<bool>("is_deleted")
            .with_condition(is_deleted.eq(false))
    }
}
fn main() {}

Hiding the db argument

In most cases you already know which database a table lives in — passing db every time is noise. A common pattern is to wrap the connection in a global accessor:

#![allow(unused)]
fn main() {
impl Product {
    fn table() -> Table<SqliteDB, Product> {
        Table::new("product", get_sqlite_db())
            // ...columns...
    }
}
}

We’ll keep passing db explicitly in this tutorial for clarity, but real applications typically use this pattern.

This defines the product table once. You can now generate queries from it:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
    name: String,
    price: i64,
}
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        let is_deleted = Column::<bool>::new("is_deleted");
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<bool>("is_deleted")
            .with_condition(is_deleted.eq(false))
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let table = Product::table(db);

// Full SELECT with all columns and conditions applied
let select = table.select();
println!("{}", select.preview());
// SELECT "id", "name", "price", "is_deleted" FROM "product" WHERE is_deleted = 0

// Count query (does not execute — just builds the expression)
let count_query = table.get_count_query();
// (SELECT COUNT(*) FROM "product" WHERE is_deleted = 0)

// Sum query for a specific column
let sum_query = table.get_sum_query(&table["price"]);
// (SELECT SUM(price) FROM "product" WHERE is_deleted = 0)
assert_eq!(
    select.preview(),
    r#"SELECT "id", "name", "price", "is_deleted" FROM "product" WHERE is_deleted = 0"#
);
assert_eq!(
    count_query.preview(),
    r#"(SELECT COUNT(*) FROM "product" WHERE is_deleted = 0)"#
);
assert_eq!(
    sum_query.preview(),
    r#"(SELECT SUM(price) FROM "product" WHERE is_deleted = 0)"#
);
Ok(())
}

These return the same SqliteSelect and Expression types from chapter 1 — the table just assembles them for you, with its conditions already applied. The count and sum queries come wrapped in parentheses so they can nest inside another query as a subquery. But most of the time you won’t need the raw query at all.


CRUD operations

With the table defined, all four operations — create, read, update, delete — are one-liners. Each comes from a trait in the prelude:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
    name: String,
    price: i64,
}
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        let is_deleted = Column::<bool>::new("is_deleted");
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<bool>("is_deleted")
            .with_condition(is_deleted.eq(false))
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db")
    .await
    .context("Failed to connect to products.db")?;
let table = Product::table(db);

// Read — list all, get one by ID
let all = table.list().await?;             // IndexMap<String, Product>
let pie = table.get("4").await?;           // Option<Product> — id 4 is Pie

// Create — insert with a known ID
let muffin = Product { name: "Muffin".into(), price: 175 };
table.insert("8", &muffin).await?;

// Update — replace the entire record
let updated = Product { name: "Blueberry Muffin".into(), price: 195 };
table.replace("8", &updated).await?;

// Delete
table.delete("8").await?;
Ok(())
}

That’s it. list() returns an IndexMap<Id, Product> — ordered and keyed by ID. get() returns Option<Product> — None when the ID doesn’t exist, so you can pattern-match or .ok_or(...) into your own not-found error. There’s also get_some() which returns an (Id, Product) pair (still Option-wrapped) for sampling, and insert_return_id() for when you want the database to generate the ID; see Safe writes for why a client-made id is the retry-safe choice.

Ids are strings

Our id column is INTEGER PRIMARY KEY, yet every id above is a string. That’s deliberate: at the Vantage API level ids are always strings — the one type that can carry any backend’s key (SQLite rowids, Mongo ObjectIds, S3 object keys). The persistence binds the string into the id-column comparison when it builds the query, so get("4") matches the integer 4. You’ll meet the same convention wherever ids travel through the framework — including the JSON output in the next chapter.

Idempotent operations

Try repeating the insert, replace and delete calls — the result is the same: an insert of an existing id returns the stored row, a replace with the same values changes nothing, and deleting a row that’s gone succeeds. See Safe writes.


Listing any table

Table<SqliteDB, Product> is great when you know the types at compile time. Sometimes you want a function that lists any table — products, orders, customers — without naming the entity type.

With generics you keep full type safety, limited to Rust callers:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
async fn list_table<D, E>(table: &D) -> VantageResult<()>
where
    D: ReadableDataSet<E>,
    D::Id: std::fmt::Display,
    E: Entity<D::Value> + std::fmt::Debug,
{
    for (id, entity) in table.list().await? {
        println!("  {}: {:?}", id, entity);
    }
    Ok(())
}
fn main() {}

The entity bound names the dataset’s own value type (Entity<D::Value>): an entity is only an entity of a particular type system, and that’s what lets list() decode rows losslessly.

To go fully type-erased — iterate columns by name, get runtime values, and expose tables outside Rust (a web admin UI, FFI, scripting) — wrap the table in a Vista, the universal schema-bearing data handle. That’s the subject of its own chapter; Vista replaced the older AnyTable carrier in 0.5.


Relationships

Our database has a category table that we haven’t used yet. Let’s define it and connect it to products.

Create src/category.rs:

// src/category.rs
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

use crate::product::Product;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Category {
    pub name: String,
}

impl Category {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("products", "category_id", Product::table)
    }
}
mod product {
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    #[entity(SqliteType)]
    #[derive(Clone, Default)]
    pub struct Product {
        pub name: String,
        pub price: i64,
    }
    impl Product {
        pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
            let is_deleted = Column::<bool>::new("is_deleted");
            Table::new("product", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_column_of::<i64>("price")
                .with_column_of::<bool>("is_deleted")
                .with_condition(is_deleted.eq(false))
        }
    }
}
fn main() {}

The important line is .with_many("products", "category_id", Product::table). This declares:

  • “products” — the name of the relationship (you’ll use this to traverse it)
  • “category_id” — the foreign key column on the product table
  • Product::table — a function that builds the target table (same one we defined earlier)

No changes to Product are needed — the relationship is declared on the side that “owns” it. Category has many products; the foreign key lives on product.

Given a set of categories, get_ref_as traverses the relationship and returns matching products:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
    name: String,
    price: i64,
}
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        let is_deleted = Column::<bool>::new("is_deleted");
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<bool>("is_deleted")
            .with_condition(is_deleted.eq(false))
    }
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Category {
    name: String,
}
impl Category {
    fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("products", "category_id", Product::table)
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let categories = Category::table(db.clone());
let products = categories.get_ref_as::<Product>("products")?;
Ok(())
}

The result is a Table<SqliteDB, Product> with an extra condition — only products whose category_id matches one of the category IDs. Narrow the categories first and the subquery narrows too. Let’s use this in a CLI that accepts an optional search filter. The .with_search() method adds a LIKE condition across all columns of the table — handy for quick filtering.

Update src/main.rs. Declare the two modules at the top with mod category; and mod product;, then:

mod product {
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    #[entity(SqliteType)]
    #[derive(Clone, Default)]
    pub struct Product {
        pub name: String,
        pub price: i64,
    }
    impl Product {
        pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
            let is_deleted = Column::<bool>::new("is_deleted");
            Table::new("product", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_column_of::<i64>("price")
                .with_column_of::<bool>("is_deleted")
                .with_condition(is_deleted.eq(false))
        }
    }
}
mod category {
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::product::Product;
    #[entity(SqliteType)]
    #[derive(Clone, Default)]
    pub struct Category {
        pub name: String,
    }
    impl Category {
        pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
            Table::new("category", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_many("products", "category_id", Product::table)
        }
    }
}
use category::Category;
use product::Product;
use vantage_sql::prelude::*;

async fn list_products(table: &Table<SqliteDB, Product>) -> VantageResult<()> {
    for (id, p) in table.list().await? {
        println!("  {:<4} {:<20} {:>3} cents", id, p.name, p.price);
    }
    Ok(())
}

#[tokio::main]
async fn main() {
    if let Err(e) = run().await {
        e.report();
    }
}

async fn run() -> VantageResult<()> {
    let db = SqliteDB::connect("sqlite:products.db")
        .await
        .context("Failed to connect to products.db")?;

    let filter = std::env::args().nth(1);

    let products = match &filter {
        Some(search) => Category::table(db.clone())
            .with_search(search)
            .get_ref_as::<Product>("products")?,
        None => Product::table(db),
    };

    list_products(&products).await?;

    Ok(())
}

Try it:

cargo run                # all active products
cargo run "Sweet"        # Cupcake, Doughnut, Cookies
cargo run "Pastries"     # Tart, Pie
cargo run "t"            # matches "Sweet Treats" AND "Pastries" — 5 products

The search "t" matches two categories (“Sweet Treats” and “Pastries”), so products from both are returned.

Sets, not joins

Notice what happened with cargo run "t": the search matched two categories, and we got products from both — without writing a JOIN or collecting IDs manually.

This is because Vantage relationships work through sets. with_search("t") narrowed the category table to two rows. get_ref_as then generated a subquery:

SELECT ... FROM "product"
WHERE "category_id" IN (SELECT "id" FROM "category" WHERE "name" LIKE '%t%')
AND "is_deleted" = 0

The subquery comes from whatever conditions are on the source table. Add more conditions and the subquery narrows further. The relationship definition stays the same — you never need to rewrite the traversal logic.


Computed fields with expressions

So far, list_products only shows name and price. It would be nice to show the category name too — but Product doesn’t have a category field, and the category name lives in a different table.

Vantage solves this with expressions — computed fields that are evaluated as part of the SELECT query. You define them on the table, and they appear alongside regular columns.

First, add a with_one relationship on Product (the reverse of with_many on Category):

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
    name: String,
    price: i64,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Category {
    name: String,
}
impl Category {
    fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let products: Table<SqliteDB, Product> = Table::new("product", db)
    .with_id_column("id")
    .with_column_of::<i64>("category_id")
.with_one("category", "category_id", Category::table)
;
Ok(())
}

This says: each product has one category, linked through category_id. Now you can use get_subquery_as inside with_expression to build a correlated subquery:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
    name: String,
    price: i64,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Category {
    name: String,
}
impl Category {
    fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let products: Table<SqliteDB, Product> = Table::new("product", db)
    .with_id_column("id")
    .with_column_of::<i64>("category_id")
    .with_one("category", "category_id", Category::table)
.with_expression("category", |t| {
    t.get_subquery_as::<Category>("category")
        .unwrap()
        .select_column("name")
        .expect("Category has a 'name' column")
})
;
Ok(())
}

select_column("name") builds a subquery like SELECT "name" FROM "category" WHERE "id" = "product"."category_id" — a correlated subquery that fetches the category name for each product row. It returns an Option, None when the target table has no such column; the name here is hard-coded and known to exist, so .expect() is the honest unwrap.

Here’s the updated src/product.rs:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

use crate::category::Category;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub category: Option<String>,
}

impl Product {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        let is_deleted = Column::<bool>::new("is_deleted");
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<bool>("is_deleted")
            .with_condition(is_deleted.eq(false))
            .with_one("category", "category_id", Category::table)
            .with_expression("category", |t| {
                t.get_subquery_as::<Category>("category")
                    .unwrap()
                    .select_column("name")
                    .expect("Category has a 'name' column")
            })
    }
}
mod category {
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::Product;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default)]
    pub struct Category {
        pub name: String,
    }
    impl Category {
        pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
            Table::new("category", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_many("products", "category_id", Product::table)
        }
    }
}
fn main() {}

A few things to note:

  • category: Option<String> — the field is Option because a product might not have a category (NULL in the database). The expression result is deserialized into this field automatically.
  • No with_column_of for “category” — the expression replaces what would normally be a column. Vantage adds it to the SELECT as a computed field.
  • with_one + with_expression — the relationship defines how to traverse; the expression defines what to fetch. They work together but serve different purposes.

Update list_products to show the category:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product {
    name: String,
    price: i64,
    category: Option<String>,
}
async fn list_products(table: &Table<SqliteDB, Product>) -> VantageResult<()> {
    for (id, p) in table.list().await? {
        let cat = p.category.as_deref().unwrap_or("-");
        println!("  {:<4} {:<20} {:>3} cents  [{}]", id, p.name, p.price, cat);
    }
    Ok(())
}
fn main() {}

Run it:

cargo run
  1    Cupcake              120 cents  [Sweet Treats]
  2    Doughnut             135 cents  [Sweet Treats]
  3    Tart                 220 cents  [Pastries]
  4    Pie                  299 cents  [Pastries]
  5    Cookies              199 cents  [Sweet Treats]
  7    Sourdough Loaf       350 cents  [Breads]

The category name comes from a subquery — no JOIN, no extra round trip. And the category filter still works:

cargo run "t"
  1    Cupcake              120 cents  [Sweet Treats]
  2    Doughnut             135 cents  [Sweet Treats]
  3    Tart                 220 cents  [Pastries]
  4    Pie                  299 cents  [Pastries]
  5    Cookies              199 cents  [Sweet Treats]

Expressions compose

Expressions can reference other expressions. For example, we could add a product_count expression to Category that counts products via a subquery, then build a title expression that combines the name and count:

#![allow(unused)]
fn main() {
// In Category::table()
.with_expression("product_count", |t| {
    t.get_subquery_as::<Product>("products")
        .unwrap()
        .get_count_query()
})
.with_expression("title", |t| {
    let name = t.get_column_expr("name").unwrap();
    let count = t.get_column_expr("product_count").unwrap();
    concat_!(name, " (", count, ")").expr()
})
}

get_column_expr returns the expression for either a real column or a computed expression by name. Here it resolves "product_count" to the count subquery, so title becomes something like name || ' (' || (SELECT COUNT(*) FROM product WHERE ...) || ')'.

Now change Product’s category expression to fetch title instead of name:

#![allow(unused)]
fn main() {
.with_expression("category", |t| {
    t.get_subquery_as::<Category>("category")
        .unwrap()
        .select_column("title")
        .expect("Category has a 'title' column")
})
}

select_column("title") resolves the title expression on Category and builds it into the subquery. The result:

  1    Cupcake              120 cents  [Sweet Treats (3)]
  2    Doughnut             135 cents  [Sweet Treats (3)]
  3    Tart                 220 cents  [Pastries (2)]
  4    Pie                  299 cents  [Pastries (2)]
  5    Cookies              199 cents  [Sweet Treats (3)]
  7    Sourdough Loaf       350 cents  [Breads (1)]

Vantage renders everything into a single SQL query. The nested subqueries might look redundant, but modern SQL databases optimise and execute them efficiently — there’s no extra round trip to the database.


Extension traits

Throughout this chapter you’ve seen calls like categories.get_ref_as::<Product>("products"). The turbofish ::<Product> and the string "products" are repetitive and error-prone — get the string wrong and you get a runtime error.

Rust’s extension traits solve this. Define a trait on Table<SqliteDB, Category> that wraps the traversal:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
    name: String,
    price: i64,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Category {
    name: String,
}
pub trait CategoryTable {
    fn ref_products(&self) -> Table<SqliteDB, Product>;
}

impl CategoryTable for Table<SqliteDB, Category> {
    fn ref_products(&self) -> Table<SqliteDB, Product> {
        self.get_ref_as("products").unwrap()
    }
}
fn main() {}

The trait declares the vocabulary; the impl block supplies the bodies. Nothing is auto-implemented — ref_products() appears on Table<SqliteDB, Category> the moment you write this impl, and on nothing else. The bodies must live impl-side, too: methods like get_ref_as are inherent to Table, so they’re only reachable where self is concretely that table — inside the impl, not inside a trait default.

The unwrap() is safe because we know “products” is defined on every CategoryTable. If the string were wrong, it would panic immediately during development, not silently fail at runtime.

Now callers write categories.ref_products() — no turbofish, no string, no ?, and the compiler catches typos.

The same pattern works for typed column access:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
    name: String,
    price: i64,
}
pub trait ProductTable {
    fn price(&self) -> Column<i64>;
}

impl ProductTable for Table<SqliteDB, Product> {
    fn price(&self) -> Column<i64> {
        self.get_column("price").unwrap()
    }
}
fn main() {}

With both traits in place, code reads naturally:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
    name: String,
    price: i64,
}
impl Product {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        let is_deleted = Column::<bool>::new("is_deleted");
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<bool>("is_deleted")
            .with_condition(is_deleted.eq(false))
    }
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Category {
    name: String,
}
impl Category {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("products", "category_id", Product::table)
    }
}
pub trait CategoryTable {
    fn ref_products(&self) -> Table<SqliteDB, Product>;
}
impl CategoryTable for Table<SqliteDB, Category> {
    fn ref_products(&self) -> Table<SqliteDB, Product> {
        self.get_ref_as("products").unwrap()
    }
}
pub trait ProductTable {
    fn price(&self) -> Column<i64>;
}
impl ProductTable for Table<SqliteDB, Product> {
    fn price(&self) -> Column<i64> {
        self.get_column("price").unwrap()
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let categories = Category::table(db);
let products = categories.ref_products();
let price = products.price();
let expensive = products.with_condition(price.gt(200));
assert!(expensive.select().preview().contains("price > 200"));
Ok(())
}

price() is read before with_condition because with_condition consumes the table it’s called on; taking the column first keeps the borrow and the move apart.

Where to put extension traits

Define extension traits in the same file as the entity — product.rs gets ProductTable, category.rs gets CategoryTable. They’re part of your model’s public API: anyone who imports the entity gets the typed accessors for free.

This is how Vantage scales to large codebases. The table definition (columns, conditions, relationships, expressions) is declared once. Extension traits give it a clean, typed API. Business logic code never sees raw strings or turbofish — just method calls.

Custom methods on extension traits

Extension traits aren’t limited to column and relationship accessors — you can add any business logic. For example, the vantage-cli-util crate provides print_table(), which accepts any &Table<T, E> and renders it for the terminal. Declare the method on your trait and implement it in the same impl block as the accessors:

#![allow(unused)]
fn main() {
pub trait ProductTable {
    fn price(&self) -> Column<i64>;
    async fn print(&self) -> VantageResult<()>;
}

impl ProductTable for Table<SqliteDB, Product> {
    fn price(&self) -> Column<i64> {
        self.get_column("price").unwrap()
    }

    async fn print(&self) -> VantageResult<()> {
        vantage_cli_util::print_table(self).await
    }
}
}

Now products.print().await? gives you a formatted table in the terminal:

+----+----------------+-------+-------------------+
| id | name           | price | category          |
+----+----------------+-------+-------------------+
| 1  | Cupcake        | 120   | Sweet Treats (3)  |
| 2  | Doughnut       | 135   | Sweet Treats (3)  |
| 3  | Tart           | 220   | Pastries (2)      |
| ...                                              |
+----+----------------+-------+-------------------+

Other methods you might add: only_expensive() that returns a filtered clone, total_revenue() that computes a sum, or export_csv() that writes to a file. The table is yours to extend.


Persistence Abstraction

The files you have written so far — Cargo.toml, product.rs, category.rs, main.rs — are the makings of an extensible business software architecture: scalable, maintainable, testable, and portable across databases without rewriting a single line of business logic.

What you have is four distinct components, each with a clear job:

flowchart TD
    A["<b>Business code</b><br/><code>main.rs</code><br/>minimal, domain-focused"]
    B["<b>Model definitions</b><br/><code>product.rs</code>, <code>category.rs</code><br/>entities, tables, relationships, domain methods"]
    C["<b>Persistence crate</b><br/><code>vantage-sql</code> (or surrealdb, mongodb, csv…)<br/>query builder, CRUD, type system"]
    D["<b>Vantage framework</b><br/><code>vantage-table</code>, <code>vantage-expressions</code>, <code>vantage-types</code><br/>the extensible mechanism that ties it together"]
    A --> B
    B --> C
    C --> D

The majority of your business code can work with data without any knowledge of where it’s stored or how. It operates on typed entities, relationships, and domain methods — nothing else.

The model definitions focus on describing where and how data is stored, but they don’t micro-manage the process. If storage requirements change — a new backend, a schema migration, a cached layer in front — the model is where you make the tweaks. Business code remains untouched.

This separation gives you the ergonomics scripting-language ORMs are known for — concise, domain-focused code — with Rust’s type safety and performance underneath: the abstraction compiles down to direct calls. When storage requirements shift, the change stays contained in the model layer instead of rippling through business code, and the whole architecture travels as a single static binary — server, desktop, or embedded.

Going deeper

Table has considerably more surface than one chapter can show. The reference pages pick up where this one stops:

  • Records: Traversal, Invariants & Hooks — lifecycle hooks (audit stamps, validation, soft-delete that intercepts a real delete), set invariants that fill and enforce foreign keys on every write through a relation, and traversing relations from a single loaded record.
  • Expressions & Queries — the machinery underneath with_expression and every condition you wrote in this chapter.
  • Model-Driven Architecture — how entities, table constructors, and connection management are organised in a real model crate, including one entity with constructors for several backends.
  • Three Paths for Developers — how this chapter’s entity framework relates to raw query building and custom persistences, and when to reach for each.

A sneak peek at what’s next

The pattern you’ve just built scales well beyond a product catalog:

  • Client-side caching and reactive data — the second half of this guide puts a model behind a persistent cache with live updates streaming to a UI, over a slow external API or your own relational database — whichever path matches your problem.
  • Cross-persistence traversal — follow a relationship from a Postgres table into a MongoDB collection, expressed in Rust, executed efficiently on each side.
  • UI framework integration — the same model drives desktop, terminal, and web UIs through vantage-ui-adapters: define once, render anywhere.

A Standalone Axum Server

Chapter 2 ended with a CLI that prints products to stdout. In this chapter we put the same model behind an HTTP API:

curl "http://localhost:3001/categories"
[{ "name": "Sweet Treats" }, { "name": "Pastries" }, { "name": "Breads" }]
# Products in a specific category
curl "http://localhost:3001/categories/1/products"

# Writes
curl -X POST "http://localhost:3001/categories" \
  -H 'content-type: application/json' -d '{"name":"Gluten-Free"}'

A few things about the shape of this API:

  • /categories/{id}/products is a nested route — products narrowed by the relationship we defined in chapter 2. The handler for it is the same generic list as /categories, just applied to a scoped table.
  • /products and /categories start out on SQLite, same as chapter 2. Toward the end of the chapter we migrate them to MongoDB. That migration is a change to the model file; the handlers and routes don’t know the difference.

The handler functions are each written once and mounted against any Table<Backend, Entity> the router has on hand — one list, one get, one post, one patch, one delete, parameterised over the backend and entity types. Adding another entity is a route registration, not a new handler.


The minimum Axum skeleton

Two pieces: a small adaptation to chapter 2’s entities so they round-trip through HTTP, then the server itself. One new dependency:

cargo add axum

Tokio and serde are already pulled in from earlier chapters. axum is the HTTP framework; it uses the existing serde to encode response bodies as JSON.

Entities. Chapter 2’s Product carried a computed category field, and the Expressions compose callout showed Category gaining a computed title. Both were assembled from subqueries — great for display, but they don’t round-trip cleanly through a POST body because the JSON would try to write columns that don’t exist. For a writable API we replace the computed fields with the plain FK column that sits under them. (See with_expression for the chapter-2 computed-field setup we’re walking away from here.)

src/category.rs:

use serde::{Deserialize, Serialize};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

use crate::product::Product;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Category {
    pub name: String,
}

impl Category {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("products", "category_id", Product::table)
    }
}
mod product {
    use serde::{Deserialize, Serialize};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::Category;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Product {
        pub name: String,
        pub price: i64,
        #[serde(skip_serializing_if = "Option::is_none", default)]
        pub category_id: Option<String>,
        #[serde(default)]
        pub is_deleted: bool,
    }
    impl Product {
        pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
            let is_deleted = Column::<bool>::new("is_deleted");
            Table::new("product", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_column_of::<i64>("price")
                .with_column_of::<String>("category_id")
                .with_column_of::<bool>("is_deleted")
                .with_condition(is_deleted.eq(false))
                .with_one("category", "category_id", Category::table)
        }
    }
}
fn main() {}

src/product.rs:

use serde::{Deserialize, Serialize};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

use crate::category::Category;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Product {
    pub name: String,
    pub price: i64,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub category_id: Option<String>,
    #[serde(default)]
    pub is_deleted: bool,
}

impl Product {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        let is_deleted = Column::<bool>::new("is_deleted");
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<String>("category_id")
            .with_column_of::<bool>("is_deleted")
            .with_condition(is_deleted.eq(false))
            .with_one("category", "category_id", Category::table)
    }
}
mod category {
    use serde::{Deserialize, Serialize};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::Product;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Category {
        pub name: String,
    }
    impl Category {
        pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
            Table::new("category", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_many("products", "category_id", Product::table)
        }
    }
}
fn main() {}

Two things to notice:

  • category_id is now a real column in both the struct and the table, not a computed expression. Same information as before, but clients can read it from GET and supply it on POST.
  • is_deleted picks up #[serde(default)] so POST /products bodies don’t have to carry it; new products default to live.

The soft-delete condition from chapter 2 stays — GET /products will still hide rows with is_deleted = true.

Now the server. Replace src/main.rs with the code below, keeping chapter 2’s mod category; and mod product; declarations at the top:

mod product {
    use serde::{Deserialize, Serialize};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::category::Category;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Product {
        pub name: String,
        pub price: i64,
        #[serde(skip_serializing_if = "Option::is_none", default)]
        pub category_id: Option<String>,
        #[serde(default)]
        pub is_deleted: bool,
    }
    impl Product {
        pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
            let is_deleted = Column::<bool>::new("is_deleted");
            Table::new("product", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_column_of::<i64>("price")
                .with_column_of::<String>("category_id")
                .with_column_of::<bool>("is_deleted")
                .with_condition(is_deleted.eq(false))
                .with_one("category", "category_id", Category::table)
        }
    }
}
mod category {
    use serde::{Deserialize, Serialize};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::product::Product;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Category {
        pub name: String,
    }
    impl Category {
        pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
            Table::new("category", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_many("products", "category_id", Product::table)
        }
    }
}
use std::sync::OnceLock;

use axum::{routing::get, Json, Router};
use category::Category;
use vantage_sql::prelude::*;

static DB: OnceLock<SqliteDB> = OnceLock::new();

fn db() -> SqliteDB {
    DB.get().expect("database not initialised").clone()
}

async fn list_categories() -> Json<Vec<Category>> {
    let rows = Category::table(db()).list().await.unwrap();
    Json(rows.into_values().collect())
}

#[tokio::main]
async fn main() -> VantageResult<()> {
    let conn = SqliteDB::connect("sqlite:products.db")
        .await
        .context("Failed to connect to products.db")?;
    DB.set(conn).ok();

    let app = Router::new().route("/categories", get(list_categories));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
    axum::serve(listener, app).await.unwrap();

    Ok(())
}

A few notes on what this is doing:

  • static DB: OnceLock<SqliteDB>. The handler needs a database handle but we don’t want to open a new connection per request. A program-wide OnceLock is the simplest possible holder — set once in main, read from anywhere.
  • Each request calls Category::table(db()) — building the table (columns, relationships) and then lists it.
  • The handler returns Json<Vec<Category>> directly. No DTO layer — the entity’s fields become the JSON fields.

Run it:

cargo run

In another terminal:

curl http://localhost:3001/categories
[
  { "name": "Sweet Treats" },
  { "name": "Pastries" },
  { "name": "Breads" }
]

Caching table definitions

We can cache each table definition so it’s built once and handed out by reference on every subsequent call. In src/category.rs, add use std::sync::OnceLock; at the top and wrap the body of Category::table:

use serde::{Deserialize, Serialize};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use std::sync::OnceLock;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Category {
    pub name: String,
}
impl Category {
    pub fn table(db: SqliteDB) -> &'static Table<SqliteDB, Category> {
        static CACHE: OnceLock<Table<SqliteDB, Category>> = OnceLock::new();
        CACHE.get_or_init(|| {
            Table::new("category", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_many("products", "category_id", |db| Product::table(db).clone())
        })
    }
}
#[entity(SqliteType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Product {
    pub name: String,
    pub price: i64,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub category_id: Option<String>,
    #[serde(default)]
    pub is_deleted: bool,
}
impl Product {
    pub fn table(db: SqliteDB) -> &'static Table<SqliteDB, Product> {
        static CACHE: OnceLock<Table<SqliteDB, Product>> = OnceLock::new();
        CACHE.get_or_init(|| {
            let is_deleted = Column::<bool>::new("is_deleted");
            Table::new("product", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
                .with_column_of::<i64>("price")
                .with_column_of::<String>("category_id")
                .with_column_of::<bool>("is_deleted")
                .with_condition(is_deleted.eq(false))
                .with_one("category", "category_id", |db| Category::table(db).clone())
        })
    }
}
fn main() {}

Three changes from chapter 2’s version:

  • Return type is &'static Table<...> instead of Table<...>. The cache owns the definition; callers get a shared reference. Most table operations (list, get, insert, replace, delete, get_ref_as) take &self, so they work on the reference without any clone.
  • static CACHE: OnceLock<...> lives inside the function. A function-local static is still a program-wide single instance — Rust allows this and it keeps the cache private to the table that owns it.
  • The with_many callback is a closure: |db| Product::table(db).clone(). The framework still hands us a db and expects an owned Table<SqliteDB, Product> back, so we call the cached accessor and clone the reference. On the first call this triggers Product’s cache to build; every subsequent call just clones a pre-built definition.

src/product.rs follows the same pattern — wrap the body in OnceLock::get_or_init, change the return type to &'static Table<SqliteDB, Product>, and rewrite with_one as |db| Category::table(db).clone().

The handler in main.rs doesn’t change at all — Category::table(db()) used to return an owned Table, and now it returns a &'static Table that auto-derefs through the same .list() call. Restart the server and hit /categories — the response is identical, but now the table definitions are built on the first request and reused forever after.

When the cached table needs narrowing

Most operations work on &Table<...>, but the builder methods that narrow it — like with_condition, with_search, and with_order — consume self and return a new Table, and the setters (add_condition, set_pagination) need &mut. If a handler needs to narrow the cached definition, it clones first:

#![allow(unused)]
fn main() {
let narrowed = Category::table(db())
    .clone()                      // owned copy of the cached definition
    .with_condition(...)           // now we can narrow it
}

That’s chapter 2’s rule in action — cloning a table copies the definition, and it’s cheap.


Products of a category

The with_many relationship we kept from chapter 2 lets us serve a nested route — /categories/{id}/products. Before writing the handler, give the relationship a proper name with an extension trait on Table<SqliteDB, Category>. Add this to src/category.rs:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Category {
    pub name: String,
}
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
}
pub trait CategoryTable {
    fn ref_products(&self) -> Table<SqliteDB, Product>;
}

impl CategoryTable for Table<SqliteDB, Category> {
    fn ref_products(&self) -> Table<SqliteDB, Product> {
        self.get_ref_as("products").unwrap()
    }
}
fn main() {}

Chapter 2 introduced this pattern — a typed, discoverable name instead of the string-and-turbofish call. The .unwrap() is safe here because we’re the ones who registered "products"; a typo surfaces immediately at startup.

Now the handler. Take the cached category table, narrow it to a single id, and traverse the relationship:

mod product {
    use std::sync::OnceLock;
    use serde::{Deserialize, Serialize};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::category::Category;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Product {
        pub name: String,
        pub price: i64,
        #[serde(skip_serializing_if = "Option::is_none", default)]
        pub category_id: Option<String>,
        #[serde(default)]
        pub is_deleted: bool,
    }
    impl Product {
        pub fn table(db: SqliteDB) -> &'static Table<SqliteDB, Product> {
            static CACHE: OnceLock<Table<SqliteDB, Product>> = OnceLock::new();
            CACHE.get_or_init(|| {
                let is_deleted = Column::<bool>::new("is_deleted");
                Table::new("product", db)
                    .with_id_column("id")
                    .with_column_of::<String>("name")
                    .with_column_of::<i64>("price")
                    .with_column_of::<String>("category_id")
                    .with_column_of::<bool>("is_deleted")
                    .with_condition(is_deleted.eq(false))
                    .with_one("category", "category_id", |db| Category::table(db).clone())
            })
        }
    }
}
mod category {
    use std::sync::OnceLock;
    use serde::{Deserialize, Serialize};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::product::Product;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Category {
        pub name: String,
    }
    impl Category {
        pub fn table(db: SqliteDB) -> &'static Table<SqliteDB, Category> {
            static CACHE: OnceLock<Table<SqliteDB, Category>> = OnceLock::new();
            CACHE.get_or_init(|| {
                Table::new("category", db)
                    .with_id_column("id")
                    .with_column_of::<String>("name")
                    .with_many("products", "category_id", |db| Product::table(db).clone())
            })
        }
    }
    pub trait CategoryTable {
        fn ref_products(&self) -> Table<SqliteDB, Product>;
    }
    impl CategoryTable for Table<SqliteDB, Category> {
        fn ref_products(&self) -> Table<SqliteDB, Product> {
            self.get_ref_as("products").unwrap()
        }
    }
}
use std::sync::OnceLock;
use axum::Json;
use category::Category;
use vantage_sql::prelude::*;
static DB: OnceLock<SqliteDB> = OnceLock::new();
fn db() -> SqliteDB {
    DB.get().expect("database not initialised").clone()
}
use axum::extract::Path;
use category::CategoryTable;
use product::Product;

async fn list_category_products(Path(id): Path<i64>) -> Json<Vec<Product>> {
    let id_col = Column::<i64>::new("id");
    let products = Category::table(db())
        .clone()
        .with_condition(id_col.eq(id))
        .ref_products();
    let rows = products.list().await.unwrap();
    Json(rows.into_values().collect())
}
fn main() {}

Three things going on:

  • Category::table(db()).clone() — we need an owned Table to chain with_condition onto, so we clone the cached definition — the shape, not any rows.
  • with_condition(id_col.eq(id)) — narrows the category table to one row: the one we’re asking for. Nothing hits the database yet.
  • ref_products() — traverses the with_many relationship we registered on Category in chapter 2, via get_ref_as. The returned table is Table<SqliteDB, Product>, already scoped to products whose category_id matches the narrowed category set. Chapter 2 walked through what the emitted SQL looks like; it’s the same here.

Register the route in main.rs:

mod product {
    use std::sync::OnceLock;
    use serde::{Deserialize, Serialize};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::category::Category;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Product {
        pub name: String,
        pub price: i64,
        #[serde(skip_serializing_if = "Option::is_none", default)]
        pub category_id: Option<String>,
        #[serde(default)]
        pub is_deleted: bool,
    }
    impl Product {
        pub fn table(db: SqliteDB) -> &'static Table<SqliteDB, Product> {
            static CACHE: OnceLock<Table<SqliteDB, Product>> = OnceLock::new();
            CACHE.get_or_init(|| {
                let is_deleted = Column::<bool>::new("is_deleted");
                Table::new("product", db)
                    .with_id_column("id")
                    .with_column_of::<String>("name")
                    .with_column_of::<i64>("price")
                    .with_column_of::<String>("category_id")
                    .with_column_of::<bool>("is_deleted")
                    .with_condition(is_deleted.eq(false))
                    .with_one("category", "category_id", |db| Category::table(db).clone())
            })
        }
    }
}
mod category {
    use std::sync::OnceLock;
    use serde::{Deserialize, Serialize};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    use crate::product::Product;
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Category {
        pub name: String,
    }
    impl Category {
        pub fn table(db: SqliteDB) -> &'static Table<SqliteDB, Category> {
            static CACHE: OnceLock<Table<SqliteDB, Category>> = OnceLock::new();
            CACHE.get_or_init(|| {
                Table::new("category", db)
                    .with_id_column("id")
                    .with_column_of::<String>("name")
                    .with_many("products", "category_id", |db| Product::table(db).clone())
            })
        }
    }
    pub trait CategoryTable {
        fn ref_products(&self) -> Table<SqliteDB, Product>;
    }
    impl CategoryTable for Table<SqliteDB, Category> {
        fn ref_products(&self) -> Table<SqliteDB, Product> {
            self.get_ref_as("products").unwrap()
        }
    }
}
use std::sync::OnceLock;
use axum::extract::Path;
use axum::{Json, Router, routing::get};
use category::{Category, CategoryTable};
use product::Product;
use vantage_sql::prelude::*;
static DB: OnceLock<SqliteDB> = OnceLock::new();
fn db() -> SqliteDB {
    DB.get().expect("database not initialised").clone()
}
async fn list_categories() -> Json<Vec<Category>> {
    let rows = Category::table(db()).list().await.unwrap();
    Json(rows.into_values().collect())
}
async fn list_category_products(Path(id): Path<i64>) -> Json<Vec<Product>> {
    let id_col = Column::<i64>::new("id");
    let products = Category::table(db())
        .clone()
        .with_condition(id_col.eq(id))
        .ref_products();
    let rows = products.list().await.unwrap();
    Json(rows.into_values().collect())
}
fn main() {
let app = Router::new()
    .route("/categories", get(list_categories))
    .route("/categories/{id}/products", get(list_category_products));
let _: Router = app;
}

Hit it:

curl http://localhost:3001/categories/1/products
[
  { "name": "Cupcake", "price": 120, "category_id": "1", "is_deleted": false },
  { "name": "Doughnut", "price": 135, "category_id": "1", "is_deleted": false },
  { "name": "Cookies", "price": 199, "category_id": "1", "is_deleted": false }
]
curl http://localhost:3001/categories/2/products
[
  { "name": "Tart", "price": 220, "category_id": "2", "is_deleted": false },
  { "name": "Pie", "price": 299, "category_id": "2", "is_deleted": false }
]

Swap the id in the URL and the products narrow accordingly. The relationship was declared once, back in chapter 2, and we haven’t touched it since — every new nested route reuses the same declaration.

Notice "category_id": "1" in the responses — a string, even though the SQLite column is INTEGER. This is chapter 2’s id convention at work: ids travel as strings through Vantage, so the entity declares category_id: Option<String> and the value converts on its way out of the database.


A generic crud helper

Two handlers so far — list_categories and list_category_products — and they’re already doing the same thing: take a narrowed table, call .list(), return JSON. Adding POST /categories, PATCH /categories/{id}, and DELETE /categories/{id} would mean four more near-identical handlers per entity. That’s not how you scale a codebase.

The five HTTP methods of CRUD all map onto one of two operation shapes:

  • List-level at /collection: GET (list all) and POST (create one).
  • Item-level at /collection/{id}: GET (read one), PATCH (update), DELETE (remove).

If we can describe the set of rows this endpoint operates on with one closure, the same handler bodies serve every entity. That closure is Fn(SqliteDB, &Params) -> Table<SqliteDB, E> — given the database and whatever path params axum extracted, return the Table to act on. Put it in main.rs:

use std::sync::OnceLock;
use axum::extract::Path;
use axum::http::StatusCode;
use axum::{Json, Router, routing::get};
use serde::{Serialize, de::DeserializeOwned};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
static DB: OnceLock<SqliteDB> = OnceLock::new();
fn db() -> SqliteDB {
    DB.get().expect("database not initialised").clone()
}
use std::collections::HashMap;
use std::sync::Arc;

type Params = HashMap<String, String>;

fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(SqliteDB, &Params) -> Table<SqliteDB, E> + Send + Sync + 'static,
    E: Entity<AnySqliteType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnySqliteType>>::Error: std::fmt::Debug,
{
    let f = Arc::new(make_table);
    Router::new()
        .route(
            "/",
            get({
                let f = f.clone();
                move |p: Option<Path<Params>>| async move {
                    let params = p.map(|Path(p)| p).unwrap_or_default();
                    let rows = f(db(), &params).list().await.unwrap();
                    Json::<Vec<E>>(rows.into_values().collect())
                }
            })
            .post({
                let f = f.clone();
                move |p: Option<Path<Params>>, Json(entity): Json<E>| async move {
                    let params = p.map(|Path(p)| p).unwrap_or_default();
                    let id = f(db(), &params).insert_return_id(&entity).await.unwrap();
                    Json(serde_json::json!({ "id": id }))
                }
            }),
        )
        .route(
            "/{id}",
            get({
                let f = f.clone();
                move |Path(params): Path<Params>| async move {
                    let id = params["id"].clone();
                    let entity = f(db(), &params).get(&id).await.unwrap().unwrap();
                    Json(entity)
                }
            })
            .patch({
                let f = f.clone();
                move |Path(params): Path<Params>, Json(partial): Json<E>| async move {
                    let id = params["id"].clone();
                    let updated = f(db(), &params).patch(&id, &partial).await.unwrap();
                    Json(updated)
                }
            })
            .delete({
                let f = f;
                move |Path(params): Path<Params>| async move {
                    let id = params["id"].clone();
                    f(db(), &params).delete(&id).await.unwrap();
                    StatusCode::NO_CONTENT
                }
            }),
        )
}
fn main() {}

Five handler bodies, each tiny, all generic over the entity type. The last bound, TryIntoRecord<AnySqliteType>::Error: Debug, is what the table’s write path asks of an entity: a record that fails to serialize has to be reportable. Mount crud(...) under a prefix with .nest(...) and every route below it gets the full CRUD verb set for free.

/categories becomes a one-liner:

use std::collections::HashMap;
use std::sync::OnceLock;
use axum::Router;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
type Params = HashMap<String, String>;
fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(SqliteDB, &Params) -> Table<SqliteDB, E> + Send + Sync + 'static,
    E: Entity<AnySqliteType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnySqliteType>>::Error: std::fmt::Debug,
{
    let _ = make_table;
    Router::new()
}
#[entity(SqliteType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Category {
    pub name: String,
}
impl Category {
    pub fn table(db: SqliteDB) -> &'static Table<SqliteDB, Category> {
        static CACHE: OnceLock<Table<SqliteDB, Category>> = OnceLock::new();
        CACHE.get_or_init(|| {
            Table::new("category", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
        })
    }
}
fn main() {
let _app: Router = Router::new()
.nest("/categories", crud(|db, _| Category::table(db).clone()))
;
}

That gives us:

MethodPathDoes
GET/categorieslist all
POST/categoriesinsert, return new id
GET/categories/{id}fetch one
PATCH/categories/{id}partial update
DELETE/categories/{id}remove

The nested /categories/{cat_id}/products route uses the same helper. The closure reads cat_id out of the params map to narrow the set:

use std::collections::HashMap;
use std::sync::OnceLock;
use axum::Router;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
type Params = HashMap<String, String>;
fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(SqliteDB, &Params) -> Table<SqliteDB, E> + Send + Sync + 'static,
    E: Entity<AnySqliteType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnySqliteType>>::Error: std::fmt::Debug,
{
    let _ = make_table;
    Router::new()
}
#[entity(SqliteType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Product {
    pub name: String,
}
#[entity(SqliteType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Category {
    pub name: String,
}
impl Category {
    pub fn table(db: SqliteDB) -> &'static Table<SqliteDB, Category> {
        static CACHE: OnceLock<Table<SqliteDB, Category>> = OnceLock::new();
        CACHE.get_or_init(|| {
            Table::new("category", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
        })
    }
}
pub trait CategoryTable {
    fn id(&self) -> Column<i64>;
    fn ref_products(&self) -> Table<SqliteDB, Product>;
}
impl CategoryTable for Table<SqliteDB, Category> {
    fn id(&self) -> Column<i64> {
        self.get_column("id").unwrap()
    }
    fn ref_products(&self) -> Table<SqliteDB, Product> {
        self.get_ref_as("products").unwrap()
    }
}
fn main() {
let _app: Router = Router::new()
.nest(
    "/categories/{cat_id}/products",
    crud(|db, p| {
        let cat_id: i64 = p["cat_id"].parse().unwrap();
        let mut c = Category::table(db).clone();
        c.add_condition(c.id().eq(cat_id));
        c.ref_products()
    }),
)
;
}

c.id() comes from extending the CategoryTable trait we already set up — add it next to ref_products:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Category {
    pub name: String,
}
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
}
pub trait CategoryTable {
    fn id(&self) -> Column<i64>;
    fn ref_products(&self) -> Table<SqliteDB, Product>;
}

impl CategoryTable for Table<SqliteDB, Category> {
    fn id(&self) -> Column<i64> {
        self.get_column("id").unwrap()
    }
    fn ref_products(&self) -> Table<SqliteDB, Product> {
        self.get_ref_as("products").unwrap()
    }
}
fn main() {}

Nesting crud under /categories/{cat_id}/products gives a full CRUD surface for products belonging to that category:

MethodPathDoes
GET/categories/{cat_id}/productslist
POST/categories/{cat_id}/productscreate
GET/categories/{cat_id}/products/{id}fetch one
PATCH/categories/{cat_id}/products/{id}partial update
DELETE/categories/{cat_id}/products/{id}remove

Try it:

curl -X POST http://localhost:3001/categories \
  -H 'content-type: application/json' -d '{"name":"Gluten-Free"}'
# {"id":"4"}

curl http://localhost:3001/categories/1/products/1
# {"name":"Cupcake","price":120,"category_id":"1","is_deleted":false}

curl -X PATCH http://localhost:3001/categories/1 \
  -H 'content-type: application/json' -d '{"name":"Sweet Things"}'
# {"name":"Sweet Things"}

curl -X DELETE http://localhost:3001/categories/4
# HTTP/1.1 204 No Content

The inline list_categories and list_category_products functions can be deleted — crud covers them both.

Why a HashMap for path params?

Axum only lets a handler run the Path extractor once per request — after that, the URL params are considered consumed. We need the outer {cat_id} inside the closure and the inner {id} to identify which record to fetch, which rules out calling Path<i64> plus a second Path<String>. Grabbing all params in one shot as HashMap<String, String> sidesteps the limit — the closure picks out what it needs by name, and the item-level handlers pull id from the same map.

The cost is a small amount of stringly-typed parsing (p["cat_id"].parse::<i64>()), which is a fair trade for letting one crud function cover every route shape in the server.

What’s inside crud(), briefly

Each HTTP method gets its own closure in the Router. They all need to share the same make_table function, but Rust closures that capture by move can’t be cloned by default, and each axum handler is an independent Fn — so we wrap make_table in an Arc once and let each handler clone the Arc (cheap — just a refcount bump). Inside the async block, the closure can then invoke f(db(), &params) to build the narrowed table for that request.


Error handling

Every handler in crud still ends in .unwrap(). The happy path has been fine to demo, but an API that panics on the slightest database hiccup isn’t usable. The worst part isn’t the 500 — it’s what happens on the wire when axum’s request task panics: the connection is dropped and the client sees an empty reply, with no status code and no body to explain.

A proper REST API needs three things:

  • Missing resources return 404, not 500.
  • Bad JSON bodies return 400 with a useful message.
  • Everything else returns 500 but with a structured JSON body, not silence.

Axum already gives us the middle one for free — it rejects malformed Json<E> bodies with 400. The other two come down to converting VantageError into an HTTP response. Add an ApiError type to main.rs:

use axum::Json;
use axum::http::StatusCode;
use vantage_sql::prelude::*;
use axum::response::{IntoResponse, Response};

struct ApiError {
    status: StatusCode,
    message: String,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (
            self.status,
            Json(serde_json::json!({ "error": self.message })),
        )
            .into_response()
    }
}

impl From<VantageError> for ApiError {
    fn from(e: VantageError) -> Self {
        eprintln!("API error: {:?}", e);
        Self {
            status: StatusCode::INTERNAL_SERVER_ERROR,
            message: e.to_string(),
        }
    }
}

fn not_found(id: &str) -> ApiError {
    ApiError {
        status: StatusCode::NOT_FOUND,
        message: format!("not found: {}", id),
    }
}

type ApiResult<T> = Result<T, ApiError>;
fn main() {}

Three pieces matter here:

  • IntoResponse makes ApiError returnable from a handler; axum calls into_response() to assemble status, headers, and body.
  • From<VantageError> lets us use ? inside a handler — every .await? short-circuits to an ApiError mapped to a 500, which axum will render for us.
  • not_found(id) is how we build a 404 explicitly. Vantage’s get returns Option<E>, so we .ok_or_else(|| not_found(&id))? the missing case straight into a 404 — no error-message string-matching, no brittleness.

With that in place, the unwraps inside crud turn into ?, and each handler returns ApiResult<T>:

use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Json, Router, routing::get};
use serde::{Serialize, de::DeserializeOwned};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
static DB: OnceLock<SqliteDB> = OnceLock::new();
fn db() -> SqliteDB {
    DB.get().expect("database not initialised").clone()
}
type Params = HashMap<String, String>;
struct ApiError {
    status: StatusCode,
    message: String,
}
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
    }
}
impl From<VantageError> for ApiError {
    fn from(e: VantageError) -> Self {
        Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: e.to_string() }
    }
}
type ApiResult<T> = Result<T, ApiError>;
fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(SqliteDB, &Params) -> Table<SqliteDB, E> + Send + Sync + 'static,
    E: Entity<AnySqliteType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnySqliteType>>::Error: std::fmt::Debug,
{
    let f = Arc::new(make_table);
    Router::new().route(
        "/",
get({
    let f = f.clone();
    move |p: Option<Path<Params>>| async move {
        let params = p.map(|Path(p)| p).unwrap_or_default();
        let rows = f(db(), &params).list().await?;
        ApiResult::Ok(Json::<Vec<E>>(rows.into_values().collect()))
    }
})
    )
}
fn main() {}

For the GET /{id} handler, convert the Option into a 404 at the handler:

use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Json, Router, routing::get};
use serde::{Serialize, de::DeserializeOwned};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
static DB: OnceLock<SqliteDB> = OnceLock::new();
fn db() -> SqliteDB {
    DB.get().expect("database not initialised").clone()
}
type Params = HashMap<String, String>;
struct ApiError {
    status: StatusCode,
    message: String,
}
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
    }
}
impl From<VantageError> for ApiError {
    fn from(e: VantageError) -> Self {
        Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: e.to_string() }
    }
}
fn not_found(id: &str) -> ApiError {
    ApiError { status: StatusCode::NOT_FOUND, message: format!("not found: {}", id) }
}
type ApiResult<T> = Result<T, ApiError>;
fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(SqliteDB, &Params) -> Table<SqliteDB, E> + Send + Sync + 'static,
    E: Entity<AnySqliteType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnySqliteType>>::Error: std::fmt::Debug,
{
    let f = Arc::new(make_table);
    Router::new().route(
        "/{id}",
get({
    let f = f.clone();
    move |Path(params): Path<Params>| async move {
        let id = params["id"].clone();
        let entity = f(db(), &params)
            .get(id.clone())
            .await?
            .ok_or_else(|| not_found(&id))?;
        ApiResult::Ok(Json(entity))
    }
})
    )
}
fn main() {}

Do the same shape for post, patch, delete — drop the .unwrap(), add ?, wrap the happy path in ApiResult::Ok(...). Handlers that don’t read by id just need the ?.

Try a few error cases:

curl -w "\nstatus=%{http_code}\n" http://localhost:3001/categories/999
# {"error":"not found: 999"}
# status=404

curl -w "\nstatus=%{http_code}\n" -X PATCH http://localhost:3001/categories/999 \
  -H 'content-type: application/json' -d '{"name":"Ghost"}'
# {"error":"Row not found (table: \"category\", id: \"999\")"}
# status=500

curl -w "\nstatus=%{http_code}\n" -X POST http://localhost:3001/categories \
  -H 'content-type: application/json' -d '{not-json}'
# Failed to parse the request body as JSON: ...
# status=400

curl -w "\nstatus=%{http_code}\n" -X DELETE http://localhost:3001/categories/999
# status=204

Missing ids produce 404s with a clean JSON body. The malformed body gets axum’s built-in 400 for free. DELETE on a missing id still returns 204 — vantage’s delete is idempotent, and “the resource is gone” is true whether or not it was ever there. PATCH on a missing id fails with an error whose is_not_found() is true; this handler maps every non-get error to 500, so add Err(e) if e.is_not_found() => not_found(&id) to answer 404 there too.

Why not match on the error message?

An earlier draft of this tutorial matched e.to_string().contains("no row found") to decide between 404 and 500 — brittle, because it hard-codes a vantage-internal error string. Once ReadableDataSet::get switched to Result<Option<E>>, the handler can map missing rows explicitly with .ok_or_else(|| not_found(&id))?. Errors are errors, misses are None — no string-matching required.

Logging with {:?} on the server side

eprintln!("API error: {:?}", e); prints the full error structure — location, context, nested sources — which is exactly what a human debugging a 500 needs. The client meanwhile only sees e.to_string(), the short one-liner message. That’s the whole point of .context("…") from chapter 1: context accumulates on the server, a single sentence reaches the client.

Swap eprintln! for tracing::error! if you’re wired up for structured logging. The mechanics are identical.


Three categories is fine for dev; three thousand would crush any client that naively calls GET /categories and tries to render everything. Real APIs page through long lists and let callers filter by a search term — two query-string features that belong inside crud so every entity gets them.

Axum parses query strings for us via the Query<T> extractor. Add a small struct for the parameters, and a second extractor on the list handler:

use serde::Deserialize;
use axum::extract::Query;
use vantage_table::pagination::Pagination;

#[derive(Deserialize, Default)]
struct ListQuery {
    page: Option<i64>,
    per_page: Option<i64>,
    q: Option<String>,
}
fn main() {}

Inside crud, only the GET / handler changes. It takes both extractors, mutates the narrowed table with whatever the caller asked for, and lists:

use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Json, Router, routing::get};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use vantage_sql::prelude::*;
use vantage_table::pagination::Pagination;
use vantage_types::prelude::*;
static DB: OnceLock<SqliteDB> = OnceLock::new();
fn db() -> SqliteDB {
    DB.get().expect("database not initialised").clone()
}
type Params = HashMap<String, String>;
struct ApiError {
    status: StatusCode,
    message: String,
}
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
    }
}
impl From<VantageError> for ApiError {
    fn from(e: VantageError) -> Self {
        Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: e.to_string() }
    }
}
type ApiResult<T> = Result<T, ApiError>;
#[derive(Deserialize, Default)]
struct ListQuery {
    page: Option<i64>,
    per_page: Option<i64>,
    q: Option<String>,
}
fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(SqliteDB, &Params) -> Table<SqliteDB, E> + Send + Sync + 'static,
    E: Entity<AnySqliteType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnySqliteType>>::Error: std::fmt::Debug,
{
    let f = Arc::new(make_table);
    Router::new().route(
        "/",
get({
    let f = f.clone();
    move |p: Option<Path<Params>>, Query(q): Query<ListQuery>| async move {
        let params = p.map(|Path(p)| p).unwrap_or_default();
        let mut t = f(db(), &params);
        if q.page.is_some() || q.per_page.is_some() {
            t.set_pagination(Some(Pagination::new(
                q.page.unwrap_or(1),
                q.per_page.unwrap_or(50),
            )));
        }
        if let Some(term) = q.q.as_deref() {
            t = t.with_search(term);
        }
        let rows = t.list().await?;
        ApiResult::Ok(Json::<Vec<E>>(rows.into_values().collect()))
    }
})
    )
}
fn main() {}

Two things happen:

  • set_pagination(Some(Pagination::new(page, per_page))) takes a page number and a page size. Vantage applies these as LIMIT … OFFSET … on the SELECT. Missing params fall back to the defaults (page 1, 50 per page) — and if neither is supplied we don’t touch pagination at all, so the unfiltered list still hits the whole set.
  • with_search(term) is the same call we used in chapter 2 to add a LIKE filter across the table’s search columns (every column, unless some are flagged searchable). It consumes the table and hands back the narrowed one, hence t = t.with_search(term). Both end up as extra clauses on the query that vantage already compiles for us.

Try it:

curl "http://localhost:3001/categories?page=1&per_page=2"
# [{"name":"Sweet Treats"},{"name":"Pastries"}]

curl "http://localhost:3001/categories?page=2&per_page=2"
# [{"name":"Breads"}]

curl "http://localhost:3001/categories?q=Pastries"
# [{"name":"Pastries"}]

curl "http://localhost:3001/categories?q=e"
# [{"name":"Sweet Treats"},{"name":"Pastries"},{"name":"Breads"}]

The nested route gets these for free — crud is the same function. per_page works on /categories/{cat_id}/products out of the box:

curl "http://localhost:3001/categories/1/products?per_page=2"
# [
#   {"name":"Cupcake","price":120,"category_id":"1","is_deleted":false},
#   {"name":"Doughnut","price":135,"category_id":"1","is_deleted":false}
# ]

Because the closure for the nested mount narrows the table with with_condition before crud applies its own pagination/search, the filters compose cleanly: the category scope stays in effect, and pagination just counts rows within it.

What about ordering?

A full ?order_by=price&dir=desc pairing is the obvious next thing — and the Table API supports it via with_order(OrderBy::ascending(…)) — but the OrderBy type is generic over the backend’s condition type (T::Condition), not the easier-to-hand-you Expression<T::Value> that get_column_expr returns. Wiring it up at the generic crud level takes a small extra layer of From conversions that would balloon this section.

For a single-entity handler, order the cloned definition directly: Category::table(db()).clone().with_order(OrderBy::ascending(ident("name").into())), with OrderBy from vantage_table::sorting. Adding ordering to crud is a fine exercise once the rest of the server is in place — and a natural thing to push back into the framework so every backend picks it up uniformly.

Validating pagination params

Our ListQuery silently accepts page=0, per_page=-5, or per_page=1000000. Pagination::new clamps page and items-per-page to at least 1, so the first two can’t crash us — but an API that hands out 1M rows because someone asked for it is a DoS target. For production, extend ListQuery with a fn validate(&self) -> Result<(), ApiError> that caps per_page at something like 200 and returns 400 otherwise. The plumbing is already there — ApiError already knows how to render a 400.


Migrating to MongoDB

Chapter 2 closed on a claim: the model layer isolates business code from storage, so swapping databases is a change to the model, not to the routes, handlers, or business logic. Now we cash the check. /categories, /categories/{id}, and /categories/{cat_id}/products keep their exact URL shape, response bodies, and behaviour — but the data lives in MongoDB instead of SqliteDB.

Start Mongo — Docker is the easiest way:

docker run -d --name mongo-learn -p 27017:27017 mongo:7

Cargo.toml

Drop vantage-sql, add vantage-mongodb:

[dependencies]
axum = "0.8.9"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
vantage-core = "1.1"
vantage-dataset = "1.1"
vantage-expressions = "1.2"
vantage-mongodb = "2.0"
vantage-table = "2.0"
vantage-types = { version = "1.0", features = ["serde"] }

Entities

Swap the #[entity] type tag from SqliteType to MongoType and the id column name from id to the MongoDB-idiomatic _id. Imports collapse to a single prelude use-line.

src/category.rs:

use std::sync::OnceLock;

use vantage_mongodb::prelude::*;

use crate::product::Product;

#[entity(MongoType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Category {
    pub name: String,
}

impl Category {
    pub fn table(db: MongoDB) -> &'static Table<MongoDB, Category> {
        static CACHE: OnceLock<Table<MongoDB, Category>> = OnceLock::new();
        CACHE.get_or_init(|| {
            Table::new("category", db)
                .with_id_column("_id")
                .with_column_of::<String>("name")
                .with_many("products", "category_id", |db| Product::table(db).clone())
        })
    }
}
mod product {
    use std::sync::OnceLock;
    use vantage_mongodb::prelude::*;
    use crate::Category;
    #[entity(MongoType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Product {
        pub name: String,
        pub price: i64,
        #[serde(skip_serializing_if = "Option::is_none", default)]
        pub category_id: Option<String>,
        #[serde(default)]
        pub is_deleted: bool,
    }
    impl Product {
        pub fn table(db: MongoDB) -> &'static Table<MongoDB, Product> {
            static CACHE: OnceLock<Table<MongoDB, Product>> = OnceLock::new();
            CACHE.get_or_init(|| {
                let is_deleted = Column::<bool>::new("is_deleted");
                Table::new("product", db)
                    .with_id_column("_id")
                    .with_column_of::<String>("name")
                    .with_column_of::<i64>("price")
                    .with_column_of::<String>("category_id")
                    .with_column_of::<bool>("is_deleted")
                    .with_condition(is_deleted.eq(false))
                    .with_one("category", "category_id", |db| Category::table(db).clone())
            })
        }
    }
}
fn main() {}

src/product.rs gets the symmetric changes:

use std::sync::OnceLock;

use vantage_mongodb::prelude::*;

use crate::category::Category;

#[entity(MongoType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Product {
    pub name: String,
    pub price: i64,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub category_id: Option<String>,
    #[serde(default)]
    pub is_deleted: bool,
}

impl Product {
    pub fn table(db: MongoDB) -> &'static Table<MongoDB, Product> {
        static CACHE: OnceLock<Table<MongoDB, Product>> = OnceLock::new();
        CACHE.get_or_init(|| {
            let is_deleted = Column::<bool>::new("is_deleted");
            Table::new("product", db)
                .with_id_column("_id")
                .with_column_of::<String>("name")
                .with_column_of::<i64>("price")
                .with_column_of::<String>("category_id")
                .with_column_of::<bool>("is_deleted")
                .with_condition(is_deleted.eq(false))
                .with_one("category", "category_id", |db| Category::table(db).clone())
        })
    }
}
mod category {
    use std::sync::OnceLock;
    use vantage_mongodb::prelude::*;
    use crate::Product;
    #[entity(MongoType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Category {
        pub name: String,
    }
    impl Category {
        pub fn table(db: MongoDB) -> &'static Table<MongoDB, Category> {
            static CACHE: OnceLock<Table<MongoDB, Category>> = OnceLock::new();
            CACHE.get_or_init(|| {
                Table::new("category", db)
                    .with_id_column("_id")
                    .with_column_of::<String>("name")
                    .with_many("products", "category_id", |db| Product::table(db).clone())
            })
        }
    }
}
fn main() {}

Nothing about the struct shape changed — category_id and is_deleted have been real columns since § 2. The with_* calls line up one-for-one with the SQLite version; only the id column name and the entity’s type tag shift.

The CategoryTable trait from § 3 also updates — MongoDB’s _id is a string, not an integer:

use vantage_mongodb::prelude::*;
#[entity(MongoType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Category {
    pub name: String,
}
#[entity(MongoType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Product {
    pub name: String,
}
pub trait CategoryTable {
    fn id(&self) -> Column<String>;
    fn ref_products(&self) -> Table<MongoDB, Product>;
}

impl CategoryTable for Table<MongoDB, Category> {
    fn id(&self) -> Column<String> {
        self.get_column("_id").unwrap()
    }
    fn ref_products(&self) -> Table<MongoDB, Product> {
        self.get_ref_as("products").unwrap()
    }
}
fn main() {}

The nested route’s closure gets a little simpler as a result — no parse step, just narrow by the URL’s cat_id string directly:

use std::collections::HashMap;
use std::sync::OnceLock;
use axum::Router;
use vantage_mongodb::prelude::*;
type Params = HashMap<String, String>;
fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(MongoDB, &Params) -> Table<MongoDB, E> + Send + Sync + 'static,
    E: Entity<AnyMongoType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnyMongoType>>::Error: std::fmt::Debug,
{
    let _ = make_table;
    Router::new()
}
#[entity(MongoType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Product {
    pub name: String,
}
#[entity(MongoType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Category {
    pub name: String,
}
impl Category {
    pub fn table(db: MongoDB) -> &'static Table<MongoDB, Category> {
        static CACHE: OnceLock<Table<MongoDB, Category>> = OnceLock::new();
        CACHE.get_or_init(|| {
            Table::new("category", db)
                .with_id_column("_id")
                .with_column_of::<String>("name")
        })
    }
}
pub trait CategoryTable {
    fn id(&self) -> Column<String>;
    fn ref_products(&self) -> Table<MongoDB, Product>;
}
impl CategoryTable for Table<MongoDB, Category> {
    fn id(&self) -> Column<String> {
        self.get_column("_id").unwrap()
    }
    fn ref_products(&self) -> Table<MongoDB, Product> {
        self.get_ref_as("products").unwrap()
    }
}
fn main() {
let _router: Router =
crud(|db, p| {
    let mut c = Category::table(db).clone();
    c.add_condition(c.id().eq(p["cat_id"].as_str()));
    c.ref_products()
})
;
}

.eq(&str) works here because vantage-mongodb’s From<&str> for AnyMongoType auto-promotes 24-character hex strings to ObjectId and leaves everything else as String. The comparison fires with the right BSON type whichever _id convention the collection uses.

main.rs

The crud function’s generic bounds shift from Entity<AnySqliteType> to Entity<AnyMongoType> and its Fn(SqliteDB, ...) becomes Fn(MongoDB, ...). Body unchanged.

use std::collections::HashMap;
use axum::Router;
use vantage_mongodb::prelude::*;
type Params = HashMap<String, String>;
fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(MongoDB, &Params) -> Table<MongoDB, E> + Send + Sync + 'static,
    E: Entity<AnyMongoType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnyMongoType>>::Error: std::fmt::Debug,
{
    let _ = make_table;
    Router::new()
}
fn main() {}

Connection and id handling are the only handler-level changes:

use std::collections::HashMap;
use vantage_mongodb::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let params = HashMap::from([("id".to_string(), "69e2b9101a552c206f5f8468".to_string())]);
// db() returns MongoDB; connect with URL + database name.
let conn = MongoDB::connect("mongodb://localhost:27017", "learn3")
    .await
    .context("Failed to connect to MongoDB")?;

// item-level ids are MongoId. String → MongoId dispatches to ObjectId when the
// string is a 24-char hex, otherwise stays a plain String, via a `From<String>`
// smart-parse in vantage-mongodb.
let id: MongoId = params["id"].clone().into();
Ok(())
}

The 404 path needs no change at all. Mongo’s get returns Result<Option<E>> just like SQLite’s, so the same .ok_or_else(|| not_found(&id))? maps a missing document to 404 — the promise of the Why not match on the error message? box holds across backends.

Running it

cargo run

The collection is empty, so the first request returns an empty array:

curl http://localhost:3001/categories
# []

POST a few categories — responses carry the auto-generated MongoDB ObjectId as the new id:

SWEETS=$(curl -s -X POST http://localhost:3001/categories \
  -H 'content-type: application/json' -d '{"name":"Sweet Treats"}' \
  | jq -r .id)
echo "$SWEETS"
# 69e2b9101a552c206f5f8468

Create a product in that category by POSTing to the nested route, including the parent id as category_id in the body:

curl -X POST "http://localhost:3001/categories/$SWEETS/products" \
  -H 'content-type: application/json' \
  -d "{\"name\":\"Cupcake\",\"price\":120,\"category_id\":\"$SWEETS\"}"
# {"id":"69e2b9101a552c206f5f846a"}

curl "http://localhost:3001/categories/$SWEETS/products"
# [{"name":"Cupcake","price":120,"category_id":"69e2b9101a552c206f5f8468","is_deleted":false}]

Same URL shape as the SQLite version. Same JSON. Same error codes. Handlers, routing, pagination, filtering — nothing in the request path learned that storage moved from a local file to a document database.

Cross-type $in in relationship traversal

CategoryTable::ref_products() still works. MongoDB’s _id defaults to ObjectId, but application fields like product.category_id arrive as plain JSON strings and get stored as BSON String. A naive $in: [ObjectId(...)] wouldn’t match. vantage-mongodb sidesteps that by pushing both representations into the $in inside related_in_condition — an ObjectId value also emits its 24-char hex string, and a hex-shaped String value also emits the parsed ObjectId. Traversal works regardless of which form the target stores.

String _ids are also an option

Nothing in MongoDB requires _id to be an ObjectId — it can be any BSON value, including a plain string. If the app supplies _id explicitly on insert (or the framework generates a UUID and writes it into _id), both category._id and product.category_id are strings and everything lines up without the $in dual-push. Useful when you want stable, human-legible ids or ids that came from an upstream system.


Scaling up: CRUD as a one-liner

The crud function, ApiError, Params, and ListQuery aren’t really tied to this app — they’re generic over any Table<MongoDB, E>. Move them into their own module and main.rs collapses to exactly what it’s about: connecting the database and registering routes. Three files do the work:

src/db.rs (17 lines) — the static DB: OnceLock<MongoDB>, an init(url, db) helper that connects and stores the handle, and a pub fn db() -> MongoDB accessor that hands out cheap MongoDB clones:

mod db {
use std::sync::OnceLock;

use vantage_mongodb::prelude::*;

static DB: OnceLock<MongoDB> = OnceLock::new();

pub async fn init(url: &str, database: &str) -> VantageResult<()> {
    let conn = MongoDB::connect(url, database)
        .await
        .context("Failed to connect to MongoDB")?;
    DB.set(conn).ok();
    Ok(())
}

pub fn db() -> MongoDB {
    DB.get().expect("database not initialised").clone()
}
}
fn main() {}

src/vantage_axum.rs (one file, ~130 lines) — everything HTTP: ApiError, ListQuery, and the crud<E, F> helper. Accepts any entity E that implements Entity<AnyMongoType> plus the usual serde bounds. It’s the pieces from the sections above, collected:

mod db {
    use std::sync::OnceLock;
    use vantage_mongodb::prelude::*;
    static DB: OnceLock<MongoDB> = OnceLock::new();
    pub async fn init(url: &str, database: &str) -> VantageResult<()> {
        let conn = MongoDB::connect(url, database)
            .await
            .context("Failed to connect to MongoDB")?;
        DB.set(conn).ok();
        Ok(())
    }
    pub fn db() -> MongoDB {
        DB.get().expect("database not initialised").clone()
    }
}
mod vantage_axum {
use std::collections::HashMap;
use std::sync::Arc;

use axum::{
    Json, Router,
    extract::{Path, Query},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
};
use vantage_mongodb::prelude::*;

use crate::db::db;

pub struct ApiError {
    pub status: StatusCode,
    pub message: String,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (
            self.status,
            Json(serde_json::json!({ "error": self.message })),
        )
            .into_response()
    }
}

impl From<VantageError> for ApiError {
    fn from(e: VantageError) -> Self {
        eprintln!("API error: {:?}", e);
        Self {
            status: StatusCode::INTERNAL_SERVER_ERROR,
            message: e.to_string(),
        }
    }
}

fn not_found(id: &str) -> ApiError {
    ApiError {
        status: StatusCode::NOT_FOUND,
        message: format!("not found: {}", id),
    }
}

pub type Params = HashMap<String, String>;
pub type ApiResult<T> = Result<T, ApiError>;

#[derive(Deserialize, Default)]
pub struct ListQuery {
    pub page: Option<i64>,
    pub per_page: Option<i64>,
    pub q: Option<String>,
}

pub fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(MongoDB, &Params) -> Table<MongoDB, E> + Send + Sync + 'static,
    E: Entity<AnyMongoType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnyMongoType>>::Error: std::fmt::Debug,
{
    let f = Arc::new(make_table);
    Router::new()
        .route(
            "/",
            get({
                let f = f.clone();
                move |p: Option<Path<Params>>, Query(q): Query<ListQuery>| async move {
                    let params = p.map(|Path(p)| p).unwrap_or_default();
                    let mut t = f(db(), &params);
                    if q.page.is_some() || q.per_page.is_some() {
                        t.set_pagination(Some(Pagination::new(
                            q.page.unwrap_or(1),
                            q.per_page.unwrap_or(50),
                        )));
                    }
                    if let Some(term) = q.q.as_deref() {
                        t = t.with_search(term);
                    }
                    let rows = t.list().await?;
                    ApiResult::Ok(Json::<Vec<E>>(rows.into_values().collect()))
                }
            })
            .post({
                let f = f.clone();
                move |p: Option<Path<Params>>, Json(entity): Json<E>| async move {
                    let params = p.map(|Path(p)| p).unwrap_or_default();
                    let id = f(db(), &params).insert_return_id(&entity).await?;
                    ApiResult::Ok(Json(serde_json::json!({ "id": id })))
                }
            }),
        )
        .route(
            "/{id}",
            get({
                let f = f.clone();
                move |Path(params): Path<Params>| async move {
                    let id = params["id"].clone();
                    let entity = f(db(), &params)
                        .get(id.clone())
                        .await?
                        .ok_or_else(|| not_found(&id))?;
                    ApiResult::Ok(Json(entity))
                }
            })
            .patch({
                let f = f.clone();
                move |Path(params): Path<Params>, Json(partial): Json<E>| async move {
                    let id: MongoId = params["id"].clone().into();
                    let updated = f(db(), &params).patch(id.clone(), &partial).await?;
                    ApiResult::Ok(Json(updated))
                }
            })
            .delete({
                let f = f;
                move |Path(params): Path<Params>| async move {
                    let id: MongoId = params["id"].clone().into();
                    f(db(), &params).delete(id.clone()).await?;
                    ApiResult::Ok(StatusCode::NO_CONTENT)
                }
            }),
        )
}
}
fn main() {}

src/main.rs — now down to ~30 lines. It declares the four modules (mod category;, mod db;, mod product;, mod vantage_axum;), then:

mod db {
    use std::sync::OnceLock;
    use vantage_mongodb::prelude::*;
    static DB: OnceLock<MongoDB> = OnceLock::new();
    pub async fn init(url: &str, database: &str) -> VantageResult<()> {
        let conn = MongoDB::connect(url, database)
            .await
            .context("Failed to connect to MongoDB")?;
        DB.set(conn).ok();
        Ok(())
    }
    pub fn db() -> MongoDB {
        DB.get().expect("database not initialised").clone()
    }
}
mod product {
    use std::sync::OnceLock;
    use vantage_mongodb::prelude::*;
    use crate::category::Category;
    #[entity(MongoType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Product {
        pub name: String,
        pub price: i64,
        #[serde(skip_serializing_if = "Option::is_none", default)]
        pub category_id: Option<String>,
        #[serde(default)]
        pub is_deleted: bool,
    }
    impl Product {
        pub fn table(db: MongoDB) -> &'static Table<MongoDB, Product> {
            static CACHE: OnceLock<Table<MongoDB, Product>> = OnceLock::new();
            CACHE.get_or_init(|| {
                let is_deleted = Column::<bool>::new("is_deleted");
                Table::new("product", db)
                    .with_id_column("_id")
                    .with_column_of::<String>("name")
                    .with_column_of::<i64>("price")
                    .with_column_of::<String>("category_id")
                    .with_column_of::<bool>("is_deleted")
                    .with_condition(is_deleted.eq(false))
                    .with_one("category", "category_id", |db| Category::table(db).clone())
            })
        }
    }
}
mod category {
    use std::sync::OnceLock;
    use vantage_mongodb::prelude::*;
    use crate::product::Product;
    #[entity(MongoType)]
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct Category {
        pub name: String,
    }
    impl Category {
        pub fn table(db: MongoDB) -> &'static Table<MongoDB, Category> {
            static CACHE: OnceLock<Table<MongoDB, Category>> = OnceLock::new();
            CACHE.get_or_init(|| {
                Table::new("category", db)
                    .with_id_column("_id")
                    .with_column_of::<String>("name")
                    .with_many("products", "category_id", |db| Product::table(db).clone())
            })
        }
    }
    pub trait CategoryTable {
        fn id(&self) -> Column<String>;
        fn ref_products(&self) -> Table<MongoDB, Product>;
    }
    impl CategoryTable for Table<MongoDB, Category> {
        fn id(&self) -> Column<String> {
            self.get_column("_id").unwrap()
        }
        fn ref_products(&self) -> Table<MongoDB, Product> {
            self.get_ref_as("products").unwrap()
        }
    }
}
mod vantage_axum {
    use std::collections::HashMap;
    use std::sync::Arc;
    use axum::{
        Json, Router,
        extract::{Path, Query},
        http::StatusCode,
        response::{IntoResponse, Response},
        routing::get,
    };
    use vantage_mongodb::prelude::*;
    use crate::db::db;
    pub struct ApiError {
        pub status: StatusCode,
        pub message: String,
    }
    impl IntoResponse for ApiError {
        fn into_response(self) -> Response {
            (self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
        }
    }
    impl From<VantageError> for ApiError {
        fn from(e: VantageError) -> Self {
            eprintln!("API error: {:?}", e);
            Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: e.to_string() }
        }
    }
    fn not_found(id: &str) -> ApiError {
        ApiError { status: StatusCode::NOT_FOUND, message: format!("not found: {}", id) }
    }
    pub type Params = HashMap<String, String>;
    pub type ApiResult<T> = Result<T, ApiError>;
    #[derive(Deserialize, Default)]
    pub struct ListQuery {
        pub page: Option<i64>,
        pub per_page: Option<i64>,
        pub q: Option<String>,
    }
    pub fn crud<E, F>(make_table: F) -> Router
    where
        F: Fn(MongoDB, &Params) -> Table<MongoDB, E> + Send + Sync + 'static,
        E: Entity<AnyMongoType> + Serialize + DeserializeOwned + Send + Sync + 'static,
        <E as vantage_types::TryIntoRecord<AnyMongoType>>::Error: std::fmt::Debug,
    {
        let f = Arc::new(make_table);
        Router::new()
            .route(
                "/",
                get({
                    let f = f.clone();
                    move |p: Option<Path<Params>>, Query(q): Query<ListQuery>| async move {
                        let params = p.map(|Path(p)| p).unwrap_or_default();
                        let mut t = f(db(), &params);
                        if q.page.is_some() || q.per_page.is_some() {
                            t.set_pagination(Some(Pagination::new(
                                q.page.unwrap_or(1),
                                q.per_page.unwrap_or(50),
                            )));
                        }
                        if let Some(term) = q.q.as_deref() {
                            t = t.with_search(term);
                        }
                        let rows = t.list().await?;
                        ApiResult::Ok(Json::<Vec<E>>(rows.into_values().collect()))
                    }
                })
                .post({
                    let f = f.clone();
                    move |p: Option<Path<Params>>, Json(entity): Json<E>| async move {
                        let params = p.map(|Path(p)| p).unwrap_or_default();
                        let id = f(db(), &params).insert_return_id(&entity).await?;
                        ApiResult::Ok(Json(serde_json::json!({ "id": id })))
                    }
                }),
            )
            .route(
                "/{id}",
                get({
                    let f = f.clone();
                    move |Path(params): Path<Params>| async move {
                        let id = params["id"].clone();
                        let entity = f(db(), &params)
                            .get(id.clone())
                            .await?
                            .ok_or_else(|| not_found(&id))?;
                        ApiResult::Ok(Json(entity))
                    }
                })
                .patch({
                    let f = f.clone();
                    move |Path(params): Path<Params>, Json(partial): Json<E>| async move {
                        let id: MongoId = params["id"].clone().into();
                        let updated = f(db(), &params).patch(id.clone(), &partial).await?;
                        ApiResult::Ok(Json(updated))
                    }
                })
                .delete({
                    let f = f;
                    move |Path(params): Path<Params>| async move {
                        let id: MongoId = params["id"].clone().into();
                        f(db(), &params).delete(id.clone()).await?;
                        ApiResult::Ok(StatusCode::NO_CONTENT)
                    }
                }),
            )
    }
}
use axum::Router;
use category::{Category, CategoryTable};
use vantage_axum::crud;
use vantage_mongodb::prelude::*;

#[tokio::main]
async fn main() -> VantageResult<()> {
    db::init("mongodb://localhost:27017", "learn3").await?;

    let app = Router::new()
        .nest("/categories", crud(|db, _| Category::table(db).clone()))
        .nest(
            "/categories/{cat_id}/products",
            crud(|db, p| {
                let mut c = Category::table(db).clone();
                c.add_condition(c.id().eq(p["cat_id"].as_str()));
                c.ref_products()
            }),
        );

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
    axum::serve(listener, app).await.unwrap();

    Ok(())
}

Adding a new entity to this server is now two things:

  1. Write a Table::new(...) constructor for it — declarative, one function, same shape we learned in chapter 2.
  2. Mount it.
use std::collections::HashMap;
use std::sync::OnceLock;
use axum::Router;
use vantage_mongodb::prelude::*;
type Params = HashMap<String, String>;
fn crud<E, F>(make_table: F) -> Router
where
    F: Fn(MongoDB, &Params) -> Table<MongoDB, E> + Send + Sync + 'static,
    E: Entity<AnyMongoType> + Serialize + DeserializeOwned + Send + Sync + 'static,
    <E as vantage_types::TryIntoRecord<AnyMongoType>>::Error: std::fmt::Debug,
{
    let _ = make_table;
    Router::new()
}
#[entity(MongoType)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Widget {
    pub name: String,
}
impl Widget {
    pub fn table(db: MongoDB) -> &'static Table<MongoDB, Widget> {
        static CACHE: OnceLock<Table<MongoDB, Widget>> = OnceLock::new();
        CACHE.get_or_init(|| {
            Table::new("widget", db)
                .with_id_column("_id")
                .with_column_of::<String>("name")
        })
    }
}
fn main() {
let _app: Router = Router::new()
.nest("/widgets", crud(|db, _| Widget::table(db).clone()))
;
}

The full surface: GET list, POST create, GET /{id}, PATCH /{id}, DELETE /{id}, pagination via ?page=&per_page=, full-text search via ?q=, 404s for missing ids, 400s for malformed bodies, structured JSON errors. No new handler code. No per-entity error mapping. No per-entity query-param struct. One line per entity.

Every route plays by the same rules because every route is served by the same crud — the single description of “what this route does” lives in the entity’s Table definition, and the HTTP boundary just routes to it. That is the “one description, many operations” principle from chapter 2’s Table carried all the way to the wire, unbroken.

The vantage_axum module is generic enough to lift directly into a larger codebase — it has no knowledge of Category, Product, or your particular routes. Drop it into your own binary, give it a db() accessor, write entity files, mount routes.

One thing it deliberately doesn’t do is authentication — every endpoint here is anonymous. Auth belongs to the HTTP layer: wrap the Router in your usual tower middleware; Vantage stays out of it.

Vista — the Universal Data Handle

Chapters 1–3 built a typed data layer: Table<SqliteDB, Product>, conditions, relationships, CRUD. That layer is great when you know the entity at compile time. But step 3’s generic crud() helper already showed the limitation — it could only exist because we erased the entity type with Serialize + DeserializeOwned and went through JSON.

A CLI that lists “any table from any backend”, a web admin that draws forms from a YAML schema, a UI data grid that works with whatever you point it at — none of these know your Product struct. They need a handle that carries its own schema, speaks a single value type, and works regardless of which database sits underneath.

That handle is Vista.

Why ‘Vista’?

Vantage is named for the view. Stand at a vantage point — the peak above your infrastructure — and the landscape arranges itself below: every database, API, and file your organisation keeps, observable from one place. The framework’s names stay true to that scene, borrowing from landscape and photography rather than reusing overloaded database words.

A table isn’t one of ours — it’s what you already have. Vantage describes your tables; it doesn’t reinvent them. A vista is the first thing the framework adds: in the landscape sense, a wide view opening up from where you stand. And that is precisely what a Vista is here — one data source seen in full, its schema, capabilities, and records composed into a single view, no matter what produces it. Further along the trail wait a Lens, a Diorama, and Scenery — each another way of looking, never another thing to store.

Goals for this chapter

By the end of this page you’ll be able to:

  1. Wrap a typed Table into a Vista
  2. Read schema metadata (columns, references, id column) from a Vista
  3. Narrow a Vista with conditions, search, and ordering
  4. Read a window of rows with fetch_window, or walk a cursor with fetch_cursor
  5. Traverse relationships and cross-backend references
  6. Understand capabilities — the explicit contract between Vista and its driver

What Vista actually is

A Vista wraps a typed Table<DB, E> and erases both the backend and the entity. All data flows through Record<CborValue> — an ordered map of string keys to CBOR values. All schema lives on the Vista itself: columns (with types and flags), references, id column, and a set of capabilities.

Think of the progression:

Table<SqliteDB, Product>   — typed entity, typed backend, compile-time safe
Vista                      — fully erased: schema-bearing, CborValue, no generics

Vista trades away compile-time knowledge for runtime flexibility: everything is a string key and a CBOR value, and the schema travels with the handle rather than with your types. Wrapping a typed Table — this chapter’s path — is also not the only way to get one: every driver ships a VistaFactory that can materialize a Vista from a declarative YAML spec, with Rhai scripts for the expressions YAML can’t state. That path has its own guide — Config-Driven Vistas — and this chapter stays on the typed one. Scripts read and write Vistas through their own short vocabulary (table("product").where(…)), taught in Scripting with Rhai; the methods below are the Rust side of the same operations.

CBOR, not JSON

Vista uses ciborium::Value as its carrier type — a CBOR value. CBOR preserves type fidelity that JSON loses (integer vs float, binary blobs, precise decimals). You’ll see CborValue in every Vista method signature.

If you need JSON (for an HTTP response, for example), convert at the boundary — Record<CborValue> → Record<serde_json::Value> is a one-liner. But inside the Vista layer, CBOR is the standard.


Wrapping a typed Table

Each backend ships a factory that turns a typed table into a Vista. For SQLite, the database hands you one:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("category_id")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let table = Product::table(db.clone());
let vista: Vista = db.vista_factory().from_table(table)?;
Ok(())
}

That’s it. The factory harvests columns, id field, title fields, and references from the typed table definition you already built in chapter 2. No extra mapping code.

For MongoDB the factory is a MongoVistaFactory, for AWS an AwsVistaFactory — same shape, different import. The resulting Vista is the same type regardless of which factory produced it.

This chapter’s snippets read products.db, the file chapters 1–3 built, so they compile but are not run by the book’s tests.


Reading schema

Once you have a Vista, everything is runtime introspection:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Category { name: String }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("category_id")
            .with_one("category", "category_id", Category::table)
    }
}
impl Category {
    fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("products", "category_id", Product::table)
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
// Columns — name, original type, flags
for (name, column) in vista.columns() {
    let ty = column.original_type.as_deref().unwrap_or("?");
    println!("{name}: {ty}");
}
// price: i64
// …

// ID column
if let Some(id_column) = vista.id_column() {
    println!("id column: {id_column}");
}

// Title columns — the human-readable ones
for title in vista.title_columns() {
    println!("title: {title}");
}

// References — relationships declared on the typed table
for (name, reference) in vista.references() {
    println!("ref: {name} ({})", reference.kind.as_str());
}
// ref: category (has_one)
Ok(())
}

Column carries an original_type (the Rust type name the typed column was declared with, or None when nothing declares one) and a set of ColumnFlags. The flags the data layer acts on:

FlagMeaning
IdThis column is the primary key
TitleHuman-readable label column
SearchableIncluded in quicksearch
OrderableCan be sorted server-side
HiddenDon’t show in default views
MandatoryRequired on insert
RequestParamSent to the backend as a request parameter (a URL slot)
CalculatedComputed, not stored; read-only, and writes drop it
AutoThe backend makes the value (auto-increment, record ids)

Any other name is kept as ColumnFlag::Other for the layer that reads it — a UI’s presentation flag, for instance. Vista itself never interprets those.


Narrowing

Vista doesn’t carry its own condition type. Instead, it delegates to a TableShell — the per-driver executor every Vista wraps, and the piece each factory actually builds — which translates the value into whatever the backend speaks:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("category_id")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
let cakes = vista.clone().with_condition("category_id", FilterOp::Eq, 1)?;

let rows = cakes.list_values().await?;
// Only products with category_id == 1; `vista` still holds every product
Ok(())
}

The value converts into a CborValue. The driver’s shell translates the condition into a native one — an Expression for SQL, a bson::Document for MongoDB, an AwsCondition for AWS — and pushes it onto the wrapped table. FilterOp has the other comparisons too (Gt, In, …); every shell takes Eq, and the rest need the FilterOps capability.

Why with_* consumes

A Vista is a value, the same as the Table it wraps. It owns its query state, so vista.clone().with_condition(..) hands back a new, smaller set and leaves vista exactly as it was. That’s the property generic code leans on: a function that receives a Vista can narrow it for its own purposes without changing what the caller sees, and a cached “all products” Vista can be the starting point for every request that needs a subset.

The with_* methods take self and return Result<Self>, so they chain:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("category_id")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
let v = vista
    .clone()
    .with_condition("category_id", FilterOp::Eq, 1)?
    .with_order("price", SortDirection::Descending)?;
Ok(())
}

When you hold a Vista of your own and want to change it in place — a UI handle whose search box the user keeps editing — the &mut self methods do the same work: add_condition, set_search, set_order. They exist for the owner of the value, not for narrowing someone else’s.

OperationSemantics
with_condition, with_id / add_conditionAccrete — each call narrows further; none can be removed
with_search, with_order / set_search, set_orderReplace — a later call swaps the earlier one out; None clears it
WideningClone first — narrow the clone, keep the original

A narrowed Vista is a different, smaller set — not a view you toggle filters on. Each clause the shell accepted is recorded on query(), so the layers above (the cache in the next chapter, for one) can tell two narrowed Vistas apart.

Narrowing by id

A common pattern — “I have an id and want the row”:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("category_id")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
let row = vista.clone().with_id(7)?.get_some_value().await?;
// Some(("7", record)) when the row exists
Ok(())
}

with_id reads the id column name from the schema and adds the equality condition for you.


Search and ordering

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("category_id")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
// Quicksearch — fans across columns flagged Searchable — then sort by column
let v = vista
    .clone()
    .with_search("tart")?
    .with_order("price", SortDirection::Descending)?;

let rows = v.list_values().await?;
Ok(())
}

Both have replace semantics — a second search drops the first. Both return an error if the driver doesn’t support them, and ordering also refuses a column that isn’t flagged Orderable. Check capabilities first, or try and handle the error.

Not every driver supports these

A CSV file can’t sort or search server-side — it loads everything into memory. DynamoDB can only order by its declared sort key. Each driver advertises its capabilities accordingly. Calling a method the driver doesn’t advertise returns an Unsupported error naming the capability. This is by design — it’s better to fail clearly than to silently return unfiltered results.

Generic code branches on the capabilities instead, here on a Vista it owns:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
let mut v = vista.clone();
if v.capabilities().has(Capability::Search) {
    v.set_search(Some("query"))?;
}
if v.capabilities().has(Capability::Order) {
    v.set_order(Some(("name", SortDirection::Ascending)))?;
}
Ok(())
}

Reading windows

Vista has two ways to read part of a set. Not every driver supports both — capabilities tell you which to use.

A window by offset: fetch_window

Random access — any [offset, offset + limit) slice of the set, in its order:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
let first = vista.fetch_window(0, 25).await?;    // rows 0..25
let second = vista.fetch_window(25, 25).await?;  // rows 25..50

println!("{} rows, total {:?}", first.rows.len(), first.total);
Ok(())
}

A Page holds the rows and, when the fetch learned it, the set’s total — None means “didn’t say”, never zero. The leading window (offset == 0) works on every driver that lists; any other offset needs the Window capability. SQL databases and MongoDB have it, anything with LIMIT … OFFSET.

A cursor: fetch_cursor

Forward only — each call returns up to limit rows and a token for the next call:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
if vista.capabilities().has(Capability::Cursor) {
    let (page, token) = vista.fetch_cursor(None, 100).await?;   // first page
    let (page, token) = vista.fetch_cursor(token, 100).await?;  // second page
    // token is None when the set is exhausted
}
Ok(())
}

The token is opaque — its shape is driver-private (a DynamoDB LastEvaluatedKey, a REST nextToken, the last key S3 returned). Just round-trip it. Requires the Cursor capability, which SQLite doesn’t advertise and the S3 listing in the next chapter does.

When neither is available

Without Window and Cursor, the leading window and list_values() are what the driver offers. That’s the CSV case — fine for small datasets.


Traversing references

Same-persistence references (the with_many / with_one from chapter 2) come through automatically. Given a parent row, get_ref resolves the relationship:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Category { name: String }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("category_id")
            .with_one("category", "category_id", Category::table)
    }
}
impl Category {
    fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("products", "category_id", Product::table)
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let categories = db.vista_factory().from_table(Category::table(db.clone()))?;

// Given a category row, traverse to its products
if let Some((_, category)) = categories.get_some_value().await? {
    let products = categories.get_ref("products", Some(&category))?;
    for (id, row) in products.list_values().await? {
        println!("  {id} — {:?}", row.get("name"));
    }
}
Ok(())
}

get_ref takes the parent row as a Record<CborValue>. It reads the join field (e.g. category_id) out of that row to build the condition on the target, so a traversal starts from a row: fetch the parent with get_some_value() or list_values(), then traverse from each row. With None in place of the row, you get the bare target — the set a new related row goes into.

get_ref resolves two kinds of relation:

  1. Contained relations — records embedded in a column of the parent row (an order’s lines array) come back as an editable sub-Vista.
  2. Foreign-key references — the shell forwards to the with_one / with_many relationships declared on the typed table, and re-wraps the narrowed target as a fresh Vista.

Either way the result is another Vista, so traversal chains without ever leaving the universal surface. Traversal needs the Traverse capability.

Cross-backend references

What if your categories live in PostgreSQL and your products in MongoDB? That is deliberately not a Vista’s job — a Vista honestly describes one backend, and there is no engine that could execute a join across two. Cross-persistence traversal lives one layer up, in a Catalog: register a loader per model name, and reference.narrow(catalog.vista(&reference.target)?, &row) builds the target in its backend and narrows it by values read from the parent row. The consumer still just receives a Vista — see Config-Driven Vistas for the catalog in action.


Capabilities — the explicit contract

VistaCapabilities is a set of Capability values. The driver puts in exactly what it implements:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product { name: String, price: i64 }
impl Product {
    fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let vista = db.vista_factory().from_table(Product::table(db.clone()))?;
let caps = vista.capabilities();
for capability in caps.iter() {
    println!("{capability}");
}
// order, search, filter_ops, window, traverse, insert, update, delete, …

if caps.has(Capability::Insert) {
    println!("writable");
}
Ok(())
}

Each capability names one method family: Insert, Update, Delete, Import, Subscribe, Order, Search, FilterOps, NativeConditions, Window, Cursor, Traverse and ConfineWrites (writes through a narrowed Vista stay inside its set). Listing, getting a row, counting and equality conditions have no capability: every driver answers them.

A CSV file advertises Window and Traverse — it’s read-only. SQLite advertises reads, writes, Window and Traverse, but not Cursor. An S3 listing advertises Cursor and Traverse: no random access, no search, no writes.

Calling unsupported methods is an error

The capabilities aren’t suggestions — they’re a contract. If Search isn’t in the set, set_search() returns an Unsupported error, which VantageError::is_unsupported() recognises. A contract test calls every advertised capability on every shell in the workspace, so a driver can’t advertise a method it doesn’t answer.

UI adapters branch on these to decide which controls to show. A data grid checks Window to decide between a scrollbar (random access) and a “load more” button (Cursor).


Putting it together

Here’s a small function that takes any Vista and prints a summary — works with any backend:

#![allow(unused)]
fn main() {
use vantage_core::Result as VantageResult;
use vantage_vista::prelude::*;
async fn print_vista(vista: &Vista) -> VantageResult<()> {
    let columns: Vec<&String> = vista.columns().keys().collect();

    // Header
    print!("  {:>12} ", vista.id_column().unwrap_or("id"));
    for col in &columns {
        print!("{col:>16} ");
    }
    println!();

    // Count — every driver answers it
    println!("  ({} rows)", vista.count().await?);

    // Rows
    let rows = vista.list_values().await?;
    for (id, record) in &rows {
        print!("  {id:>12} ");
        for col in &columns {
            let val = record
                .get(col.as_str())
                .map(|v| format!("{v:?}"))
                .unwrap_or_default();
            print!("{val:>16} ");
        }
        println!();
    }
    Ok(())
}
}

No generics. No entity type. No backend knowledge. print_vista works with a SQLite Vista, a MongoDB Vista, an AWS Vista — anything the framework can produce.


What we covered

ConceptWhat it does
VistaUniversal schema-bearing data handle, wraps any Table
TableShellPer-driver executor that Vista delegates to
VistaCapabilitiesExplicit contract of what the driver supports
ColumnColumn metadata with name, type, and flags
columns() / id_column() / references()Read the schema at runtime
clone().with_condition(..)Narrow a copy; the original is untouched
with_idConvenience: narrow by primary key
with_search / with_orderQuicksearch and sorting (replace semantics)
add_condition / set_search / set_orderThe same, in place, on a Vista you own
fetch_window / fetch_cursorOffset windows and cursor pages
get_refTraverse a relationship from a parent row
CatalogCross-persistence traversal, one layer above Vista

What’s next

Vista gives you a universal read/write handle. But every call still hits the database — there’s no caching, no reactivity, no way to push live updates to a UI.

The next chapter introduces Dio and Lens — the caching and event layer that sits between a Vista (your master data) and a Scenery (the reactive view a UI consumes).

Choose Your Path

You now have a Vista — a runtime handle over your data that works the same no matter what’s behind it. Everything up to here was the foundation. Everything past here is the reactive stack: a local cache, an event bus, and watchable views that update as the data changes.

That stack is backend-agnostic, and the rest of the guide proves it by forking here into two paths. Both build the same layers — Dio, Lens, Augmentation, Scenery, and a watch-streaming HTTP server — and differ only in the backend underneath. Pick the one shaped like your problem; the code you write is nearly identical either way.

Which one is yours?

Path A · A facade over an API you don’t control

React / TUI ratatui, web Cache Dio · events Vista AWS S3 AWS API slow, read-only

Your data lives behind something slow you cannot change — a cloud API, a legacy service, a third-party endpoint. It is read-only, hundreds of milliseconds away, and cannot sort, search, or paginate. You want a fast, queryable local view of it.

The example builds an inventory of a public S3 bucket (NOAA’s climate archive) — thousands of files served seamlessly and responsively. Diorama caches the listing, injects the capabilities S3 lacks, and enriches each row from its contents; the path ends with a terminal UI (ratatui) and an Axum API feeding a React frontend.

Take Path A → — steps 5–8.

Path B · A live view in front of your own database

React / TUI ratatui, web Cache Dio · events Vista Postgres PostgreSQL you control it

Your data lives in a relational database you own — you read it, write it, and it already sorts, searches, and joins. What you want is a live, cached, watchable facade in front of it: instant reads, changes streaming to every viewer, writes routed on your terms.

The example builds a bar’s product inventory whose stock ticks down in real time as items sell. The same caching, reactive, watch-streaming stack — and the path moves the app from SQLite to PostgreSQL with a single switch, then on to SurrealDB, proving the backend was never load-bearing.

Take Path B → — steps 5–9.

Why the guide leads with S3

If you own a SQL database, an S3 bucket may look like a strange place to start teaching caching and reactivity. That is deliberate. A slow, read-only, capability-poor backend makes the value of the layer above visible: every millisecond the cache saves, and every sort the backend cannot do but Diorama can, is obvious. A capable SQL database would hide the very problem the reactive stack solves — so Path A teaches it where it shows, and Path B proves it was never about S3.

Path A — Custom API (S3)Path B — Relational DB (SQL)
Backendslow, remote, read-onlyfast, local, writable
Sort / searchno — Diorama injects ityes — natively
Writesrouted elsewhere (the master can’t take them)routed to the database
Reactivity sourceperiodic reconcile (S3 can’t push)your own writes + reconcile
The payoffa rich handle over a poor backenda live facade, then a backend swap

Both paths converge on the same idea: once a Dio wraps a Vista, nothing above it knows or cares what the backend is. Read whichever path is yours — or both, and watch the code stay the same while the backend changes underneath it.

Dio & Lens — Caching and Events

Chapter 4 gave you Vista — a universal handle that works with any backend. But every call still hits the backend. That was easy to forgive with a local SQLite file; this chapter picks a data source where it hurts: a real cloud API, hundreds of milliseconds away, read-only, and unable to sort or search. We’ll build a small CLI around it — an inventory of a public S3 bucket, with a cache.

Diorama (vantage-diorama) is the layer that makes this pleasant. It sits between your Vista and whatever consumes it, and does three things:

  1. Transparent caching. Keep a local copy of your data. Reads come from cache, not from the master — a listing that costs seconds over the network costs microseconds after the first fetch.
  2. Capability injection. A Vista backed by S3 can’t sort, search, or read a window at an offset. Diorama caches the dataset locally and answers those queries from cache — the consumer sees a richer handle than the backend actually offers.
  3. Custom write routing. Writes don’t have to go to the master — useful when the master can’t take them at all. You decide what a write means: a queue, a different store, an API call.

Why ‘Diorama’?

The vista from the peak is magnificent — and far away. Sooner or later you want a piece of it close at hand: on your desk, under glass, alive. A diorama is exactly that — a crafted miniature of a real scene, small enough to hold, faithful enough to study.

That’s the layer this chapter builds. A Lens is the optics you capture the scene through — ground once, reused for every capture, deciding what is kept and how it refreshes. A Dio is one captured segment: your local, living copy of the data, reconciling with the world it depicts. And once the miniature is lit and running, Scenery (chapter 7) is what an audience actually watches.


Table, Vista, Dio

This is the third handle to the same records — and like the query-vs-table comparison back in chapter 2, each one trades something away for something new:

Table<DB, E>VistaDio
PurposeModel your data; business logicLet generic code consume any tableKeep a live local copy of a data segment
TypingCompile-time entity & backendSchema carried at runtimeSame records as Vista
ReadsQuery the backendQuery the backendServed from a local cache
WritesApplied immediatelyApplied immediatelyQueued — through the master or a Lens writer
ChangesYou re-queryYou re-queryAnnounced on an event bus
CapabilitiesWhatever the backend supportsHonestly advertisedExtended — the cache fills the gaps
ModeTransactionalTransactionalLive
LifecycleA value — cheap to clone, narrowed per useA value — cloned, narrowed, droppedA shared handle — owns its cache, queue, and background tasks

Caching

Diorama caches at two levels. Page segments hold windows of an ordered query result — they’re what lets a viewport scroll a huge listing without re-asking the master, and they power the loading strategies you’ll meet in chapter 7. Beneath them sits the key/value record store — one entry per record id, plain and dumb. We keep it simple for now: everything in this chapter runs on the record store alone.

Dio master Vista — S3 listing ~200 ms per page cache redb — id → record µs away the Lens pumps master → cache facade Vista dio.cached() Sceneries chapter 7 reads — µs

A Dio owns exactly the two blocks above: a master Vista — the source of truth, however far away — and a cache, the local copy. What it deliberately does not decide is policy: when the cache fills, when it goes stale, what a write means. There are too many valid answers for one default — seed once and keep forever, re-fetch on a timer, reconcile from a push stream, write through to the master, write somewhere else entirely. That’s what the Lens is for: it describes how the cache is used. The simplest useful Lens pumps the master into the cache once, when the Dio is built:

use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;
use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, Loader, LoaderCx};
use vantage_vista::prelude::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct File {
    #[serde(rename = "Key")]
    filename: String,
    #[serde(rename = "Size")]
    size: String,
}
impl File {
    fn table(aws: AwsAccount, bucket: &str, prefix: &str) -> Table<AwsAccount, File> {
        Table::new(
            "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
            aws,
        )
        .with_id_column("Key")
        .with_column_of::<String>("Size")
        .with_condition(eq("Bucket", bucket))
        .with_condition(eq("prefix", prefix))
        .with_condition(eq("max-keys", 100))
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let aws = AwsAccount::public("us-east-1");
let master = aws
    .vista_factory()
    .from_table(File::table(aws, "noaa-ghcn-pds", "csv/by_station/GM"))?;
let lens = Lens::builder()
    .cache(Cache::redb("cache.redb"))
    .loader(Loader::eager(|cx: LoaderCx| async move {
        // Pump: read everything from the master, make the cache hold exactly that.
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()?;

let dio = lens.dio(master).build().await?;   // runs the eager start, returns a warm Dio
Ok(())
}

With the Dio in place, there are two ways to consume it. The first is the facade Vista — dio.cached() — for proactive querying: you ask, it answers, exactly like the backend’s own Vista from chapter 4. Same interface, same records — except the answers come from the cache, and the capability set can be wider than the master’s: the facade searches, orders and reads windows whatever the master can do, and a read-only master gains writes when the Lens routes them somewhere. The second way in is the Scenery — a standing, reactive view that keeps itself current and tells you when it changed. UIs bind to sceneries; chapter 7 lives there.

Dio master + cache facade Vista dio.cached() proactive: you ask — list, get, count… …it answers from the cache Scenery a standing view reactive: changes stream in — the generation counter announces each one

The facade Vista

Hand dio.cached() to chapter 4’s print_vista and it just works — it never learns a cache is underneath. Each call returns a fresh, independent Vista, so you narrow it with with_* like any other and no other consumer sees the change.

What makes the facade interesting is its capability set. Chapter 4’s contract was honest but rigid: whatever the backend can’t do, your application can’t have. The facade advertises what the pipeline can do: Search, Order, FilterOps and Window always (a clause the master takes is pushed down, and the rest is evaluated over the cached rows), Subscribe because the event bus backs it, and the writes the master can take — or all of them once a Lens writer gives writes somewhere to go. The honesty contract still holds; it just describes a different engine.

facade VistaScenery
Access styleProactive — you ask, it answersReactive — a standing view that stays current
InterfaceChapter 4’s Vista API, unchangedPurpose-built: rows by index, a record, a scalar
FreshnessAs fresh as the cache when you askRecomputes on every change
Change awarenessNone — ask againA generation counter that bumps on each change
CapabilitiesSearch, order, windows, plus the writes the pipeline routesOrdering, search, viewport — regardless of backend
Typical consumerHandlers, scripts, CLI commandsUIs, dashboards, live views

The project: a weather-station inventory

NOAA publishes its daily climate archive — GHCN, one CSV per weather station — as a public S3 bucket in the AWS Open Data registry. It’s a perfect Diorama subject: listing it is slow, every listing request is paid again on every run, and the API can’t sort or search. We’ll grow one small tool across three chapters:

  • This chapter — a CLI that lists the station files from a persistent local cache.
  • Chapter 6 — augmentation: each file gains columns computed from its contents (how many readings, how recent).
  • Chapter 7 — a live terminal UI that scrolls the whole archive and fetches per-file data for exactly the rows on screen.

No AWS account required

The bucket allows anonymous access, the way aws s3 ls --no-sign-request reads it. AwsAccount::public(region) is the vantage-aws equivalent: requests go out unsigned, so there are no credentials to configure — nothing to install, nothing to pay.

Set up the project:

cargo new learn-4 && cd learn-4
cargo add serde --features derive
cargo add tokio --features full
cargo add vantage-aws vantage-diorama vantage-vista

vantage-aws is the S3/DynamoDB/IAM driver — it signs (or deliberately doesn’t sign) requests itself, so there’s no AWS SDK in the tree. vantage-diorama is what this chapter is about. The code splits into two files: files.rs holds the table definition, main.rs uses it.

Every snippet that talks to S3 or opens cache.redb compiles in the book’s tests but doesn’t run there; the definitions without either do run.


files.rs — the listing as a table

One row per file in the bucket. This is chapter 2’s pattern — an entity, and a table() constructor that describes the source — pointed at a cloud API instead of a database:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;

/// One file in the bucket. Field names match S3's wire XML
/// (`<Contents><Key/><Size/></Contents>`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct File {
    #[serde(rename = "Key")]
    pub filename: String,
    #[serde(rename = "Size")]
    pub size: String,
}
}

The serde renames map S3’s wire names onto the fields we actually want to write in Rust. Size is a String because that’s what the XML carries — no silent coercion.

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct File {
    #[serde(rename = "Key")]
    pub filename: String,
    #[serde(rename = "Size")]
    pub size: String,
}
impl File {
    /// `ListObjectsV2` narrowed to one bucket and prefix. S3 sends at
    /// most `max-keys` keys per response; the `@continuation-token`
    /// cursor tells the framework to keep requesting pages until the
    /// listing is complete.
    pub fn table(aws: AwsAccount, bucket: &str, prefix: &str) -> Table<AwsAccount, File> {
        Table::new(
            "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
            aws,
        )
        .with_id_column("Key")
        .with_column_of::<String>("Size")
        .with_condition(eq("Bucket", bucket))
        .with_condition(eq("prefix", prefix))
        .with_condition(eq("max-keys", 100))
    }
}
}

The table name is doing a lot of work here — for vantage-aws, it’s the wire protocol spelled out: restxml is the protocol S3 speaks; Contents is the response element holding the rows; s3/GET /{Bucket}?list-type=2 is the service and request. Conditions complete the request the same way chapter 2’s conditions completed a WHERE clause: Bucket fills the path placeholder, and anything else (prefix, max-keys) becomes a query parameter.

The @continuation-token=NextContinuationToken part is auto-pagination. S3 answers at most max-keys files per response, plus a continuation token when more exist; the cursor declaration tells the driver to keep re-issuing the request — token folded back in — until the listing is complete. One list() call, as many HTTP requests as it takes.

The table-name grammar

The full shape vantage-aws parses is:

{protocol}/{array_key}[@cursor|@request=response]:{service}/{METHOD} {path}?{query}

protocol is one of json1, json10, query, restxml, restjson; array_key names the response element holding the rows; the optional @ suffix declares the pagination cursor (one name if request and response fields match, request=response when they differ, as here). Get it wrong and the first query returns a VantageError quoting this grammar — an unknown protocol lists the valid ones. Nothing is guessed and nothing fails silently.

A first listing

main.rs, shortest possible version. It starts with mod files;, then builds the table, lists it and prints:

mod files {
    use serde::{Deserialize, Serialize};
    use vantage_aws::prelude::*;
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct File {
        #[serde(rename = "Key")]
        pub filename: String,
        #[serde(rename = "Size")]
        pub size: String,
    }
    impl File {
        pub fn table(aws: AwsAccount, bucket: &str, prefix: &str) -> Table<AwsAccount, File> {
            Table::new(
                "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
                aws,
            )
            .with_id_column("Key")
            .with_column_of::<String>("Size")
            .with_condition(eq("Bucket", bucket))
            .with_condition(eq("prefix", prefix))
            .with_condition(eq("max-keys", 100))
        }
    }
}
use files::File;
use vantage_aws::prelude::*;

const BUCKET: &str = "noaa-ghcn-pds";
const PREFIX: &str = "csv/by_station/GM";

#[tokio::main]
async fn main() {
    if let Err(e) = run().await {
        e.report();
    }
}

async fn run() -> VantageResult<()> {
    let aws = AwsAccount::public("us-east-1");
    let files = File::table(aws, BUCKET, PREFIX);

    for (filename, file) in files.list().await? {
        println!("{:>10}  {filename}", file.size);
    }
    Ok(())
}

The GM prefix narrows the archive to Germany’s stations — 1122 files, small enough to look at, big enough to feel:

   4796352  csv/by_station/GM000001153.csv
   7057976  csv/by_station/GM000001474.csv
   2978238  csv/by_station/GM000002277.csv
   ...
1122 files in 2.3s

2.3 seconds: twelve HTTPS round-trips (1122 files at 100 per page), paid on every run, forever, because nothing remembers the answer. This is the itch the rest of the chapter scratches.


The master Vista, and what it can’t do

The Dio wants a Vista for its master, so we erase the typed table the same way chapter 4 did:

use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct File {
    #[serde(rename = "Key")]
    filename: String,
    #[serde(rename = "Size")]
    size: String,
}
impl File {
    fn table(aws: AwsAccount, bucket: &str, prefix: &str) -> Table<AwsAccount, File> {
        Table::new(
            "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
            aws,
        )
        .with_id_column("Key")
        .with_column_of::<String>("Size")
        .with_condition(eq("Bucket", bucket))
        .with_condition(eq("prefix", prefix))
        .with_condition(eq("max-keys", 100))
    }
}
const BUCKET: &str = "noaa-ghcn-pds";
const PREFIX: &str = "csv/by_station/GM";
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let aws = AwsAccount::public("us-east-1");
let master = aws
    .vista_factory()
    .from_table(File::table(aws, BUCKET, PREFIX))?;
Ok(())
}

Ask this Vista what it can do, and you’ll see why this backend needs a Diorama:

use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;
use vantage_vista::prelude::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct File {
    #[serde(rename = "Key")]
    filename: String,
    #[serde(rename = "Size")]
    size: String,
}
impl File {
    fn table(aws: AwsAccount, bucket: &str, prefix: &str) -> Table<AwsAccount, File> {
        Table::new(
            "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
            aws,
        )
        .with_id_column("Key")
        .with_column_of::<String>("Size")
        .with_condition(eq("Bucket", bucket))
        .with_condition(eq("prefix", prefix))
        .with_condition(eq("max-keys", 100))
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let aws = AwsAccount::public("us-east-1");
let master = aws
    .vista_factory()
    .from_table(File::table(aws, "noaa-ghcn-pds", "csv/by_station/GM"))?;
let caps = master.capabilities();
// Cursor and Traverse — and that's it.
assert!(caps.has(Capability::Cursor));
assert!(!caps.has(Capability::Search) && !caps.has(Capability::Order));
assert!(!caps.has(Capability::Window) && !caps.has(Capability::Insert));
Ok(())
}

Cursor is worth pausing on — it’s the one capability this chapter leans on. fetch_cursor(token, limit) is Vista’s cursor pagination from chapter 4: pass None for the first page, pass back the returned token for the next one. For S3 the driver defines the token as the last key of the previous page, because S3 lists keys in order and accepts any key as a starting point (start-after). That has a consequence the generic contract doesn’t promise: this cursor survives process restarts. Any key you already hold — say, the last key in a cache — resumes the listing right after it.

The Lens: pump pages, resume where you left off

Now the Lens. One setting does the work — an eager loader that, when the Dio is built, synchronizes the cache with the bucket:

use std::time::Instant;
use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;
use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, Loader, LoaderCx};
use vantage_vista::prelude::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct File {
    #[serde(rename = "Key")]
    filename: String,
    #[serde(rename = "Size")]
    size: String,
}
impl File {
    fn table(aws: AwsAccount, bucket: &str, prefix: &str) -> Table<AwsAccount, File> {
        Table::new(
            "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
            aws,
        )
        .with_id_column("Key")
        .with_column_of::<String>("Size")
        .with_condition(eq("Bucket", bucket))
        .with_condition(eq("prefix", prefix))
        .with_condition(eq("max-keys", 100))
    }
}
const CACHE_FILE: &str = "cache.redb";
const PAGE_SIZE: usize = 1000;
async fn sync(cx: &LoaderCx) -> VantageResult<()> {
    let mut token: Option<CborValue> =
        cx.cache().list_values().await?.keys().last().cloned().map(Into::into);
    loop {
        let start = Instant::now();
        let (page, next) = cx.master().fetch_cursor(token, PAGE_SIZE).await?;
        let count = page.rows.len();
        cx.cache().insert_values(page.rows.into_iter().collect()).await?;
        println!("fetched {count} files in {:?}", start.elapsed());
        if next.is_none() {
            return Ok(());
        }
        token = next;
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let aws = AwsAccount::public("us-east-1");
let master = aws
    .vista_factory()
    .from_table(File::table(aws, "noaa-ghcn-pds", "csv/by_station/GM"))?;
let lens = Lens::builder()
    .cache(Cache::redb(CACHE_FILE))
    .loader(Loader::eager(|cx: LoaderCx| async move { sync(&cx).await }))
    .build()
    .context("Failed to build lens")?;
let dio = lens.dio(master).build().await?;
Ok(())
}
  • Lens::builder() collects the settings; .build() checks them and returns the Lens, already in an Arc because every Dio it builds shares it.
  • .cache(Cache::redb(CACHE_FILE)) opens a redb file on disk. This is why the cache survives process restarts. Cache::memory() is the alternative for when persistence isn’t wanted.
  • Loader::eager(...) runs once per Dio, inside build(), which waits for it. The callback gets a LoaderCx: an owned, cheap-to-clone context with the Dio’s master() and cache(), so the future can hold it across .await without borrowing anything.
  • lens.dio(master).build().await does the rest in one call: opens a cache table named after the master, spawns the write worker, runs the eager start, and hands back the Dio.

sync is where the cursor pays off. Instead of one big list_values(), it pulls the listing one page at a time and seeds the cursor from the cache:

#![allow(unused)]
fn main() {
use std::time::Instant;
use vantage_aws::prelude::*;
use vantage_diorama::LoaderCx;
use vantage_vista::prelude::*;
const PAGE_SIZE: usize = 1000;
/// Pump the master listing into the cache, one page per request. S3's
/// paging cursor is simply "the last key seen" — so the last key already
/// in the cache resumes the listing, and pages loaded by an earlier run
/// (even one that was interrupted) are never fetched again.
async fn sync(cx: &LoaderCx) -> VantageResult<()> {
    let mut token: Option<CborValue> = cx
        .cache()
        .list_values()
        .await?
        .keys()
        .last()
        .cloned()
        .map(Into::into);
    loop {
        let start = Instant::now();
        let (page, next) = cx.master().fetch_cursor(token, PAGE_SIZE).await?;
        let count = page.rows.len();
        cx.cache()
            .insert_values(page.rows.into_iter().collect())
            .await?;
        println!("fetched {count} files in {:?}", start.elapsed());
        if next.is_none() {
            return Ok(());
        }
        token = next;
    }
}
}

Read the first statement again: the initial token is the last filename already cached. On a cold cache that’s None — start from the top. On a warm cache it’s the end of what we have — S3 continues from there. Kill the process halfway through a sync and run it again: the pages you already paid for are never fetched twice. The cache isn’t just an answer store; it’s the resume point. PAGE_SIZE is the most keys one request asks for; S3 returns at most 1000.

Reads come from the cache

The listing loop stops asking S3 and starts asking the Dio — through the facade Vista from the first half of the chapter. Notice who talks to what: sync, being Lens plumbing, addresses the Dio’s two sides through its LoaderCx (cx.master() to fetch, cx.cache() to store and resume); a consumer asks dio.cached() and never learns what’s underneath. The records come back as Record<CborValue> — the erased form from chapter 4 — so field access goes through the CborValueExt helpers the prelude brings in:

use std::time::Instant;
use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;
use vantage_diorama::prelude::*;
use vantage_diorama::Cache;
use vantage_vista::prelude::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct File {
    #[serde(rename = "Key")]
    filename: String,
    #[serde(rename = "Size")]
    size: String,
}
impl File {
    fn table(aws: AwsAccount, bucket: &str, prefix: &str) -> Table<AwsAccount, File> {
        Table::new(
            "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
            aws,
        )
        .with_id_column("Key")
        .with_column_of::<String>("Size")
        .with_condition(eq("Bucket", bucket))
        .with_condition(eq("prefix", prefix))
        .with_condition(eq("max-keys", 100))
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let aws = AwsAccount::public("us-east-1");
let master = aws
    .vista_factory()
    .from_table(File::table(aws, "noaa-ghcn-pds", "csv/by_station/GM"))?;
let dio = Lens::builder().cache(Cache::redb("cache.redb")).build()?.dio(master).build().await?;
let start = Instant::now();
let listing = dio.cached().list_values().await?;
for (filename, file) in &listing {
    let size = file.get("Size").and_then(|v| v.as_str()).unwrap_or("");
    println!("{size:>10}  {filename}");
}
println!("{} files from cache in {:?}", listing.len(), start.elapsed());
Ok(())
}

First run — cold cache, the sync pump narrating each page:

fetched 100 files in 215ms
fetched 100 files in 217ms
...
fetched 22 files in 114ms
   4796352  csv/by_station/GM000001153.csv
   7057976  csv/by_station/GM000001474.csv
   ...
1122 files from cache in 18ms

Second run — same command, new process:

fetched 0 files in 370ms
1122 files from cache in 17ms

One round-trip — the resume request from the last cached key, confirming nothing new exists — and then the full listing in 17 milliseconds. New files that do appear under the prefix arrive on exactly that request, without re-fetching the thousand we already hold.

Invalidating

A resuming cache has one blind spot: it only ever looks past what it holds, so files deleted from the bucket linger locally. The honest fix is to start over. The Lens opens cache.redb when it is built, so the place to start over is before that: delete the file, and the eager start finds an empty cache and lists the bucket from the top.

use vantage_aws::prelude::*;
const CACHE_FILE: &str = "cache.redb";
fn main() -> VantageResult<()> {
// In `run`, before the Lens is built:
if std::env::args().any(|a| a == "--invalidate") {
    remove_cache()?;
}
Ok(())
}

fn remove_cache() -> VantageResult<()> {
    match std::fs::remove_file(CACHE_FILE) {
        Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
            Err(e).context("Failed to remove the cache")
        }
        _ => Ok(()),
    }
}
$ cargo run -- --invalidate
fetched 100 files in 215ms
fetched 100 files in 217ms
...
1122 files from cache in 18ms

The CLI is complete: two seconds once, milliseconds forever after, resumable mid-sync, and an escape hatch back to the truth.


The event bus

One more thing build() set up, invisibly: every Dio carries a broadcast event bus, which dio.subscribe_events() listens to. Anything that changes data announces it there as a DioEvent: Seeded when an eager start has put the whole set in the cache, DatasetChanged when the set was rewritten wholesale, RecordChanged / RecordInserted / RecordRemoved for row-level changes, Refreshing and RefreshFailed around a re-read, and WritePending / WriteReverted / WriteFailed as writes move through the queue. Nothing in this chapter listens yet; chapter 7’s sceneries subscribe to this bus to know when to recompute, and chapter 6’s hydration uses it to report progress.

One rule is worth forming as a habit now: direct cache writes are silent. Our sync calls cx.cache().insert_values(...), which stores rows and announces nothing. Inside an eager start that’s fine — the Dio publishes Seeded once the start returns, and every scenery reloads from the cache. Anywhere else, use the calls that write and announce in one motion: cx.reconcile(rows) makes the cache hold exactly the set you hand it (writing what differs, deleting what’s gone) and publishes one DatasetChanged; dio.apply_change(...) takes one upstream ChangeEvent — Inserted, Updated, Deleted or Invalidated — and publishes the matching row event.

Poll or push — the Lens doesn’t care

Our S3 listing has no way to notify us, so freshness is pulled: the eager start on every run, --invalidate by hand, or a Refresher on a timer (chapter 7’s live view uses one). Backends that can push take the other route: lens.dio(master).live() follows a master that advertises Subscribe (a SurrealDB live query, Postgres LISTEN/NOTIFY), and anything else — a Kafka topic, a webhook — calls dio.apply_change(...) itself. Same cache, same events — only the trigger differs.

Writes, on a read-only master

S3’s listing Vista doesn’t advertise Insert, and the facade won’t pretend otherwise — by default. A write enters a Dio as a ChangeFlash handed to dio.flash(...) (the facade’s own insert_value and friends become flashes too). The flash is staged in the cache at once, joins the Dio’s write queue, and the queue writes it through the master. A master that can’t take it fails the write: the staged row is rolled back, WriteReverted goes out on the bus, and flash returns the error — never a silent drop.

use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;
use vantage_diorama::prelude::*;
use vantage_diorama::Cache;
use vantage_types::Record;
use vantage_vista::prelude::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct File {
    #[serde(rename = "Key")]
    filename: String,
    #[serde(rename = "Size")]
    size: String,
}
impl File {
    fn table(aws: AwsAccount, bucket: &str, prefix: &str) -> Table<AwsAccount, File> {
        Table::new(
            "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
            aws,
        )
        .with_id_column("Key")
        .with_column_of::<String>("Size")
        .with_condition(eq("Bucket", bucket))
        .with_condition(eq("prefix", prefix))
        .with_condition(eq("max-keys", 100))
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let aws = AwsAccount::public("us-east-1");
let master = aws
    .vista_factory()
    .from_table(File::table(aws, "noaa-ghcn-pds", "csv/by_station/GM"))?;
let dio = Lens::builder().cache(Cache::redb("cache.redb")).build()?.dio(master).build().await?;
let mut row = Record::new();
row.insert("Size".to_string(), CborValue::from("0"));

let written = dio.flash(ChangeFlash::insert("csv/by_station/NOTE.csv", row)).await;
// Err: the S3 listing takes no inserts, and the staged row is rolled back
Ok(())
}

To make the write mean something else, give the Lens a writer. Every flash the queue runs is then handed to your closure instead of the master — append to a journal, call a different API, publish to a queue. This is exactly the introduction’s “a read-only CSV file accepts writes by routing them into a queue”. Ok confirms the write and keeps the staged row; Err rolls it back. A writer also makes every Dio of the Lens advertise the writes on its facade:

use vantage_aws::prelude::*;
use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, FlashOutcome, LoaderCx};
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder()
    .cache(Cache::redb("cache.redb"))
    .writer(|_cx: LoaderCx, flash: ChangeFlash| async move {
        println!("journal: {:?} {:?}", flash.kind(), flash.id());
        Ok(FlashOutcome::applied())
    })
    .build()?;
Ok(())
}

The four settings

A Lens has four settings, each with its own moment:

SettingWhen it runsIn this chapter
Loadereager: once per Dio, inside build() (or in the background with .blocking(false)). paged: each window a table scenery asks for. paged(..).or_eager(..): pages a master with Window, copies one withoutLoader::eager — sync, resuming from the cache
RefresherOn dio.refresh(); every interval with .every(..); at build and scenery open when the cache is older than .max_age(..)Not used — chapter 7 refreshes on a timer
writerEvery write the Dio’s queue runs, in order, one at a timeNot used — the S3 listing is read-only
TuningNever runs; read by every Dio: write-queue size, viewport debounce, augmentation workers, refresh on open, aggregate debounceDefaults

Everything a Dio adds beyond these — augmentation in chapter 6, a live follow with .live() — is chosen on lens.dio(master) before build(), because it belongs to that one Dio, not to every Dio the Lens builds.

Why a builder that can refuse?

Lens::builder().build() returns a Result, and it does refuse things. Some settings only make sense together, and each wrong combination would otherwise fail much later, in a way that’s hard to trace back: Loader::blocking on a loader with no eager start (there’s nothing to block on), Refresher::every(Duration::ZERO) (a timer with no period), a write queue of size zero (a queue that can never take a write), a Lens built outside a tokio runtime (nowhere to spawn its tasks). build() checks the combination once and names the setting at fault, so a misconfiguration is an error at startup, not a stall at the first write:

use std::time::Duration;
use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, LoaderCx, Refresher};
use vantage_vista::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let refused = Lens::builder()
    .cache(Cache::memory())
    .refresher(
        Refresher::new(|cx: LoaderCx| async move {
            let rows = cx.master().list_values().await?;
            cx.reconcile(rows).await
        })
        .every(Duration::ZERO),
    )
    .build();

assert!(refused.is_err()); // "Refresher::every needs a non-zero interval"
Ok(())
}

The cost is one ? at the call site. A builder that took anything and guessed would save that, and turn each of these mistakes into a hang.


What we covered

ConceptWhat it does
AwsAccount::public(region)Unsigned requests — public buckets, no credentials
Cursor in the table name (@req=resp)Auto-pagination: one list(), as many requests as needed
LensShared policy: cache, loader, refresher, writer, tuning
Cacheredb(path) on disk, memory() in process
DioMaster Vista + cache under a Lens; owns the queue and event bus
fetch_cursor(token, limit)One page per call; S3’s token is the last key — durable
LoaderCxThe Dio’s master() and cache(), handed to every callback
dio.cached()Facade Vista: reads from cache, writes through the queue
dio.flash(ChangeFlash::…)A write: staged, queued, written through, confirmed or reverted
DioEventBus event: seeded, dataset and row changes, write progress

What’s next

The inventory knows every file’s name and size — and nothing about what’s inside. The next chapter teaches the Dio to augment its rows: each station file gains a reading count and a latest-reading date, computed from the file itself, fetched once, cached forever.

Augmentation — Enriching Rows

Chapter 5’s inventory knows every station file’s name and size — the two things an S3 listing gives away for free. Everything interesting is inside the files: how many readings a station has recorded, and how recently. Getting that costs one download per file, which is exactly the kind of expense you want to pay once and remember.

Augmentation is Diorama’s answer: enrich the master’s rows, one row at a time, from a detail source — and let the Dio’s cache hold the result. The listing stays the cheap, fast spine it was in chapter 5; each row gains columns it never had, hydrated on demand and persisted alongside the row. This chapter adds two such columns to the inventory:

      SIZE     ROWS     LATEST  FILENAME
   4796352   140629   19911231  csv/by_station/GM000001153.csv

ROWS — how many readings the file holds; LATEST — the date of the most recent one. Neither exists anywhere in S3’s listing; both are computed from the file’s contents.

We build on chapter 5’s crate unchanged — learn-5 starts as a copy of learn-4 (files.rs and main.rs exactly as they were) and this chapter only adds. One new file, readings.rs, describes the detail side; a handful of lines in main.rs wire it up.


The detail side: readings.rs

Every station file is a CSV of date-ordered readings:

ID,DATE,ELEMENT,DATA_VALUE,M_FLAG,Q_FLAG,S_FLAG,OBS_TIME
GM000001153,18910101,TMAX,-20,,,I,
GM000001153,18910102,TMAX,-40,,,I,
...

So the two columns we’re after are cheap derivations: rows is the line count minus the header, latest is the DATE field of the last line. The expensive part is getting the contents at all.

The detail source is a table — the same ListObjectsV2 listing as the master, in fact, with two differences: it declares no prefix (the augmentation will narrow it to a single file per fetch), and it carries the derived columns. Start readings.rs with its entity:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;

/// One file, seen through the augmenter: the listing row plus columns
/// computed from the file's contents.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Readings {
    #[serde(rename = "Key")]
    pub filename: String,
    pub rows: i64,
    pub latest: String,
}
}

Lazy expressions

Where do rows and latest come from? Chapter 2’s with_expression won’t do it — those expressions lower into the backend’s query (a SQL subselect), and S3 can’t compute anything server-side. What we need runs on our side, after the row comes back.

That’s a lazy expression: a column computed in Rust, on the returned record. Lazy expressions apply in declaration order, and each callback borrows the record as built so far — including the columns earlier lazy expressions added. That ordering rule is the whole trick:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use vantage_aws::prelude::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Readings {
    #[serde(rename = "Key")]
    pub filename: String,
    pub rows: i64,
    pub latest: String,
}
impl Readings {
    pub fn table(aws: AwsAccount, bucket: &str) -> Table<AwsAccount, Readings> {
        let bucket = bucket.to_string();
        Table::new("restxml/Contents:s3/GET /{Bucket}?list-type=2", aws.clone())
            .with_id_column("Key")
            .with_condition(eq("Bucket", bucket.clone()))
            .with_lazy_expression("contents", move |row| {
                let aws = aws.clone();
                let bucket = bucket.clone();
                let key = row.get("Key").and_then(|v| v.as_str()).unwrap_or_default().to_string();
                async move { Ok(s3::get_object(&aws, &bucket, &key).await?.into()) }
            })
            .with_lazy_expression("rows", |row| {
                // Every line after the CSV header is one reading.
                let contents = row.get("contents").and_then(|v| v.as_str()).unwrap_or_default();
                let rows = contents.lines().count().saturating_sub(1) as i64;
                async move { Ok(rows.into()) }
            })
            .with_lazy_expression("latest", |row| {
                // Readings are date-ordered; take the last line's DATE column.
                let contents = row.get("contents").and_then(|v| v.as_str()).unwrap_or_default();
                let latest = contents
                    .lines()
                    .last()
                    .and_then(|line| line.split(',').nth(1))
                    .unwrap_or_default()
                    .to_string();
                async move { Ok(latest.into()) }
            })
    }
}
}

Read it as a pipeline over one record. The listing returns {Key, Size, …}; then:

  1. contents reads Key off the record and downloads the file — s3::get_object is the driver’s raw fetch, unsigned here just like the listing. Its return value is inserted into the record under contents. This is the expensive step, and it happens once.
  2. rows never touches the network: it reads row.get("contents") — the column the previous expression just added — and counts lines.
  3. latest reads the same contents and takes the last line’s date.

One download feeds every derived column declared after it. Each callback clones what it needs out of the borrowed record before going async, and each expression’s name is also registered as a column on the table, so the derived fields are part of its schema like any other.

Lazy expressions from YAML

Like most things declared on a table, lazy columns have a config-driven form: a column spec may carry lazy: <rhai script>, where the script sees the record built so far as row and its final expression becomes the value — row.contents.split("\n").len() - 1. Declaration order chains the same way. A lazy: script can’t do I/O, so it suits the rows and latest steps but not the download. See Computed Columns.

Why not just list this table?

It’s tempting to stop here — the augmenter table already produces every column we want, so why not make it the Dio’s master? Because lazy expressions run when data is fetched. A list() on this table triggers the whole pipeline for every row it returns: a thousand files means a thousand downloads inside one blocking call, and nothing comes back until the last one lands. That’s not a listing — it’s a batch job wearing a listing’s interface.

What we’re after is different. The list of files should reach the user instantly — it’s chapter 5’s cached listing, it costs milliseconds. The expensive columns should then be fetched one row at a time, each result written into the Dio and announced on the event bus, so anything watching updates progressively. Loading details for every station takes a long time no matter what; the win is in never blocking the listing on it, and in choosing the order — the next chapter puts a viewport in charge, so the rows the user is currently looking at are hydrated first, and rows nobody scrolls to are never fetched at all.

That split — a cheap master everyone lists, an expensive detail source consulted per row — is what augmentation declares.

Wiring the augmentation

Back in main.rs. The augmenter becomes a Vista like any table, and the Dio is told how to use it. Chapter 5 built its Dio with lens.dio(master).build(); the augmentation slots in between the two calls, and that’s the only structural change to run():

#![allow(unused)]
fn main() {
use std::sync::Arc;
use vantage_aws::prelude::*;
use vantage_diorama::{Augmentation, Detail, Fetch, Lens, MergeRule, Source};
use vantage_types::EmptyEntity;
use vantage_vista::Vista;
const BUCKET: &str = "noaa-ghcn-pds";
struct Readings;
impl Readings {
    fn table(aws: AwsAccount, bucket: &str) -> Table<AwsAccount, EmptyEntity> {
        Table::new("restxml/Contents:s3/GET /{Bucket}?list-type=2", aws)
            .with_id_column("Key")
            .with_condition(eq("Bucket", bucket))
    }
}
async fn run(aws: AwsAccount, lens: Arc<Lens>, master: Vista) -> vantage_core::Result<()> {
let augmenter = aws
    .vista_factory()
    .from_table(Readings::table(aws.clone(), BUCKET))?;

let dio = lens
    .dio(master)
    .augment(vec![Augmentation::new(
        Detail::Fixed(Arc::new(augmenter)),
        Source::Column {
            from: "Key".into(),
            to: Some("prefix".into()),
        },
        Fetch::PerRow,
        MergeRule::lift(["rows", "latest"]),
    )])
    .build()
    .await?;
let _ = dio;
Ok(())
}
}

An Augmentation answers four questions:

  • detail: where do detail records come from? Detail::Fixed holds our augmenter Vista directly. The config-driven form is Detail::Resolved { resolver, name }: a config carries only a name, so the detail Vista is looked up by that name on each fetch, through a VistaResolver such as a Catalog. The resolver travels with the detail, so the Dio needs no catalog of its own and augment takes nothing but the list.
  • source — how does a master row select its detail record? Source::Column maps the master’s Key onto the detail table’s prefix condition. A full filename used as an S3 prefix matches exactly one object — so each fetch narrows the augmenter to a one-row listing, and the lazy expressions run for precisely that file.
  • fetch — Fetch::PerRow: one detail fetch per master row.
  • merge — which detail columns land on the master row? Just rows and latest. contents is deliberately absent: it exists only inside the detail fetch, feeds the derived columns, and is never cached. The megabytes stay out of the Dio; the two numbers stay in.

Reads hydrate

Nothing has fetched anything yet — declaring an augmentation is free. The work happens on read: rows a facade read returns come back hydrated — any of them still missing its augment columns runs the detail fetch first, and the result is written back to the cache as complete.

Dio master S3 listing cache redb facade Vista dio.cached() get / window — you ask hydrated rows — gaps filled first per row with a gap: Key → prefix {rows, latest} merged into the cache, RecordChanged on the bus augmenter Vista — Readings lazy: contents → rows → latest — one download

Not every read, though. The listing must stay what chapter 5 made it — instant — so list_values through the facade returns the cheap rows untouched. Hydration belongs to bounded reads: get_value for one record, fetch_window for a range. The rows you ask for are the rows that pay:

#![allow(unused)]
fn main() {
use std::time::Instant;
use vantage_aws::prelude::*;
use vantage_diorama::Dio;
async fn run(dio: Dio) -> vantage_core::Result<()> {
// The listing stays instant — cheap rows, no downloads.
let start = Instant::now();
let listing = dio.cached().list_values().await?;
println!("{} files (listed in {:?})", listing.len(), start.elapsed());

// Details are paid for by the rows you ask for: a window of ten.
let start = Instant::now();
let window = dio.cached().fetch_window(0, 10).await?;
for (filename, file) in &window.rows {
    let size = file.get("Size").and_then(|v| v.as_str()).unwrap_or("");
    let rows = file.get("rows").and_then(|v| v.as_i64()).unwrap_or(0);
    let latest = file.get("latest").and_then(|v| v.as_str()).unwrap_or("");
    println!("{size:>10} {rows:>8} {latest:>10}  {filename}");
}
println!("{} files detailed in {:?}", window.rows.len(), start.elapsed());
Ok(())
}
}

dio.cached() is the Dio’s facade: a Vista with the master’s schema that reads through the cache. fetch_window returns a page, whose rows keep the window’s order.

Ten downloads is still a wait worth narrating, and the Dio announces it: one DioEvent::Hydrating with the pending count before the first fetch, then a RecordChanged per row as each lands. A dozen lines of event plumbing become the progress display:

#![allow(unused)]
fn main() {
use vantage_diorama::{Dio, DioEvent};
fn run(dio: Dio) {
let mut events = dio.subscribe_events();
tokio::spawn(async move {
    while let Ok(event) = events.recv().await {
        match event {
            DioEvent::Hydrating { pending } => println!("hydrating {pending} files…"),
            DioEvent::RecordChanged { id } => println!("  {id}"),
            _ => {}
        }
    }
});
}
}

First run — chapter 5’s sync, the instant listing, then the window doing its downloads:

fetched 100 files in 215ms
...
1122 files (listed in 16ms)
hydrating 10 files…
  csv/by_station/GM000001153.csv
  csv/by_station/GM000001474.csv
  ...
   4796352   140629   19911231  csv/by_station/GM000001153.csv
   7057976   206874   20260531  csv/by_station/GM000001474.csv
   2978238    87207   19990131  csv/by_station/GM000002277.csv
   ...
10 files detailed in 20.2s

Real data, and readable at a glance: station GM000001474 has 206,874 readings and is still reporting (May 2026); its neighbour GM000001153 went silent at the end of 1991.

Second run — new process, warm cache:

fetched 0 files in 340ms
1122 files (listed in 16ms)
   4796352   140629   19911231  csv/by_station/GM000001153.csv
   ...
10 files detailed in 16ms

No hydrating line at all: those ten rows already carry their augment columns, so the window finds no gaps and never touches the network. Twenty seconds became sixteen milliseconds; the derived numbers live in cache.redb with the rest of the row. Ask for a different window — fetch_window(500, 10) — and only its gaps download. (--invalidate still clears everything, derived columns included — derived data is data.)

What if a download fails?

Nothing is swallowed. Each failing row broadcasts DioEvent::RecordLoadFailed { id, error } on the bus — the same listener printing our progress can print failures beside it. The bounded read that requested the row then returns an error (“augment hydration failed”) rather than handing you a window that quietly misses data. Retry by asking again: rows that did land are already cached and are never re-downloaded. Chapter 7’s reactive views are more forgiving — a failed row is marked RowStatus::LoadFailed while its cheap listing columns stay visible, and the fetch is retried the next time the viewport reaches for it.


What we covered

ConceptWhat it does
with_lazy_expression(name, callback)A column computed in Rust on the returned record; chains in order
s3::get_objectRaw object fetch through the same (unsigned) driver
AugmentationDeclares detail source, row→detail mapping, fetch style, merge
Detail::Fixed / Detail::ResolvedDetail Vista held directly, or looked up by name through a resolver
lens.dio(master).augment(..).build()Declares the augmentations when the Dio is built
dio.cached()The Dio’s facade Vista, read through the cache
Source::Column { from, to }Master column → detail condition (our Key → prefix)
MergeRuleWhich detail columns land on the master row — and which don’t
Bounded facade reads hydrateget_value / fetch_window fill gaps for the rows they return; list_values stays cheap
DioEvent::HydratingFired before a hydration sweep — the cue for progress UI

What’s next

Our CLI asks for one fixed window and exits. A real interface is a standing view: the window should follow the user’s cursor, rows should repaint as details land, and the whole thing should stay current as the bucket changes. The next chapter opens Sceneries over this same Dio — a live terminal UI scrolling the full 122,000-station archive, hydrating the rows the user is looking at.

Going deeper

Augmentation has a reference chapter of its own — batched fetches, the gap rule, demand gating, and the YAML form — at Augmentation.

Scenery — Reactive Views

Chapter 6’s CLI asks for one window of details and exits — the asking is still on the programmer. A real interface is a standing view: the user scrolls, and the window should follow; details land one by one, and the rows should repaint; the bucket changes, and the listing should notice. And it has to hold up at scale — the full GHCN archive is about 122,000 station files, of which a screen shows forty. What a UI actually needs is an ordered row set it can read by index, hydration that follows the user’s attention, and a signal whenever anything on screen changes.

Scenery is that layer. Each Scenery is a reactive view onto a Dio, exposing one access pattern:

All three share one reactivity mechanism: a Generation counter that bumps whenever the view’s state changes. Consumers subscribe() to a watch channel and redraw on each bump — the channel only ever holds the latest generation, so a burst of changes costs one repaint, not one per change.

Why ‘Scenery’?

Point a camera at a vista and you never capture the whole of it. The lens frames a scene: a limited cut, chosen by where you aim. But what it frames is alive — pan, and the scene follows; wait, and the light changes in front of you. A Scenery makes the same trade. It will never hold the entire Vista — one window of rows, one record, one number — yet within that frame everything flows: rows repaint as details land, the count ticks as data arrives, the window follows the user’s attention. Limited, but dynamic — that is the whole design.

A Scenery is not another handle to your data — it hands you no records to keep. It maintains one particular view of the Dio’s copy, precomputed and ready for a widget to read:

DioScenery
PurposeKeep a live local copy of a data segmentPresent that copy to a consumer, one access pattern at a time
ShapeMaster + cache + write queue + event busOrdered rows / one record / one scalar
ReadsYou ask — the facade answers from the cachePrecomputed — row(idx) / value() return instantly
ChangesAnnounces them on the event busReacts to the bus, recomputes, bumps its generation
QueriesThe master’s, carried by the facadeIts own: sort, search, filter, viewport — served locally
How manyOne per data segmentMany per Dio — one per open, rows shared per query
LifecycleLong-lived; owns cache and background tasksOpened by a view, dropped with it
ConsumerLoaders, handlers, scriptsUI widgets, bound through an adapter

That last cell is where this chapter ends up. The consumer — a terminal table here, a desktop grid or a remote client such as a React page elsewhere — drives its scenery in one repeating loop:

  1. The consumer declares what it shows: set_viewport(40..80, 40..80) — nothing more than “these rows are on screen.”
  2. The scenery turns that into work for the Dio: list pages for the spine, detail fetches for the rows in view.
  3. Results land in the Dio’s cache, and the event bus announces each one — RecordChanged, row by row.
  4. The scenery reacts to the bus: it updates the affected slot and bumps its Generation.
  5. The consumer’s watch channel ticks; it re-reads row(idx) for what’s visible and repaints.

The code that runs this loop for one particular consumer is the adapter — the same handful of lines whether it paints a terminal, a desktop toolkit, or a wire to the browser.

Dio cache event bus fetches run through the Lens Scenery rows by index Generation Consumer adapter UI widget or remote client ① set_viewport ② hydrate rows in view ③ RecordChanged ④ Generation tick ⑤ row(idx) — repaint

Back to the inventory

Chapter 6 left the CLI listing one prefix and detailing ten fixed rows. What we actually want from the inventory is the tool you’d reach for daily — and that sets the requirements:

  • the whole archive, not a prefix — all 122,000 station files, scrollable end to end;
  • instant open, even on the first run — an empty table that fills as the listing arrives, never a frozen prompt;
  • details where the user is looking — the rows on screen sprout ROWS and LATEST as their files are read, and the cursor drags that attention with it;
  • standing freshness — the bucket changes, the table follows, plus a grand total of readings across the archive.

Measure chapter 6’s ending against that. It read from the Dio with dio.cached().fetch_window(0, 10) — and it works fine, for a CLI: the read is bounded to ten records, and it resolves once all ten have their details. Nothing blocks — it’s an async wait, the runtime stays free — but the user waits all the same: twenty seconds of silence on a cold cache, and every widening of the window widens the wait. For a better experience we need a more responsive UI:

  1. display results as soon as we have them — don’t hold rows hostage to their details;
  2. show data immediately and fix it reactively as better values arrive;
  3. register what the user is currently looking at, reducing what is fetched and transmitted.

The Scenery implements exactly this:

  • rows are served as soon as the list pass has them, with … standing in for details still pending;
  • every landed detail updates its own row and bumps the Generation — one tick, one repaint;
  • set_viewport(loaded, shown) registers the user’s attention, and only those rows hydrate.

For the consumer, this chapter reaches for ratatui — a terminal-UI library — which Vantage updates in real time through a ready-made adapter. The dataset-ui-adapters crate ships such adapters for several UI frameworks (egui, Slint, GPUI, Cursive, Tauri among them), and they all speak the same scenery loop. Here is ours a few seconds into a cold start: the first two visible rows already hydrated, and the total still waiting for the rest of the archive:

FILENAME                              SIZE        ROWS      LATEST
csv/by_station/ACW00011604.csv        41673       1231      19490814
csv/by_station/ACW00011647.csv        481021      14355     20260710
csv/by_station/AE000041196.csv        1998959     …         …
…
 8000 rows · 2 augmented · total rows 0 · ↑/↓ PgUp/PgDn scroll · r refresh · q quit

learn-6 starts as a copy of learn-5’s data side — files.rs, readings.rs, and the whole Dio setup carry over. Three things change: the prefix widens to the entire archive, the Lens learns to serve a UI, and the printing loop is replaced by a scenery-bound terminal table.

cargo add dataset-ui-adapters --features ratatui

Widening the scope is two touches. The prefix loses its country filter:

#![allow(unused)]
fn main() {
const PREFIX: &str = "csv/by_station/";
}

And the listing pages grow. Every page is one HTTP round-trip: chapter 5 fetched 100 keys at a time, a comfortable twelve requests for its 1,122 files — but at 122,000 files the same setting means 1,220 round-trips of mostly latency. S3 caps a single response at 1,000 keys, so files.rs asks for the cap and the whole archive syncs in about 122 requests:

#![allow(unused)]
fn main() {
use vantage_aws::prelude::*;
use vantage_types::EmptyEntity;
fn table(aws: AwsAccount) -> Table<AwsAccount, EmptyEntity> {
    Table::new("restxml/Contents:s3/GET /{Bucket}?list-type=2", aws)
        .with_id_column("Key")
        // S3's per-response maximum — we're listing the whole station set.
        .with_condition(eq("max-keys", 1000))
}
}

A Lens that serves a UI

Chapter 5’s Lens had one job: warm the cache before anyone reads. A UI inverts the priorities — the screen must appear immediately, on whatever is known so far, and data streams in behind it. Two changes to the builder:

#![allow(unused)]
fn main() {
use std::time::Duration;
use vantage_aws::prelude::*;
use vantage_diorama::{Cache, Lens, Loader, LoaderCx, Refresher};
async fn sync(_cx: &LoaderCx) -> vantage_core::Result<()> {
    Ok(())
}
fn run() -> vantage_core::Result<()> {
let lens = Lens::builder()
    .cache(Cache::redb("cache.redb"))
    // Don't wait for the sync: it warms the cache in the background,
    // while the view lists the bucket itself.
    .loader(Loader::eager(|cx: LoaderCx| async move { sync(&cx).await }).blocking(false))
    // Reconcile against the bucket once a minute: new files appear
    // as un-hydrated rows, vanished ones drop out, changed ones are
    // demoted for re-hydration. The augmentation's refresh pass does
    // the work; the refresher only sets the interval.
    .refresher(Refresher::new(|_cx: LoaderCx| async { Ok(()) }).every(Duration::from_secs(60)))
    .build()
    .context("Failed to build lens")?;
let _ = lens;
Ok(())
}
}

.blocking(false) detaches the sync. An eager start blocks the Dio’s build() by default, which is what chapter 5 wanted: nobody read before the cache was warm. Here build() returns at once and the sync runs as a background task, so the UI’s first frame never waits on 122 requests. The sync itself is chapter 5’s, minus its println! narration (a terminal UI owns the screen now). It writes the cache directly, and writes to the raw cache raise no events, so the Dio does the announcing: when the detached start finishes it sends DioEvent::Seeded, and every open view re-reads.

That covers the cache. What a TableScenery does with it needs a little more context. An augmented Dio drives two-pass loading: a list pass fetches cheap rows and establishes the view’s order (its spine), and a detail pass hydrates individual rows’ augment columns as the viewport reaches them. The detail pass is chapter 6’s augmentation. The list pass picks its source when a view first lists:

  • while the cache isn’t known to hold the whole set (a first run, or a sync still running), it asks the master, page by page;
  • once the Dio holds a whole copy, which the eager start records when it finishes, it reads the cache instead: one local read, zero network.

So on a cold start the table lists the bucket itself while the sync runs, and on Seeded it re-lists from the warm cache. Nothing in the application chooses between the two; the Dio knows which answer it can trust.

The refresher completes the freshness story. Once a minute the Dio reconciles its cache against the master (this does walk the listing: 122 requests, in the background). New files appear as un-hydrated rows, deleted ones drop out, and a file whose listing entry changed keeps showing its old numbers but is marked stale; the next time it’s on screen, the detail pass re-fetches it. Chapter 5 needed --invalidate to notice deletions; a standing view just notices. The refresher’s own callback does nothing here: on an augmented Dio the reconcile is the augmentation’s refresh pass, and Refresher::new(..).every(..) only sets how often it runs.

Why not always list from the cache?

Because an empty cache and an empty bucket look the same. A view that read the cache during a first run would show zero files and call the listing complete, then jump to 122,000 rows minutes later. A half-written cache is worse: it reads as a complete listing of whichever files the sync reached first. The whole-copy record is how the Dio tells “the cache is the listing” from “the cache is what we have so far”, and until it can say the first, the master answers. The cost is the master walk on a cold start, and that walk is the same one the sync is making anyway.

Creating the reactive UI

Our UI needs two things from the Dio: the rows, and a grand total. Each one is a scenery. Let’s open them one at a time.

The table

#![allow(unused)]
fn main() {
use vantage_diorama::Dio;
async fn run(dio: Dio) -> vantage_core::Result<()> {
// One list page covers the whole cached listing (~122k station files);
// the viewport's detail pass hydrates whatever is on screen first.
let scenery = dio.table_scenery().page_size(200_000).open().await?;
let _ = scenery;
Ok(())
}
}

.open() builds the scenery’s rows (or joins the rows another open of the same query already built), spawns its reactor (the task that watches the Dio’s event bus), and hands back Arc<dyn TableScenery>. What you get is deliberately small and synchronous:

  • row_count() and row(idx), an EnrichedRecord: the record plus a per-row status;
  • set_viewport(loaded, shown), more on this in a moment;
  • subscribe(), the generation channel from the start of the chapter;
  • refresh_source(), which asks the Dio to re-read its master; the view reloads as the fresh rows land.

The builder can also chain .order(column, SortDirection::Ascending), .search(text) and .filter(condition), every one of them served locally on a backend that can do none of it. We don’t use them here; the table shows the archive as-is.

#![allow(unused)]
fn main() {
use vantage_diorama::Dio;
use vantage_vista::SortDirection;
async fn run(dio: Dio) -> vantage_core::Result<()> {
// The same table, from the end of the alphabet.
let reversed = dio
    .table_scenery()
    .order("Key", SortDirection::Descending)
    .open()
    .await?;
let _ = reversed;
Ok(())
}
}

A UI rarely commits to one order at open time, though, and it doesn’t have to reopen. The handle mutates in place: set_order(Some((column, direction))) and set_search(Some(text)), bound to a key or a header click, re-point the view at the ordered index for the new query, swap the visible rows in one atomic step (the grid never blanks mid-reorder), and restart hydration for whatever is on screen. Sorting back reuses the already-built index: zero list calls. Runtime filters change the same way, through set_filters(conditions), which takes a Vec<Condition> and replaces the previous set (an empty vec clears it).

One thing probably caught your eye: page_size(200_000)?! That’s the list pass: how many spine rows one list page asks for. It’s a separate axis from the viewport, which drives only the detail pass over rows the spine already holds. Left at the default, the spine would stop at the first hundred files and nothing in this UI asks for page two. Set past the archive size, one list page builds the entire spine. Once the Dio holds its whole copy, that page is a single read of the local cache.

The viewport

The viewport is the scenery’s answer to “which rows is the user actually looking at?” — a plain range of row indexes, like 40..80. It’s declared through set_viewport(loaded, shown), the method on the handle we just opened. loaded is the range to load and hydrate; shown is the range actually on screen, which feeds the view’s “showing a–b” statistics and nothing else. A grid usually passes the same range twice. You rarely call it yourself: whoever renders the table calls it as the user scrolls. Later in this chapter, that’s the ratatui adapter’s job.

It is the load-bearing call, because the viewport drives hydration. The rows a consumer declares as its viewport are the rows the detail pass works on. Framework code calls this the demand gate: no row is detailed unless some live view demands it. Everything else stays a cheap list row — and rows never observed never cost a download.

Recognize it? This is chapter 6’s bounded read with the asking automated: there the programmer chose the window; here the viewport is the window, and it moves with the user.

The grand total

#![allow(unused)]
fn main() {
use vantage_diorama::Dio;
use vantage_diorama::aggregate::Sum;
async fn run(dio: Dio) -> vantage_core::Result<()> {
// Grand total of the ROWS column. `rows` is augmented, so each
// computation first hydrates every cached file: the total appears once
// they have all landed, then follows the bucket's changes.
let totals = dio.value_scenery(Sum::of("rows")).open().await?;
let _ = totals;
Ok(())
}
}

A value scenery runs one aggregation (here Sum) over the rows the Dio admits from its cache. Nothing is asked of the master. It recomputes when the Dio announces a change, and bumps its generation only when the value actually moved. open() computes the first value before it returns.

Hold on: how can it sum rows when most files aren’t augmented yet? It doesn’t sum the gaps. The aggregation declares which columns it reads, and when one of them is an augmented column, the Dio hydrates the rows missing it before the aggregation sees them. A sum that skipped un-hydrated rows would show a number that looks like a total and isn’t one. So the value is always a total over every cached file, and on a cold cache that means it waits for every file to download.

That’s an expensive number on 122,000 files, and it’s meant to be: a total of readings genuinely needs every file read once. The cost is paid once, because the hydrated columns stay in cache.redb; a warm restart totals from the cache. When you want a cheap number instead, aggregate something the listing already has. Count::rows() reads no column at all, so it counts the cached rows without hydrating anything.

(There is a third kind, RecordScenery: dio.record_scenery(id).open(), one record under the same contract: read it, subscribe, redraw on bump. A detail pane beside the table would use one; our table doesn’t need it.)

Open freely, drop when done

Sceneries are inexpensive. A page opens as many as it needs (a grid, a total, a detail pane), and every open() returns a view of its own: its own viewport, its own order, search and filters. Views of the same query still read one set of rows underneath, so two grids over one query make one request and hydrate each file once. The other half of that contract: release them when the page closes. Dropping the handle stops the view’s tasks and withdraws its demand, so a closed page stops pulling data.

Why does each open get its own view?

Because sharing the view meant sharing its setters. When identical opens returned one shared instance, a quick-search typed into one grid reshaped every other grid of the same query: set_search on one widget filtered its neighbour’s rows too, and two remote clients asking the same question couldn’t scroll to different pages. Sharing was right for the rows and wrong for everything the user controls.

So the split runs along that line. The rows (the per-query index, the cached records, the hydration already paid for) are shared between every view of one query. The view (viewport, shown range, order, search, filters) belongs to whoever opened it. A setter moves only its own view to the rows of its new query, joining another view’s rows when one already answers it. Chapter 8 leans on this: every HTTP connection opens its own view, and the downloads are still paid once.

Binding to a terminal

What remains is rendering — and none of it is specific to this app. Scrolling a virtualized table, forwarding the visible range as the viewport, redrawing on generation bumps: that’s a reusable binding, and dataset-ui-adapters ships it for ratatui in its ratatui_dio module. The entire UI:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use dataset_ui_adapters::ratatui_dio;
use vantage_aws::prelude::*;
use vantage_diorama::{TableScenery, ValueScenery};
async fn ui(
    scenery: Arc<dyn TableScenery>,
    totals: Arc<dyn ValueScenery>,
) -> vantage_core::Result<()> {
    ratatui_dio::SceneryTable::new(scenery)
        .with_column("FILENAME", "Key", 0)
        .with_column("SIZE", "Size", 10)
        .with_column("ROWS", "rows", 8)
        .with_column("LATEST", "latest", 10)
        .with_status_value("total rows", totals)
        .run()
        .await
        .context("terminal UI failed")
}
}

Line by line:

  • ratatui_dio::SceneryTable::new(scenery) — hands the scenery to the adapter. The full module path marks the boundary: everything before this line was framework, everything after is the ratatui binding.
  • .with_column(header, field, width) — one table column: the header text, the record field it reads, and a width in characters (0 = flexible fill). A field the row doesn’t have yet renders as … — which is exactly how un-hydrated rows look.
  • .with_status_value(label, scenery) — pins a ValueScenery into the status bar; it repaints whenever that scenery’s generation bumps, so total rows follows the Dio.
  • .run() — takes over the terminal until q. It draws only the visible rows (at 122,000 you don’t build widgets for the rest), keeps the scenery’s viewport on a ten-row band around the cursor — details load for the record the user is on and its neighbours, not the whole screen. Band-not-screen is the adapter’s policy, not the scenery’s: each detail fetch here is a multi-second download, and hydrating all forty visible rows would waste most of that work every time the user scrolls on. It listens to every subscribed generation for repaints, and renders the status bar: row count, an augmented counter (how many rows on hand are fully hydrated), your pinned values, and the key legend. r calls the scenery’s refresh_source(): the Dio’s reconcile on demand, ahead of the timer.

Running it

cargo run on a cold cache is the whole system visible at once. The table appears instantly, empty, while the view lists the bucket and the background sync copies it; when the sync lands, the view re-lists from the cache and the row counter passes 122,000. Meanwhile the rows around the cursor sprout numbers, one file at a time, as the detail pass works through the band (… becoming 14355 20260710), and the augmented counter ticks upward with each one. Move the cursor, and the band follows; jump to End, and the last stations of the alphabet get their turn. total rows stays at its first value, zero over an empty cache, until the total has every file it needs. Quit, run again: everything already observed is back instantly, warm from cache.redb, and hydration resumes wherever you look next.

Notice what the application never wrote: a render loop, a fetch, an event match. It reads scenery.row(idx) and totals.value() through a binding that repaints when a generation channel says so. Nothing more passes between data and display — and it’s precisely how a real UI binds to Vantage.


What we covered

ConceptWhat it does
TableSceneryOrdered rows by index; sort/search/filter served locally
ValueSceneryOne aggregation over the Dio’s cached rows; hydrates augmented columns it reads
RecordSceneryOne record by id, same subscribe/redraw contract
Generation / subscribe()Latest-value watch channel — one bump, one repaint
One view per openOwn viewport, order, search and filters; rows shared per query
Two-pass loadingList pass builds the spine; detail pass hydrates rows in view
Whole copyOnce the cache holds the whole set, the list pass reads it, not the master
set_viewport(loaded, shown)Declares what loads, and thereby what hydrates; shown is what’s on screen
set_order / set_search / set_filtersReshape one view in place, without reopening
Loader::eager(..).blocking(false)UI first: build() returns immediately, the sync runs behind it
Refresher::new(..).every(..)Standing freshness: reconcile on a timer; refresh_source() on demand
ratatui_dio::SceneryTableThe ratatui binding: virtualized rows, viewport, status bar, keys

What’s next

One terminal, one viewport. A web server is the same picture multiplied: every connected browser is its own standing view, each on a different page, all expecting details to stream in — and none of them should ever download a file another view already paid for. The final chapter puts this Dio behind chapter 3’s Axum server: kubernetes-style GET + watch endpoints, a React frontend, and a scheduler that serves every concurrent viewer fairly.

Serving Scenery — Axum & Watch Streams

Chapter 7’s client of the Dio was a terminal driving two sceneries — one showing the rows, the other aggregating a running sum. Sceneries scale well beyond that: the framework can drive many active viewers at once, and nothing requires them to live in the same process. This chapter sends them across the network — every connected browser tab becomes its own viewer, looking at its own page of the archive, with details streaming in as they land.

The traditional plumbing for live updates over HTTP is a WebSocket, server-sent events, or the style the Kubernetes API made standard — the watch: a plain HTTP response that never ends, delivering one JSON line per change over chunked transfer encoding. We take the Kubernetes shape: every endpoint answers a plain GET with a snapshot, and the same URL with ?watch=true keeps the connection open and streams changes for as long as the client stays. A watch is not a polling loop — it is a Scenery on the far end of an HTTP connection.

This chapter re-uses chapter 3’s Axum server, wired to the Dio through a new adapter crate — and finishes with a small React app browsing the bucket the way the AWS S3 Explorer does. Worth pausing on what the frontend gets for free: the page renders instantly from the cache, cells fill themselves in as augmentation lands, and every open tab stays current — the responsiveness chapter 7 built for the terminal, now delivered over a wire.

One flight per row

Before any of that can be safe, the framework needs an answer to a question chapter 7 never had to ask: what happens when several views drive augmentation at once?

With one terminal and one viewport, it never mattered who ran the detail fetches. If each view ran its own, inline, for its own viewport, two browser tabs watching overlapping pages would each download the same CSVs; two tabs on disjoint pages would race each other with no ordering at all; and a tab opened mid-download would have no way to say “me next”.

So the fetches are the Dio’s job, not the view’s. Every consumer that wants rows hydrated — a scenery’s viewport, a facade read blocking on its window — registers a queue with the Dio’s augment scheduler and enqueues row ids into it. A worker pool drains the queues:

  • Round-robin across consumers. The worker takes one id from each queue in turn, so two views with disjoint pages interleave — neither starves behind the other’s backlog.
  • One flight per row. An id already being fetched is never fetched again; every queue waiting on it is notified by the same completion. Overlapping views cost one download, not one per view.
  • Closing a view withdraws its work. The queue registration is owned by the view; when its handle drops, its queued ids vanish. A fetch already in the air completes and lands in the cache — paid-for work is kept.
  • Workers are configurable. The default single worker keeps fetch order deterministic; Lens::builder().tuning(Tuning::default().with_augment_workers(4)) hydrates four rows at a time when the detail source can take it.

Nothing in the example code asks for this: the scheduler sits under the same set_viewport and fetch_window calls the previous chapters used. What it buys is what you can safely do here: open as many sceneries as you have HTTP connections.

Every open is its own view (chapter 7, “Why does each open get its own view?”). Two identical opens share one set of rows, which is right for two widgets showing the same grid, and each still keeps its own viewport, which is right for two clients watching different pages of the same query. Every view counts in the demand union — the Dio hydrates the union of the columns every open scenery demands — and is released on drop.

The adapter: DioRouter

Everything HTTP lives in a new adapter crate, vantage-api-adapters — the server-side sibling of the dataset-ui-adapters crate that provided chapter 7’s ratatui binding. Where a UI adapter binds a scenery to a widget, an API adapter binds it to a route:

#![allow(unused)]
fn main() {
use vantage_api_adapters::axum_dio::DioRouter;
use vantage_diorama::Dio;
fn api(dio: Dio) -> axum::Router {

let api = DioRouter::new(dio.clone())
    .with_column("filename", "Key")
    .with_column("size", "Size")
    .with_column("rows", "rows")
    .with_column("latest", "latest")
    .with_page_size(50)
    .into_router();
api
}
}

Each .with_column(name, field) maps a record field to a JSON key — and doubles as the watch sceneries’ demand: naming rows and latest here is exactly what makes a watch connection drive their hydration (chapter 7’s demand gate, now per connection). into_router() yields a plain axum::Router with two routes, each in two modes:

RequestAnswered byCost
GET /?offset=&limit=A window over the cacheInstant; never fetches
GET /?watch=true&…A TableScenery view of its ownStreams while connected
GET /{id}A bounded facade read (get_value)Hydrates that one row; cached
GET /{id}?watch=trueA RecordSceneryStreams that record’s changes

The split embodies the demand philosophy: a plain GET is not a standing view, so it serves the Dio’s current knowledge instantly — augmented columns appear once some view has paid for them. A watch is the standing view: it declares its page as the viewport, hydration follows, and every change streams back as a Kubernetes-style NDJSON line:

{"type":"ADDED","object":{"index":3,"filename":"…","size":"4880965","rows":null,"latest":null}}
{"type":"MODIFIED","object":{"index":3,"filename":"…","size":"4880965","rows":143676,"latest":"20260531"}}

The stream diffs against what it already sent — a generation bump that changed nothing on this page costs nothing on the wire. And the scenery is owned by the response stream: when the client disconnects, the stream drops, the scenery’s guard aborts its tasks, its queued fetches are withdrawn, and its demand drains. A closed tab stops pulling — the same lifecycle rule as chapter 7’s closing grid, now enforced by HTTP.

One honesty note against the Kubernetes original: there is no resume token (no resourceVersion). A client that reconnects gets a fresh snapshot of ADDED lines and a new watch — not a replay of what it missed while away.

The server

learn-7 is learn-6 with the terminal swapped for the router — and the prefix narrowed back to chapter 5’s GM (1,122 files): a server should boot in seconds, and everything below works the same way on the full archive. The data plumbing is otherwise identical — files.rs, the augmenter, the Lens — with two differences. First, the cache is opened by hand, because we want a second table in the same redb file:

use std::sync::Arc;
use vantage_aws::prelude::*;
use vantage_diorama::{CacheBackend, CacheTable, RedbCache};
struct ContentsCache;
impl ContentsCache {
    fn new(_table: Arc<dyn CacheTable>) -> Arc<Self> {
        Arc::new(Self)
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let cache = Arc::new(RedbCache::open("cache.redb").context("Failed to open cache")?);
let contents = ContentsCache::new(cache.open_table("contents").await?);
let _ = contents;
Ok(())
}

A Lens normally opens cache.redb itself (Cache::redb(path)); Cache::backend(cache) hands it ours instead. The Dio claims one named table for the listing, and open_table("contents") claims another for the cache we’re about to meet.

Second, the eager start stays blocking. That’s Loader::eager’s default; learn-6 added .blocking(false) to open its UI on an empty table. A server should answer its first request from a warm cache, so the Dio’s build() runs the chapter-5 sync to completion before axum::serve ever binds the port:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::time::Duration;
use vantage_aws::prelude::*;
use vantage_diorama::{Cache, Lens, Loader, LoaderCx, RedbCache, Refresher};
async fn sync(_cx: &LoaderCx) -> vantage_core::Result<()> {
    Ok(())
}
fn run(cache: Arc<RedbCache>) -> vantage_core::Result<()> {
let lens = Lens::builder()
    .cache(Cache::backend(cache))
    // Pre-fetch: the eager start blocks the Dio's build (the default),
    // so the server starts answering only once the listing is cached.
    .loader(Loader::eager(|cx: LoaderCx| async move { sync(&cx).await }))
    .refresher(Refresher::new(|_cx: LoaderCx| async { Ok(()) }).every(Duration::from_secs(60)))
    .build()
    .context("Failed to build lens")?;
let _ = lens;
Ok(())
}
}

A cold start lists the 1,122 files in two requests before the port opens; a restart resumes from redb and confirms with a single request:

fetched 1000 files in 831.454208ms
fetched 122 files in 194.010875ms
serving on http://localhost:3007

The rest of main.rs is mounting:

#![allow(unused)]
fn main() {
use tower_http::services::ServeDir;
use vantage_aws::prelude::*;
async fn serve(api: axum::Router) -> vantage_core::Result<()> {
let app = axum::Router::new()
    .nest("/api/files", api)
    .fallback_service(ServeDir::new("frontend/dist"));

let listener = tokio::net::TcpListener::bind("0.0.0.0:3007")
    .await
    .context("Failed to bind :3007")?;
println!("serving on http://localhost:3007");
axum::serve(listener, app).await.context("server failed")
}
}

Concurrency needs no further code. Dio is a cheap clone over shared state, redb reads run concurrently, and no lock is held across a network await anywhere in the read path — every simultaneous request proceeds on its own.

Neither does authentication — because there is none. The endpoints are anonymous, as in chapter 3: wrap the router in your tower auth middleware before exposing it; Vantage deliberately stays out of authn.

A cache that earns its keep

The detail endpoint returns everything about one file, augment columns included — which means the first GET /{id} downloads the CSV. Chapter 6 deliberately kept contents out of the cache: at 122,000 stations, storing every casually-viewed file would grow cache.redb by gigabytes. But a server sees repeat traffic — the same popular stations, again and again — and re-downloading those is just as wasteful.

ContentsCache splits the difference with lazy admission: a file must be requested twice before its contents earn a slot. It is application code, not framework — the framework contributes open_table (a named key-value table in the Dio’s own redb file); the admission policy is entirely ours to write.

#![allow(unused)]
fn main() {
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use vantage_diorama::CacheTable;
pub struct ContentsCache {
    table: Arc<dyn CacheTable>,
    /// Keys downloaded at least once — the admission ledger.
    seen: Mutex<HashSet<String>>,
}
}

Its one operation is cache-first with the admission rule on the miss path:

#![allow(unused)]
fn main() {
use std::collections::HashSet;
use std::future::Future;
use std::sync::{Arc, Mutex};
use vantage_aws::prelude::{CborValueExt, VantageResult};
use vantage_diorama::CacheTable;
use vantage_vista::prelude::*;
pub struct ContentsCache {
    table: Arc<dyn CacheTable>,
    seen: Mutex<HashSet<String>>,
}
impl ContentsCache {
pub async fn get_or_fetch<F, Fut>(&self, key: &str, fetch: F) -> VantageResult<String>
where
    F: FnOnce() -> Fut,
    Fut: Future<Output = VantageResult<String>>,
{
    if let Some(row) = self.table.get_value(key).await?
        && let Some(body) = row.get("contents").and_then(|v| v.as_str())
    {
        return Ok(body.to_string());
    }
    let body = fetch().await?;
    let repeat = !self.seen.lock().unwrap().insert(key.to_string());
    if repeat {
        let record = [("contents".to_string(), CborValue::from(body.clone()))]
            .into_iter()
            .collect();
        self.table.insert_value(key, &record).await?;
    }
    Ok(body)
}
}
}

A hit is served from redb. A miss runs fetch either way, but only a key already in seen (a repeat request) gets its body written to the contents table. One-off requests leave nothing behind but their key. (The ledger itself is never trimmed — bare keys, fine at 122,000 files; a bigger keyspace would want an eviction rule, and the policy being application code makes that your call.) The augmenter’s contents lazy expression routes its download through it, and everything downstream — rows, latest, the detail endpoint — is unchanged:

#![allow(unused)]
fn main() {
use std::future::Future;
use std::sync::Arc;
use vantage_aws::prelude::*;
use vantage_types::EmptyEntity;
pub struct ContentsCache;
impl ContentsCache {
    pub async fn get_or_fetch<F, Fut>(&self, _key: &str, fetch: F) -> VantageResult<String>
    where
        F: FnOnce() -> Fut,
        Fut: Future<Output = VantageResult<String>>,
    {
        fetch().await
    }
}
fn table(aws: AwsAccount, bucket: String, contents: Arc<ContentsCache>) -> Table<AwsAccount, EmptyEntity> {
Table::new("restxml/Contents:s3/GET /{Bucket}?list-type=2", aws.clone())
    .with_id_column("Key")
.with_lazy_expression("contents", move |row| {
    let aws = aws.clone();
    let bucket = bucket.clone();
    let contents = contents.clone();
    let key = row
        .get("Key")
        .and_then(|v| v.as_str())
        .unwrap_or_default()
        .to_string();
    async move {
        let fetch_key = key.clone();
        let body = contents
            .get_or_fetch(&key, || async move {
                s3::get_object(&aws, &bucket, &fetch_key).await
            })
            .await?;
        Ok(body.into())
    }
})
}
}

Watching it work

A plain GET is the cache, instantly — rows and latest are null because nothing has demanded them yet:

$ curl 'localhost:3007/api/files?offset=0&limit=3'
{"total":1122,"offset":0,"limit":3,"items":[
  {"index":0,"filename":"csv/by_station/GM000001153.csv","size":"4796352","rows":null,"latest":null},
  …

A detail GET blocks on the bounded facade read — 1.8 s to download and digest 4.8 MB — and the same request again is a 21 ms cache hit:

$ curl 'localhost:3007/api/files/csv%2Fby_station%2FGM000001153.csv'
{"Key":"csv/by_station/GM000001153.csv", …"Size":"4796352", …"rows":140629,"latest":"19911231"}

Now the standing view. curl -N holds the connection; the page arrives as ADDED rows, then each CSV lands as a MODIFIED line the moment its download completes:

$ curl -N 'localhost:3007/api/files?offset=3&limit=3&watch=true'
{"type":"ADDED","object":{"index":3,"filename":"csv/by_station/GM000002288.csv","size":"4880965","rows":null,"latest":null}}
{"type":"ADDED","object":{"index":4,"filename":"csv/by_station/GM000002698.csv","size":"6351302","rows":null,"latest":null}}
{"type":"ADDED","object":{"index":5,"filename":"csv/by_station/GM000002716.csv","size":"5042384","rows":null,"latest":null}}
{"type":"MODIFIED","object":{"index":3,…"rows":143676,"latest":"20260531"}}
{"type":"MODIFIED","object":{"index":4,…"rows":186088,"latest":"20081031"}}
{"type":"MODIFIED","object":{"index":5,…"rows":147011,"latest":"20250824"}}

And the scheduler is visible from outside. Two watches on disjoint pages, side by side: their MODIFIED lines alternate — B, A, B, A — the single worker taking one row from each view’s queue in turn:

19:35:35 [B] {"type":"MODIFIED","object":{"index":42,…"rows":201382,…}}
19:35:36 [A] {"type":"MODIFIED","object":{"index":40,…"rows":77595,…}}
19:35:38 [B] {"type":"MODIFIED","object":{"index":43,…"rows":132579,…}}
19:35:40 [A] {"type":"MODIFIED","object":{"index":41,…"rows":128335,…}}

Two watches on the same page: each row’s MODIFIED reaches both connections in the same second — one download, fanned out by the event bus to every scenery holding the row:

19:36:18 [B] {"type":"MODIFIED","object":{"index":50,…"rows":135802,…}}
19:36:18 [A] {"type":"MODIFIED","object":{"index":50,…"rows":135802,…}}

Run the first GET again afterwards: the page the watches paid for now answers with its numbers filled in. Current knowledge grew.

A tiny React client

The frontend is one component. It fetches its page with watch=true and reads the NDJSON stream directly off fetch — no client library, twenty lines:

const res = await fetch(`/api/files?offset=${offset}&limit=${LIMIT}&watch=true`, {
  signal: ctl.signal,
})
const reader = res.body.getReader()
// …accumulate chunks, split on '\n'…
const event = JSON.parse(line)
setRows(rs => ({ ...rs, [event.object.index]: event.object }))

ADDED and MODIFIED merge the same way — the object lands at its index — so the table renders the listing immediately and the Rows / Latest cells flip from … to numbers as the watch delivers them, exactly like chapter 7’s terminal cells did. Paging aborts the fetch via the AbortController, which closes the connection, which drops the scenery server-side. Clicking a row hits the detail endpoint into a side panel. npm run build in learn-7/frontend emits dist/, the ServeDir fallback serves it, and http://localhost:3007 is a bucket explorer: title, file count, Prev/Next, and a table filling itself in.


What we covered

ConceptWhat it does
Augment schedulerDio-owned detail fetches: round-robin across views, one flight per row
Tuning::with_augment_workers(n)Worker pool size — 1 (deterministic order) by default
One view per openShared rows, own viewport — one standing view per HTTP connection
DioRouter (vantage-api-adapters).with_column() + .with_page_size() → an axum router; columns double as demand
GET vs ?watch=trueSnapshot of current knowledge vs a Scenery streaming NDJSON events
ADDED / MODIFIED linesKubernetes-style watch events, diffed per row per connection
Connection drop = scenery dropQueued fetches withdrawn, demand drained — a closed tab stops pulling
Blocking Loader::eagerPre-fetch: the port opens only once the listing is cached
Cache::backend + open_tableOne redb file, many named tables — the Dio’s and our own
ContentsCacheLazy admission: downloaded once — remembered; twice — cached

The whole climb

Eight chapters ago this book started with one SQL query; it ends with a fair-scheduled, cache-backed watch API feeding a React frontend — and every layer still speaks through the one below it.

From here, the reference half of the book takes over — Augmentation for batched fetches and demand gating, Config-Driven Vistas for defining all of this from YAML, Model-Driven Architecture for structuring a real application, and Adding a New Persistence when you’re ready to extend the framework itself.

Dio & Lens over SQL

This path builds a live, cached, watchable view in front of a SQL database you own. We start from a Vista over a SQLite product table: a runtime handle that can already sort, search, paginate and write. That is a capable backend, which raises a fair question. If the database already does all that, what is a caching-and-events layer for?

For the two things a capable backend doesn’t hand you on its own. A Dio keeps a live local copy of a data segment, so reads are answered from memory instead of round-trips, and it announces every change on an event bus, so a view can watch the data instead of re-polling it. A slow, read-only API needs a third thing from Diorama: the injection of capabilities the backend lacks, like sorting a listing it cannot sort. A SQL database needs none of that. Here the live cache and the change stream are the whole story.

This path assumes the Introduction

We build on two ideas from the Introduction: a Table, your typed model, and a Vista, the runtime handle a datasource’s factory wraps that model in. If either is new, read the Introduction first; this path picks up where it ends.

We’ll build a bar’s shelf: a handful of products, each with a stock count, behind a cached, watchable API. A background “till” sells items so the data moves. This chapter builds the cache; the next serves it as a live watch; the one after moves the whole thing to PostgreSQL without touching the model.

The full crate is learn-8.

The model

The Introduction’s Product, with one column added: stock, the units on the shelf.

#![allow(unused)]
fn main() {
// src/product.rs
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
}

impl Product {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
    }
}
}

Nothing here knows it is about to be cached, watched, or served from Postgres. The entity is written once, and the layers above it decide what to do with it.

The master

The master is the product table as a Vista, built the way the Introduction built one: hand the table to its datasource’s Vista factory.

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
}

impl Product {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
const DB_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/products.db");

let url = format!("sqlite:{DB_PATH}?mode=rwc");
let db = SqliteDB::connect(&url).await.context("connect sqlite")?;
let master = db.vista_factory().from_table(Product::table(db.clone()))?;
Ok(())
}

The database is a file beside the crate, and mode=rwc creates it on first run. A file rather than sqlite::memory: is deliberate: the next chapter edits it from another terminal with the sqlite3 CLI and watches the change arrive.

Ask this Vista what it can do and its capabilities cover everything a listing needs: Order, Search, FilterOps and Window, plus Insert, Update and Delete. Contrast that with the S3 path, where the Vista admits it cannot sort or search. There is no gap to fill here, so this path injects no capabilities. What we still want is the cache.

The lens: eager and reactive

The S3 path’s loader walks the bucket a page at a time, resuming from a cursor, and writes each page into a redb file. A SQL table needs none of that: it hands back the whole set in one call. So this path uses an eager loader that lists the table once, and a refresher that lists it again on a timer.

use std::time::Duration;

use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, Loader, LoaderCx, Refresher};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
}

impl Product {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db?mode=rwc").await.context("connect sqlite")?;
let master = db.vista_factory().from_table(Product::table(db.clone()))?;
let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(reload))
    .refresher(Refresher::new(reload).every(Duration::from_secs(1)))
    .build()
    .context("build lens")?;
let dio = lens.dio(master).build().await?;
Ok(())
}

/// Make the cache hold exactly the master's rows.
async fn reload(cx: LoaderCx) -> VantageResult<()> {
    let rows = cx.master().list_values().await?;
    cx.reconcile(rows).await
}

Three settings carry the behaviour:

  • Cache::memory(): the cache is ephemeral, and a restart re-reads the table. The S3 path persists its cache with Cache::redb("cache.redb") because re-listing a remote bucket is expensive. Re-reading a local table is not.
  • Loader::eager(reload): before the server answers a request, list_values() pulls the whole table into the cache. lens.dio(master).build() waits for it by default, so the first read is already warm.
  • Refresher::new(reload).every(..): once a second, reload runs again. This is what makes the copy live: a sale the till commits, or an edit typed into sqlite3, lands on the next tick.

The loader and the refresher share one function because both answer the same question: what should the cache hold? LoaderCx::reconcile takes the master’s answer and makes the cache hold exactly that set. It writes the rows that differ, deletes the cached rows the master no longer returns (a product that sold out), leaves unchanged rows alone, and publishes one DatasetChanged for watchers.

Why reconcile instead of clearing and reloading?

The obvious refresh clears the cache and inserts the fresh rows. It has two problems.

The first is the gap. Between the clear and the insert the cache is empty, and a reader that lands in that gap sees an empty shelf. Reconcile never empties the cache: each row is kept, rewritten, or removed.

The second is writes in flight. A Dio takes writes optimistically: the cache shows the new value at once, and the master catches up. A refresh that listed the master just before that write landed would put the old value back. Reconcile skips any row with a write in flight, and any row whose write settled after this refresh began listing, so a refresh can’t undo a write it raced.

The cost is a comparison per row on every tick. For a shelf of products that is noise. For a table of a million rows, a one-second full reconcile is the wrong tool, and the PostgreSQL chapters replace the timer with push.

Reads come from the cache

From here on, reads don’t touch SQLite. Everything a consumer asks of the Dio, a listing, a page or a single record, is answered from the in-memory cache the eager loader warmed. The database is consulted by the once-a-second reconcile and by nothing else. The next chapter puts a DioRouter over this Dio, and a plain GET returns the shelf without a query reaching the table.

The backend stays the source of truth, but the handle you read is a local, live mirror of it.

What a capable backend changes, and what it doesn’t

The lens changed: a one-call list instead of a cursor walk, memory instead of redb, a refresher where S3 had none, and no augmentation because a SQL row already carries its columns. What didn’t change is everything above lens.dio(master).build(): the Dio, its event bus, and the sceneries and watch server the next chapter opens over it are the same code the S3 path uses. The reactive stack doesn’t know which backend it sits on.

Checklist

  • The model is a plain Table with an #[entity] struct; nothing in it mentions caching.
  • The master is db.vista_factory().from_table(..), and a SQL Vista needs no capability injected.
  • One reload function lists the master and hands the rows to cx.reconcile; the loader and the refresher both call it.
  • Cache::memory() because re-reading a local table is cheap; Cache::redb(..) when it isn’t.
  • Refresher::new(..).every(..) is what brings outside writes in, until the PostgreSQL chapters switch to push.

Next: serve this Dio as a Kubernetes-style watch, and turn on the till so the shelf moves while you look at it.

Serving & Watching Live Stock

The last chapter left a Dio holding a live copy of the product table. Now we put it behind HTTP, using the same adapter the S3 path uses. DioRouter doesn’t know or care that its Dio is backed by SQL instead of S3: it binds a Dio to a pair of routes, GET and watch, and that is all it needs.

use vantage_api_adapters::axum_dio::DioRouter;
use std::time::Duration;

use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, Loader, LoaderCx, Refresher};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
}

impl Product {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
    }
}

async fn reload(cx: LoaderCx) -> VantageResult<()> {
    let rows = cx.master().list_values().await?;
    cx.reconcile(rows).await
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db?mode=rwc").await.context("connect sqlite")?;
let master = db.vista_factory().from_table(Product::table(db.clone()))?;
let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(reload))
    .refresher(Refresher::new(reload).every(Duration::from_secs(1)))
    .build()
    .context("build lens")?;
let dio = lens.dio(master).build().await?;
let listener = tokio::net::TcpListener::bind("0.0.0.0:3008").await.context("bind :3008")?;

let api = DioRouter::new(dio.clone())
    .with_column("name", "name")
    .with_column("price", "price")
    .with_column("stock", "stock")
    .with_page_size(50)
    .into_router();

let app = axum::Router::new().nest("/api/products", api);
axum::serve(listener, app).await.context("server failed")?;
Ok(())
}

Each with_column maps a JSON key to a record field. The columns double as the demand of every watch the router opens, which matters on an augmented Dio and is harmless here.

A plain GET is the cache, answered at once, never a query against SQLite:

$ curl 'localhost:3008/api/products?offset=0&limit=10'
{"total":5,"offset":0,"limit":10,"items":[
  {"index":0,"name":"Espresso","price":280,"stock":12},
  {"index":1,"name":"Cappuccino","price":340,"stock":8},
  {"index":2,"name":"Cold Brew","price":420,"stock":5},
  {"index":3,"name":"Croissant","price":260,"stock":6},
  {"index":4,"name":"Cheesecake","price":520,"stock":2}]}

A till to make it move

A cache over a table nobody writes is a slow constant. To see the reactive stack work, the data has to change, so the app runs a till: a background task that sells one unit of a random product every 800ms, removes anything that sells out, and every tenth tick takes a delivery. These are ordinary SQL writes, sent with sqlx::query(..).execute(db.pool()) on the same pool the master reads from (src/sim.rs):

-- sell one unit of a random product that still has stock
UPDATE product SET stock = stock - 1
 WHERE id = (SELECT id FROM product WHERE stock > 0 ORDER BY RANDOM() LIMIT 1);

-- anything that hit zero leaves the shelf
DELETE FROM product WHERE stock <= 0;

The server never calls the till and the till never calls the server. They meet only through the database and the once-a-second reconcile, so any writer, in any process, moves the shelf. The sqlite3 CLI will do as well, as we’ll see.

Why not have the till write through the Dio?

A Dio is writable, and a write through it shows up in every watch at once instead of on the next tick. For an app that owns all its writes, that is the better path.

This app doesn’t own all its writes. A delivery script, a back-office tool, or a person with sqlite3 changes the table behind the server’s back, and a Dio that only learned of its own writes would drift from the database. The till writes straight to SQL on purpose: it stands in for every writer the server can’t see. The price is latency, up to one refresh interval, and the PostgreSQL chapters remove it.

The watch

curl -N holds the connection open. The page arrives as ADDED lines, the current shelf, and then every sale streams back as a MODIFIED line once the reconcile picks it up:

$ curl -N 'localhost:3008/api/products?watch=true'
{"type":"ADDED","object":{"index":0,"name":"Espresso","price":280,"stock":12}}
{"type":"ADDED","object":{"index":1,"name":"Cappuccino","price":340,"stock":8}}
{"type":"ADDED","object":{"index":2,"name":"Cold Brew","price":420,"stock":5}}
{"type":"ADDED","object":{"index":3,"name":"Croissant","price":260,"stock":6}}
{"type":"ADDED","object":{"index":4,"name":"Cheesecake","price":520,"stock":2}}
{"type":"MODIFIED","object":{"index":4,"name":"Cheesecake","price":520,"stock":1}}
{"type":"MODIFIED","object":{"index":2,"name":"Cold Brew","price":420,"stock":4}}
{"type":"MODIFIED","object":{"index":0,"name":"Espresso","price":280,"stock":9}}

That is the reactive stack running over SQL, end to end: a SQL write, reconciled into the cache, one DatasetChanged event, a scenery re-derive, a diff against what this connection last saw, one NDJSON line on the wire. Nothing in that chain is specific to SQL. It is the same machinery the S3 path serves the NOAA bucket with.

Two events, not three

DioRouter emits only ADDED and MODIFIED here, never DELETED, because it diffs each page by position. A delivery that grows the shelf appears as an ADDED at a new index, but a product that sells out shows up as the list shrinking and the rows below it shifting up (each shifted index reported as MODIFIED), not as an explicit removal. A watch client reconstructs “gone” from the shrinking total. A real DELETED line is a one-call opt-in: key the watch by identity with .key_by("id"), which the live-push chapter turns on.

It really is the database

The strongest proof that this isn’t an in-process illusion: change the table from outside the app. With a watch open, in another terminal:

$ sqlite3 learn-8/products.db "UPDATE product SET price=999 WHERE id='p1'"

and the open watch stream reflects it within the second:

{"type":"MODIFIED","object":{"index":0,"name":"Espresso","price":999,"stock":11}}

No API call and no shared memory: a separate process wrote a row, the reconcile read it, and the change fanned out to every watcher. The Dio is a mirror of the database, not a copy that happens to agree with it.

Checklist

  • DioRouter::new(dio).with_column(..).into_router() serves GET and watch over any Dio; nest it under the resource’s path.
  • A plain GET reads the cache; a ?watch=true opens a scenery and streams NDJSON.
  • Writers outside the server reach watchers through the refresher’s reconcile, within one interval.
  • Without .key_by(..), a watch diffs by position and never sends DELETED.

Next: the same app, the same shelf, the same watch, served from PostgreSQL instead of a file and changed by flipping one compile flag.

Moving to PostgreSQL

A file on disk is a fine place for a bar to start. When it outgrows one, with concurrent writers, a second instance or a backup story, you reach for a database server. In most stacks that is a migration project. Here it is a refactor that adds one file and changes two attributes, and then a compile flag decides which backend the binary speaks.

The finished crate is learn-9: the same app as learn-8, made backend-parametric.

Name the backend once

learn-8 wrote SqliteDB in a handful of places. The refactor pulls every one of them into a single alias, so the concrete backend is named in one file:

#![allow(unused)]
fn main() {
// src/db.rs: the only file that names a database
use vantage_sql::prelude::*;

#[cfg(not(feature = "pg"))]
pub type Db = vantage_sql::sqlite::SqliteDB;
#[cfg(feature = "pg")]
pub type Db = vantage_sql::postgres::PostgresDB;

pub async fn connect() -> VantageResult<Db> {
    #[cfg(not(feature = "pg"))]
    let url = format!("sqlite:{}?mode=rwc", concat!(env!("CARGO_MANIFEST_DIR"), "/products.db"));
    #[cfg(feature = "pg")]
    let url = std::env::var("DATABASE_URL")
        .map_err(|_| vantage_core::error!("DATABASE_URL must be set for the `pg` build"))?;

    Db::connect(&url).await.context("connect db")
}
}

PostgresDB::connect, db.pool() and db.vista_factory().from_table(..) are the same calls as their SQLite twins, so connect() is the whole of the connection difference: a file path on one side, a DATABASE_URL on the other. db.rs also holds the CREATE TABLE; it is plain SQL both backends accept.

Two attributes on the model

The entity marker names whichever backend is compiled in, and the table builder takes the Db alias instead of SqliteDB. That is the entire change to the model:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[cfg(feature = "pg")]
use vantage_sql::postgres::AnyPostgresType;
#[cfg(not(feature = "pg"))]
type Db = vantage_sql::sqlite::SqliteDB;
#[cfg(feature = "pg")]
type Db = vantage_sql::postgres::PostgresDB;
// src/product.rs
#[cfg_attr(not(feature = "pg"), entity(SqliteType))]
#[cfg_attr(feature = "pg", entity(PostgresType))]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
}

impl Product {
    pub fn table(db: Db) -> Table<Db, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
    }
}
}

The builder body, the columns and the id, doesn’t move. #[entity(..)] takes a list of the backends a struct serves, not a choice of one: #[entity(SqliteType, PostgresType)] generates both sets of conversions. learn-9 picks one with cfg_attr only so that the build it isn’t running doesn’t need the other driver compiled in.

Everything else is unchanged: main.rs, sim.rs, the lens, the Dio and the DioRouter. They were written against Db and the portable data-set methods, so they never named a backend. The reconcile still calls cx.master().list_values(); the till still runs the same UPDATE … ORDER BY RANDOM(); the watch still streams ADDED/MODIFIED.

Why the SQL didn’t have to change either

The till uses $1 placeholders, ON CONFLICT (id) DO NOTHING and RANDOM(), which SQLite and PostgreSQL both accept, and the schema uses BIGINT, which is SQLite’s INTEGER affinity and Postgres’s 64-bit integer. Where the dialects differ (SQLite’s INTEGER for booleans against Postgres’s BOOLEAN, say), the difference lives in the schema strings in db.rs, never in the model or the app.

Why a compile flag and not a runtime switch?

You could switch at runtime. Everything from the master up is a type-erased Vista, so a match on the URL scheme that builds the master from either factory would hand the same Dio code either backend.

The parts below the master are typed by backend, though. Table<Db, Product> and the till’s db.pool() are a SQLite type or a Postgres type, so a runtime switch would compile both drivers and write those parts twice, once per arm. learn-9 serves one backend per deployment, so it takes the flag: one alias, one driver in the binary. An app that must speak both at once keeps the match, and pays for both drivers.

Run it on Postgres

Start a local Postgres (the repo ships the script), point DATABASE_URL at it, and build with the flag:

$ vantage-sql/scripts/postgres/start.sh          # docker: postgres:17-alpine on :5433
$ export DATABASE_URL='postgres://vantage:vantage@localhost:5433/vantage'
$ cargo run -p learn-9 --no-default-features --features pg
serving on http://localhost:3009 — try: curl -N 'localhost:3009/api/products?watch=true'

The shelf serves and streams as it did on SQLite, with the same routes, the same NDJSON and the same till:

$ curl -N 'localhost:3009/api/products?watch=true'
{"type":"ADDED","object":{"index":0,"name":"Espresso","price":280,"stock":12}}
{"type":"ADDED","object":{"index":1,"name":"Cappuccino","price":340,"stock":8}}
...
{"type":"MODIFIED","object":{"index":4,"name":"Cheesecake","price":520,"stock":1}}

The it-really-is-the-database proof works over Postgres too. Edit a row with psql and watch it land:

$ psql "$DATABASE_URL" -c "UPDATE product SET price=777 WHERE id='p2'"
{"type":"MODIFIED","object":{"index":1,"name":"Cappuccino","price":777,"stock":6}}

What this proves

The reactive stack, cache, events, sceneries and watch server, ran unchanged over a file-backed SQLite database and a networked PostgreSQL server, and the code that moved between them was one alias and two attributes. The repo’s vantage-sql/tests/postgres/6_vista.rs exercises the same Vista over a real Postgres.

The fork converges

The guide’s other path wraps a slow, read-only S3 bucket; this one wraps a fast, writable SQL table that then became a Postgres server. They need different lenses: a cursor walk into redb against a one-call list into memory, capability injection against none. Above lens.dio(master).build() they run the same Dio, the same sceneries and the same watch adapter. The reactive layer does not depend on the backend. Choose the backend your problem has; the stack above it is the one you just learned.

Checklist

  • One Db alias in db.rs names the backend; nothing else does.
  • #[cfg_attr(.., entity(..))] picks the entity marker for the compiled backend.
  • main.rs, the lens, the refresher and the router don’t change.
  • The till’s statements and the schema stay within the SQL both dialects accept.
  • --no-default-features --features pg and a DATABASE_URL run the same app on Postgres.

There is one thing left on the table. The app reconciles on a one-second timer: it re-reads the table whether or not anything changed, and it is always up to a beat behind. A file on disk can’t do better; a server can. Next, we commit to Postgres and trade that poll for push: the database tells us the moment a row changes.

Real-Time Push with LISTEN/NOTIFY

The app so far reconciles on a timer: Refresher::new(reload).every(Duration::from_secs(1)). That is polling. Once a second the Dio re-reads the master and reconciles. It works, but it is a beat behind every change, and it re-queries even when nothing moved. A file-backed SQLite database has no better option, because it can’t call you back. A PostgreSQL server can: it can tell you the instant a row changes, and then the poll goes away.

Having shown the app is portable, this chapter commits to Postgres: it drops the feature flag, deletes sim.rs, moves the writer into its own process, and replaces the refresher with one call on the Dio builder, .live(). The finished crate is learn-10.

Teach the database to announce changes

Postgres LISTEN/NOTIFY is a publish/subscribe channel built into the server. A trigger calls pg_notify on every write, and anyone LISTENing on that channel wakes up. So the only schema added beyond the table is a trigger. db::setup sends these statements, each through sqlx::query(..).execute(db.pool()), after CREATE TABLE product (..):

CREATE OR REPLACE FUNCTION product_notify() RETURNS trigger AS $$
BEGIN PERFORM pg_notify('product_changed', ''); RETURN NULL; END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS product_notify_trg ON product;

CREATE TRIGGER product_notify_trg
AFTER INSERT OR UPDATE OR DELETE ON product
FOR EACH STATEMENT EXECUTE FUNCTION product_notify();

FOR EACH STATEMENT fires the notify once per statement, not per row: a single “something changed” ping is all the Dio needs to reconcile. The payload is empty on purpose (more on that below). The channel is product_changed because the Postgres Vista listens on {table}_changed.

Tell the Vista the trigger exists

Postgres offers no way to ask “is anything going to notify me on this channel?”. A LISTEN on a channel nobody feeds succeeds and then waits forever. A Vista that assumed it could push because the table is writable would advertise a feed that may never arrive, and no consumer could tell that apart from a quiet table. So the application, the only party that knows, says so:

use vantage_sql::postgres::{AnyPostgresType, PostgresDB};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use vantage_vista::SortDirection;

#[entity(PostgresType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}

impl Product {
    pub fn table(db: PostgresDB) -> Table<PostgresDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
            .with_column_of::<i64>("created")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let db = PostgresDB::connect(&url).await.context("connect postgres")?;
let mut master = db
    .vista_factory()
    .with_notify(true) // db::setup installed product_notify_trg
    .from_table(Product::table(db.clone()))?;
master.set_order(Some(("created", SortDirection::Ascending)))?;
Ok(())
}

with_notify(true) is what lets the Vista advertise Subscribe. Leave it off and the live follow below does nothing. That matters here, because this chapter deletes the refresher: push and a refresher’s timer are the two ways the cache learns about a write from outside, and an app that declines both never updates.

The model gained a created column, an epoch-millis stamp set on delivery. The shelf is ordered by it, so a drink keeps its place as it sells and new deliveries append at the end.

One call replaces the poll

The server builds the Lens as before, with an eager in-memory cache and a loader that reconciles, but with no refresher. The Dio builder gets .live() instead:

use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, Loader, LoaderCx};
use vantage_sql::postgres::{AnyPostgresType, PostgresDB};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(PostgresType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}

impl Product {
    pub fn table(db: PostgresDB) -> Table<PostgresDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
            .with_column_of::<i64>("created")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let db = PostgresDB::connect(&url).await.context("connect postgres")?;
let master = db
    .vista_factory()
    .with_notify(true)
    .from_table(Product::table(db.clone()))?;
let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx: LoaderCx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()
    .context("build lens")?;

let dio = lens.dio(master).live().build().await?;
Ok(())
}

That is the whole change. live() checks whether the master advertises Subscribe. Because we opted in above, the Postgres Vista does, and the Dio opens a LISTEN on product_changed. Each notification arrives as a coarse change, “the set changed”, and the Dio answers it with a refresh. With no refresher on the Lens, a refresh re-runs the eager loader, so the same cx.reconcile(rows) that warmed the cache brings it up to date. Over a master that can’t push, live() does nothing and a refresher carries the load instead.

The server still never writes

The live follow only reads and reconciles. Nothing in the server process writes to product. That separation keeps the demo honest: whatever you see on screen arrived over the database.

Why not listen for NOTIFY yourself?

You can. A PgListener on product_changed and a loop is a dozen lines, and an event source the Vista doesn’t model (a webhook, a Kafka topic, a SaaS callback) needs exactly that. The loop hands each event to the Dio with apply_change:

use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, Loader, LoaderCx};
use vantage_sql::postgres::{AnyPostgresType, PostgresDB};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(PostgresType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}

impl Product {
    pub fn table(db: PostgresDB) -> Table<PostgresDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
            .with_column_of::<i64>("created")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let db = PostgresDB::connect(&url).await.context("connect postgres")?;
let master = db.vista_factory().from_table(Product::table(db.clone()))?;
let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx: LoaderCx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()
    .context("build lens")?;
let dio = lens.dio(master).build().await?;
// A payload-less ping: re-read the set.
dio.apply_change(ChangeEvent::Invalidated).await?;

// A source that names the row: drop it from the cache, no re-read.
dio.apply_change(ChangeEvent::Deleted { id: "d42".into() }).await?;
Ok(())
}

live() applies changes the same way, so for a Vista that advertises Subscribe the hand-written loop buys nothing and costs two things live() already gets right. It subscribes before the eager loader copies the set and applies what arrived once the copy lands, so a write during the copy is neither lost nor overwritten by it. And a follow that falls behind the stream re-reads the set instead of dropping changes. Write the loop for sources the Vista can’t reach; use live() for the ones it can.

The till becomes its own process

To drive it, learn-10 ships a second binary, the mutator, run alongside the server:

$ cargo run -p learn-10                 # the reactive server, port 3010
$ cargo run -p learn-10 --bin mutator   # a separate writer process

The mutator writes through Vantage’s active-entity API, with no hand-written SQL. list_entities() hands back drinks that each carry their own id and datasource, so selling one is:

use rand::seq::SliceRandom;
use vantage_dataset::prelude::ActiveEntitySet;
use vantage_sql::postgres::{AnyPostgresType, PostgresDB};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(PostgresType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}

impl Product {
    pub fn table(db: PostgresDB) -> Table<PostgresDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
            .with_column_of::<i64>("created")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let db = PostgresDB::connect(&url).await.context("connect postgres")?;
let table = Product::table(db.clone());
let mut shelf = table.list_entities().await?;
let drink = shelf.choose_mut(&mut rand::thread_rng()).unwrap();
if drink.stock <= 1 {
    drink.delete().await?; // last unit: off the shelf
} else {
    drink.stock -= 1;
    drink.save().await?; // one sale
}
Ok(())
}

Two independent processes that share nothing but the database and the notify channel: the strongest proof that the reactive stack tracks the data, not an in-process shortcut.

Removals, and a UI to see them

This app also keys its watch by identity, so a sold-out drink is reported as a removal rather than a list that shrinks:

use vantage_api_adapters::axum_dio::DioRouter;
use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, Loader, LoaderCx};
use vantage_sql::postgres::{AnyPostgresType, PostgresDB};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(PostgresType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}

impl Product {
    pub fn table(db: PostgresDB) -> Table<PostgresDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
            .with_column_of::<i64>("created")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let db = PostgresDB::connect(&url).await.context("connect postgres")?;
let master = db
    .vista_factory()
    .with_notify(true)
    .from_table(Product::table(db.clone()))?;
let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx: LoaderCx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()
    .context("build lens")?;
let dio = lens.dio(master).live().build().await?;
let api = DioRouter::new(dio.clone())
    .with_column("id", "id")
    .with_column("name", "name")
    .with_column("price", "price")
    .with_column("stock", "stock")
    .key_by("id") // identity-keyed: emits DELETED when a row leaves
    .with_page_size(50)
    .into_router();
Ok(())
}

With key_by("id"), the watch stream gains a third event, DELETED, alongside ADDED and MODIFIED. The key field has to be one of the with_column fields, which is why id is exposed now. A small React page (frontend/index.html, served by the same axum app through ServeDir) consumes the stream and animates each drink: arriving with a flash, ticking down as it sells, and leaving with a “sold out” stamp when a DELETED lands. It is plain React over the watch endpoint, not part of Vantage, and the next chapter reuses the same file unchanged.

Watch it on the wire:

$ curl -N 'localhost:3010/api/products?watch=true'
{"type":"ADDED","object":{"index":0,"id":"d51...","name":"Mojito","price":1000,"stock":11}}
{"type":"ADDED","object":{"index":1,"id":"d59...","name":"Boulevardier","price":1400,"stock":12}}
{"type":"MODIFIED","object":{"index":0,"id":"d51...","name":"Mojito","price":1000,"stock":10}}
{"type":"DELETED","object":{"index":0,"id":"d59...","name":"Boulevardier","price":1400,"stock":1}}

Every line is a database write, announced by Postgres, reconciled into the cache, diffed by the scenery and fanned out, with no timer anywhere in the loop.

Coarse push: a ping, then a re-read

NOTIFY carries no row data; the payload is empty. So the Dio learns only that something changed, and responds by re-reading the whole set to reconcile. That is correct and, for a shelf of a few dozen drinks, instant. But it is a coarse signal: one write means one full reconcile. The next chapter switches to a database whose push carries the changed row itself, so no re-read is needed.

Checklist

  • A FOR EACH STATEMENT trigger calls pg_notify('{table}_changed', '') on every write.
  • vista_factory().with_notify(true) declares the trigger, so the Vista advertises Subscribe.
  • lens.dio(master).live().build() follows the channel; each ping refreshes the Dio, and with no refresher that re-runs the eager loader’s reconcile.
  • Without with_notify(true) and without a refresher, the cache never sees an outside write.
  • dio.apply_change(ChangeEvent::..) is the entry for sources the Vista can’t subscribe to.
  • .key_by("id") on the router turns sell-outs into DELETED events.

Next: the same app, the same frontend, a different database. SurrealDB’s live queries push the change itself rather than a ping.

Switching to SurrealDB

Postgres was a migration within SQL: a different server, the same dialect family. SurrealDB is a different database: multi-model, schemaless by default, and reached over a WebSocket rather than a pooled SQL connection. Adopting it tests the claim this path has been making. The goal is to change as little as possible, and then to cash in the one thing SurrealDB does better than a SQL server: it pushes the changed row, not a ping.

The finished crate is learn-11. Set it next to learn-10 and the diff is small, which is the result we want.

What changes

Three places name the backend, and nothing else does.

The entity marker: swap the backend in the attribute.

#![allow(unused)]
fn main() {
use vantage_surrealdb::types::AnySurrealType;
use vantage_types::entity;
#[entity(SurrealType)] // was: #[entity(PostgresType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}
}

The table builder: the same columns, built against SurrealDB. SurrealDB’s native id is a Thing (product:⟨key⟩).

#![allow(unused)]
fn main() {
use vantage_surrealdb::surrealdb::SurrealDB;
use vantage_surrealdb::types::AnySurrealType;
use vantage_table::table::Table;
use vantage_types::entity;

#[entity(SurrealType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}

impl Product {
pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Product> {
    Table::new("product", db)
        .with_id_column("id")
        .with_column_of::<String>("name")
        .with_column_of::<i64>("price")
        .with_column_of::<i64>("stock")
        .with_column_of::<i64>("created")
}
}
}

Connect and setup: a DSN instead of a DATABASE_URL, and no trigger. Where Postgres needed a pg_notify function and an AFTER … FOR EACH STATEMENT trigger, SurrealDB emits change frames on its own. Setup makes sure the table exists:

use surreal_client::SurrealConnection;
use vantage_core::{Result, error};
use vantage_surrealdb::surrealdb::SurrealDB;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let dsn = std::env::var("SURREALDB_URL")
    .unwrap_or_else(|_| "cbor://root:root@localhost:8000/bar/v1".to_string());

let client = SurrealConnection::dsn(&dsn)
    .map_err(|e| error!("invalid SURREALDB_URL DSN", details = e.to_string()))?
    .connect()
    .await
    .map_err(|e| error!("connect surrealdb", dsn = &dsn, details = e.to_string()))?;

// The one bit of schema: a table for LIVE SELECT to attach to.
client
    .query("DEFINE TABLE IF NOT EXISTS product SCHEMALESS", None)
    .await
    .map_err(|e| error!("define product table", details = e.to_string()))?;

let db = SurrealDB::new(client);
Ok(())
}

The till (the mutator) is the same active-entity code as before. Only the id it hands new_entity is a Thing:

use vantage_core::{Context, Result};
use vantage_dataset::prelude::ActiveEntitySet;
use vantage_surrealdb::surrealdb::SurrealDB;
use vantage_surrealdb::thing::Thing;
use vantage_surrealdb::types::AnySurrealType;
use vantage_table::table::Table;
use vantage_types::entity;

#[entity(SurrealType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}

impl Product {
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
            .with_column_of::<i64>("created")
    }
}

async fn deliver(
    table: &Table<SurrealDB, Product>,
    key: String,
    name: String,
    price: i64,
    stock: i64,
    created: i64,
) -> Result<()> {
table
    .new_entity(
        Thing::new("product", key), // was: a String id
        Product { name, price, stock, created },
    )
    .save()
    .await?;
Ok(())
}

fn main() {}

What doesn’t change

The server’s reactive core is untouched. The Vista comes from SurrealVistaFactory::new(db.clone()) (the factory db.vista_factory() returns on a SurrealDB) instead of the Postgres factory, and the Lens and the Dio builder are line for line the Postgres chapter’s, including the call that matters:

use surreal_client::SurrealConnection;
use vantage_core::{Context, Result, error};
use vantage_dataset::prelude::*;
use vantage_diorama::prelude::*;
use vantage_diorama::{Cache, Loader, LoaderCx};
use vantage_surrealdb::surrealdb::SurrealDB;
use vantage_surrealdb::types::AnySurrealType;
use vantage_surrealdb::vista::SurrealVistaFactory;
use vantage_table::table::Table;
use vantage_types::entity;
use vantage_vista::SortDirection;

#[entity(SurrealType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
    pub price: i64,
    pub stock: i64,
    pub created: i64,
}

impl Product {
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<i64>("stock")
            .with_column_of::<i64>("created")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let dsn = std::env::var("SURREALDB_URL")
    .unwrap_or_else(|_| "cbor://root:root@localhost:8000/bar/v1".to_string());
let client = SurrealConnection::dsn(&dsn)
    .map_err(|e| error!("invalid SURREALDB_URL DSN", details = e.to_string()))?
    .connect()
    .await
    .map_err(|e| error!("connect surrealdb", dsn = &dsn, details = e.to_string()))?;
let db = SurrealDB::new(client);
let mut master =
    SurrealVistaFactory::new(db.clone()).from_table(Product::surreal_table(db.clone()))?;
master.set_order(Some(("created", SortDirection::Ascending)))?;

let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx: LoaderCx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()
    .context("build lens")?;

let dio = lens.dio(master).live().build().await?; // the same call, now backed by LIVE SELECT
Ok(())
}

And the frontend is the same file as learn-10. The React page consumes ?watch=true and animates the shelf; it never learns which database is behind the stream. Diff the two frontend/index.html files and you get nothing: the reactive layer, up to and including the UI, does not depend on the backend.

The payoff: precise push, no re-read

live() looks the same, but underneath it does something better than it could on Postgres. SurrealDB’s LIVE SELECT delivers a typed, per-row notification for every change: the action (CREATE, UPDATE, DELETE) and the affected record. So the Dio applies each change directly to its cache, as ChangeEvent::Inserted, Updated or Deleted would through dio.apply_change. A create inserts one row, an update repaints one row, a delete removes one row. There is no “something changed, re-read everything” step, because the change arrived in full.

$ curl -N 'localhost:3011/api/products?watch=true'
{"type":"ADDED","object":{"index":0,"id":{"@@TAGGED@@":[8,["product","d51..."]]},"name":"Mojito","price":1000,"stock":11}}
{"type":"MODIFIED","object":{"index":0,"id":{"@@TAGGED@@":[8,["product","d51..."]]},"name":"Mojito","price":1000,"stock":10}}
{"type":"DELETED","object":{"index":0,"id":{"@@TAGGED@@":[8,["product","d59..."]]},"name":"Boulevardier","price":1400,"stock":1}}

The id arrives as a SurrealDB Thing rather than a bare string. The frontend’s idOf helper normalises either shape to a stable key, which is why the same UI serves both backends.

Why does SurrealDB need no with_notify(true)?

Because there is nothing to forget. Postgres pushes only through a trigger the application installs, and the driver can’t see whether it exists, so the application has to declare it. SurrealDB’s LIVE SELECT is part of the server: any table can be followed, with no schema of yours involved. The Surreal Vista advertises Subscribe from the moment you build it, and live() uses it.

The asymmetry is the backend’s, not Vantage’s. A capability the driver can verify is advertised by default; one that depends on application setup is opt-in, so a Vista never promises a feed that may be silent.

One capability, three freshness models

The product shelf has now run behind three backends. Above the master, what changed was the Lens losing its refresher and the Dio builder gaining .live():

BackendHow the Dio learns of a changeGranularity
SQLitea Refresher::new(..).every(..) timer (it can’t push)poll, whole set
PostgreSQLLISTEN/NOTIFY: a payload-less ping, then a re-readcoarse push, re-read on each write
SurrealDBLIVE SELECT: the action and the rowfine-grained push, the row applied directly

Poll, coarse push, fine push. live() picks from what the Vista advertises, and falls back to the refresher when it advertises nothing. You pick the database your problem has; the freshness you get is the best that database can offer, and the code that consumes it is the code you already wrote.

Note what each one costs you: SurrealDB is watchable the moment you build the Vista, while the PostgreSQL row assumes the trigger from the previous chapter and the with_notify(true) that declares it. Every other backend Vantage speaks polls today; see Live Data for the full picture.

Checklist

  • The entity marker becomes #[entity(SurrealType)]; the columns don’t move.
  • SurrealConnection::dsn(..).connect() replaces DATABASE_URL, and DEFINE TABLE is the only schema.
  • New records take a Thing id: Thing::new("product", key).
  • SurrealVistaFactory::new(db).from_table(..) builds the master; the Lens and .live() are unchanged.
  • Each LIVE SELECT frame is applied as one row change; nothing is re-read.

From here the reference half of the book takes over: SurrealDB for the driver details, SQL for the dialects, Config-Driven Vistas for declaring all of this from YAML, Live Data for what every backend does about freshness, and Adding a New Persistence when your backend isn’t one Vantage already speaks.

The Vantage Journey

Historical snippets

Each snippet shows the API of the release its section describes. They aren’t compiled against the current crates.

Vantage didn’t start as a multi-backend entity framework. It started as a weekend experiment to see if Rust could build SQL queries without feeling like Rust.

This page walks you through each release — what changed, why it mattered, and how the API evolved from raw Postgres queries to a universal persistence layer.

graph LR
    D[0.0 DORM] -->|rename| V1[0.1 Vantage]
    V1 -->|entity framework| V2[0.2 MDA]
    V2 -->|crate split| V3[0.3 UI Adapters]
    V3 -->|type rewrite| V4[0.4 Type System]
    V4 -->|universal handle| V5[0.5 Vista]
    V5 -->|hardening| V6[0.6 Robustness]
    style D fill:#555,color:#fff
    style V1 fill:#4a7c59,color:#fff
    style V2 fill:#2d6a8f,color:#fff
    style V3 fill:#8f5a2d,color:#fff
    style V4 fill:#7c2d8f,color:#fff
    style V5 fill:#2d8f6a,color:#fff
    style V6 fill:#8f2d4a,color:#fff

0.0 — “DORM” (April–November 2024)

The project was originally called DORM — the Dry ORM. It was Postgres-only, monolithic, and proudly opinionated. Everything lived in one crate.

The core idea was already there: Data Sets. Instead of loading records eagerly, you describe what you want and let the framework figure out the query.

let clients = Client::table();           // Table<Postgres, Client>
let paying = clients.with_condition(
    clients.is_paying_client().eq(&true)
);
let orders = paying.ref_orders();         // Table<Postgres, Order>

for order in orders.get().await? {
    println!("#{} total: ${:.2}", order.id, order.total as f64 / 100.0);
}

Behind this innocent-looking code, DORM generated a single SQL query with subqueries, joins, and soft-delete filters — all derived from model definitions:

SELECT id,
    (SELECT name FROM client WHERE client.id = ord.client_id) AS client_name,
    (SELECT SUM((SELECT price FROM product WHERE id = product_id) * quantity)
     FROM order_line WHERE order_line.order_id = ord.id) AS total
FROM ord
WHERE client_id IN (SELECT id FROM client WHERE is_paying_client = true)
  AND is_deleted = false

No hand-written SQL. No query strings. The framework composed everything from relationship definitions and table extensions like SoftDelete.

The Arc breakthrough

Early commits show a battle with Rust’s borrow checker — the infamous “lifetime hell.” The solution came on April 28: switching to Arc for shared ownership. This unlocked clonable data sources and composable table references that define the framework to this day.

Milestones

DateWhat happened
Apr 11First commit — queries, expressions, SQLite binding
Apr 18Insert/delete support, first Postgres tests
Apr 28Arc adoption — escaped lifetime hell
May 14Query::Join implemented
May 25has_one, has_many, relationship traversal
May 26Bakery model example — all entities defined

0.1 — Vantage is born (December 2024)

On December 12, the framework was renamed from DORM to Vantage and published to crates.io for the first time. The API stayed the same — this was a branding milestone, not an architectural one.

Why ‘Vantage’?

A vantage point gives you a clear view of the landscape below. The framework gives you a clear view of your data — no matter where it lives or how complex the relationships are.

The same month brought the first Axum integration (bakery_api), proving that data sets could drive REST endpoints naturally:

async fn list_orders(
    client: axum::extract::Query<OrderRequest>,
    pager: axum::extract::Query<Pagination>,
) -> impl IntoResponse {
    let orders = Client::table()
        .with_id(client.client_id.into())
        .ref_orders();

    let mut query = orders.query();
    query.add_limit(Some(pager.per_page));
    Json(query.get().await.unwrap())
}

Tags v0.1.0 and v0.1.1 were published the same day. SQLx data source landed 11 days later.


0.2 — Entity Framework & MDA (February 2025)

Version 0.2 repositioned Vantage as a full Entity Framework with Model-Driven Architecture. The README doubled in size. The vision expanded from “clever query builder” to “how enterprises should structure business logic.”

The key insight: entities aren’t just database rows. They’re business objects that might live in SQL, NoSQL, a REST API, or a message queue — and your code shouldn’t care which.

impl Client {
    fn table() -> Table<Client, Oracle> { /* ... */ }
    fn registration_queue() -> impl Insertable<Client> { /* Kafka */ }
    fn admin_api() -> impl DataSet<Client> { /* REST */ }
    fn read_csv(file: String) -> impl ReadableDataSet<Client> { /* CSV */ }
}

Same interface, any backend

A developer calling Client::registration_queue().insert(id, client).await doesn’t need to know it’s Kafka underneath. The SDK hides the transport — only the entity contract matters.

This release also introduced the idea of struct projection — using different Rust types against the same data set to control which fields get queried:

struct MiniClient { name: String }
struct FullClient { name: String, email: String, balance: Decimal }

// Only fetches `name` from the database
let name = clients.get_id_as::<MiniClient>(42).await?.name;

The monolith was getting heavy, though. Everything still lived in one crate, and adding a new database meant touching core code.


0.3 — The Great Separation (July–October 2025)

Version 0.3 broke the monolith into dedicated crates and bet heavily on SurrealDB as the primary backend. The trait-based architecture that defines Vantage today was born here.

Crate explosion

One crate became many: vantage-expressions, vantage-table, vantage-dataset, vantage-surrealdb, surreal-client, vantage-config, vantage-ui-adapters — each with a focused responsibility.

Table definitions moved from static initialization to a builder pattern:

// 0.2 — static, Postgres-only
Table::new_with_entity("bakery", postgres())
    .with_id_column("id")
    .with_column("name")
    .with_many("clients", "bakery_id", || Box::new(Client::table()))

// 0.3 — builder, any datasource
Table::<SurrealDB, Client>::new("client", ds.clone())
    .with_id_column("id")
    .with_column("name")
    .with_column("email")
    .with_many("orders", "client_id", || Client::order_table())

Field accessors now return Expression instead of column objects — making them composable across query builders:

// Build conditions from expressions
let active = clients.is_paying_client().eq(true);
let big_spenders = clients.balance().gt(1000);
let query = clients.with_condition(active).with_condition(big_spenders);

The AnyTable type-erasure system arrived, enabling generic code that works with any datasource:

let tables: Vec<AnyTable> = vec![
    AnyTable::new(Client::table()),   // SurrealDB
    AnyTable::new(Product::table()),  // SQLite
];

for table in &tables {
    println!("{}: {} records", table.name(), table.count().await?);
}

This release culminated with UI adapters for six frameworks — egui, GPUI, Slint, Tauri, Ratatui, and Cursive — all driven by the same AnyTable interface.

One data layer, six UIs

The same bakery model powered a native desktop app (GPUI), a web app (Tauri), a terminal dashboard (Ratatui), and three more — without changing a single line of business logic.


0.4 — The Type System Rewrite (November 2025–April 2026)

Vantage 0.4 — the type system

Version 0.4 rewrites the type system from the ground up. Custom types per datasource, CBOR protocol, 7 persistence backends (SurrealDB, Postgres, MySQL, SQLite, MongoDB, CSV, REST API), ActiveEntity / ActiveRecord patterns, typed columns, unified error handling, and a progressive trait model where each persistence only implements what its engine supports.

Read the full 0.4 feature guide →


0.5 — The Vista Era (May–June 2026)

Version 0.4 made the type system precise. Version 0.5 asks the next question: how does code that doesn’t know your entity type talk to your data? A CLI that lists any table, a web admin that draws forms from a YAML schema, a UI data grid pointed at whatever you give it — none of them know your Product struct.

0.4’s answer was AnyTable: type-erasure that funnelled everything through JSON. 0.5 replaces it with Vista — a universal, schema-bearing data handle. A Vista wraps any typed Table<DB, E>, erases both backend and entity, and carries its own schema (columns, references, capabilities) while delegating execution to a per-driver TableShell.

// Wrap any typed table — SQLite, MongoDB, AWS, all the same shape
let vista = SqliteVistaFactory::new(db).from_table(Product::table(db.clone()))?;

// Everything is now runtime introspection — no generics, no entity type
for name in vista.get_column_names() {
    let col = vista.get_column(name).unwrap();
    println!("{}: {}", col.name, col.original_type);
}

let mut v = vista.clone();
v.add_condition_eq("category_id", 1.into())?;     // driver translates to native condition
v.add_search("tart")?;                            // fans across SEARCHABLE columns
v.add_order("price", SortDirection::Descending)?;
let rows = v.fetch_page(1).await?;

CBOR, not JSON

Where AnyTable narrowed every value to serde_json::Value, Vista carries ciborium::Value end to end — preserving integer-vs-float, binary blobs, and precise decimals. JSON conversion happens only at the boundary, when you actually need it (an HTTP response, say).

The AnyTable carrier was deleted outright in vantage-table 0.5.2 — type erasure now lives one layer up, in Vista. That decommission is what the 0.5 version bump marks.

Capabilities — the explicit contract

Not every backend can do everything. A CSV file can’t sort server-side; DynamoDB can only order by its sort key; a REST API may paginate by cursor but not by page number. Vista makes this explicit with a struct of capability flags, and each driver declares exactly what it supports:

let caps = vista.capabilities();
if caps.can_search { v.add_search("query")?; }
if caps.can_fetch_page { /* random-access pager */ } else { /* load-more button */ }

Calling a method the driver doesn’t advertise returns an Unsupported error — never a silent match-all, never a panic. UI adapters branch on these flags to decide which controls to render.

Config-driven: YAML specs and Rhai scripting

A Vista no longer needs a hand-written Rust definition. Tables, columns, and relations can be declared in a YAML spec (VistaSpec) and loaded by the driver’s factory. For anything YAML can’t express — vendor-specific expressions, derived sources, scripted reference traversal — there’s an optional Rhai DSL that compiles to native queries:

let users = table("users").alias("u");
select()
    .from(users)
    .expression(users["name"])
    .where(users["age"] >= 18)
    .order_by(users["name"], "asc")

The same script renders dialect-correct SQL for SQLite, Postgres, and MySQL — automatic identifier quoting, date_format() mapping to strftime/TO_CHAR/DATE_FORMAT, group_concat() becoming GROUP_CONCAT/STRING_AGG. SurrealDB gets its own vocabulary (graph traversal, RELATE, record ids). YAML stays the primary format; Rhai is the targeted, serializable escape hatch.

Contained relations and nested writes

Embedded objects and arrays — an order’s lines, a row’s JSON column — now surface as a fully editable sub-Vista. SurrealDB backs them with native nested objects; SQL backends store them as JSON columns and patch the host column on writeback. And insert_value learned to walk relations: hand it a record whose keys name a relation and it sequences the writes, stamping foreign keys automatically.

Diorama — caching, events, and reactive views

The biggest new subsystem is vantage-diorama — a layer that sits between a Vista and whatever consumes it. Three concepts:

  • Lens — long-lived shared infrastructure: a cache backend, lifecycle callbacks, refresh policy. Built once per application.
  • Dio — a Vista bound to a Lens. Owns the cache table, a write queue, an event bus, and a refresh task.
  • Scenery — a reactive view onto a Dio (ordered tables, individual records, aggregates) that a UI binds to.
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()?,
);
let products = lens.make_dio(products_vista).await?;
let mut v = products.vista();          // facade: reads from cache, writes through the queue

Capability injection

Diorama caches the full dataset locally, so a read-only CSV Vista that can’t paginate, sort, or search server-side becomes one that can — the consumer sees a richer Vista than the backend actually supports. Two-pass progressive loading renders a cheap list immediately and hydrates expensive per-row detail lazily as rows scroll into view.

More backends, more reach

0.5 also widened the backend roster around the Vista abstraction: redb (embedded key-value store), AWS (DynamoDB and friends, all returning Vistas directly), a Cmd source that turns a shell script into a queryable table, and an API pool. Cross-persistence reference traversal — categories in Postgres, products in MongoDB — moved out of Vista and into vantage-vista-factory’s VistaCatalog, keeping each Vista strictly single-backend.

A new introduction

The getting-started guide was rewritten around these concepts — chapters on Vista, Dio & Lens, and Scenery build a CLI up from a single SQLite query to a reactive, cached, multi-backend handle.

Read the full 0.5 feature guide →


0.6 — Hardening (June 2026)

If 0.5 was about reach, 0.6 is about trust. The release is a coordinated, workspace-wide sweep with a single theme: no panics, no silent failures. Every path that could abort the process deep inside a write, or quietly return a wrong answer, was made fallible and explicit.

  • Serialization is fallible. A new TryIntoRecord trait replaces the infallible blanket: a failing Serialize (non-string map keys, an out-of-range number) now becomes a recoverable error instead of a process abort mid-write. Entity write paths (insert / replace / patch) propagate it.
  • No silent zeros. get_count unwraps the common [{"count": N}] shape and surfaces unrecognized result shapes as errors instead of returning 0.
  • No silent match-all. Search on a backend that can’t search server-side (CSV, redb) returns an Unsupported error — never a quietly unfiltered result set.
  • Expression safety. f64::IntoValue degrades NaN/Infinity to Null instead of panicking; Expression::preview() uses single-pass interleaving so a {} inside a rendered value can’t corrupt the output. select_column became fallible.
  • Clearer error classification. vantage-core’s error-kind annotators were renamed to mark_unsupported / mark_unimplemented (the is_* names are now real predicates), and trace emission was decoupled — mark_* no longer logs implicitly; chain .traced() when you want the tracing::error! event.
  • Security: SurrealDB identifier injection. Identifier escaping was unified into surreal-client’s single escape_identifier authority and fixed for SurrealDB 3.x — a crafted ⟩ inside a column name could previously break out of ⟨…⟩ quoting and inject arbitrary SurrealQL. similarity() and time_group() literals are now bound as parameters rather than interpolated, closing the same hole on the Rhai-exposed search path.

Alongside the hardening, 0.6 folded the standalone vantage-data-script crate into vantage-vista’s rhai feature, added random-access windowing (fetch_window) with REST lazy-loading, and introduced ColumnFlag::Label for status-tag display hints.


The bigger picture

Looking at the trajectory:

VersionCore ideaBackendsCrates
0.0Can Rust build SQL smartly?Postgres1
0.1Let’s publish thisPostgres1
0.2Entity Framework for RustPostgres1
0.3Traits, not inheritanceSurrealDB, SQLite10+
0.4Strict types, any persistenceSurrealDB, SQLite, Postgres, MySQL, MongoDB, CSV, REST API20+
0.5A universal data handle+ redb, AWS (DynamoDB), Cmd (shell scripts)25+
0.6No panics, no silent failures(same — hardening release)25+

What started as 16 commits in April 2024 is now 800+ commits, 300+ pull requests, and a framework that can drive the same business logic across a dozen backends and six UI frameworks — typed when you know the entity, universal (via Vista) when you don’t.

The destination hasn’t changed since day one: describe your data once, use it everywhere. Each version just made “everywhere” a little bigger.

What’s New in Vantage 0.4

Historical snippets

The snippets show the 0.4 API. They aren’t compiled against the current crates.

Version 0.4 is a ground-up rewrite of the type system and a massive expansion of persistence support. If 0.3 proved the architecture, 0.4 makes it production-ready.


Progressive model — implement only what you need

In earlier versions, adding a new backend meant implementing everything. Version 0.4 flips this: capabilities unlock progressively based on which traits your persistence (backend) implements. There are two parallel trait hierarchies — one for typed entities, one for schema-less records.

DataSet works with your Rust structs:

// A CSV file is read-only — and that's fine
impl ReadableDataSet<Product> for CsvFile { /* ... */ }

// A message queue can only append
impl InsertableDataSet<Event> for KafkaTopic { /* ... */ }

// A full database gets the complete toolkit
impl ReadableDataSet<Client> for SurrealDB { /* ... */ }
impl WritableDataSet<Client> for SurrealDB { /* ... */ }
// ReadableDataSet + WritableDataSet automatically unlocks ActiveEntitySet!

ValueSet is the schema-less counterpart — same hierarchy, but operates on Record<Value> instead of entities. Useful for dynamic tables, config-driven schemas, or when you don’t have (or want) a struct:

// Read raw records — no struct needed
impl ReadableValueSet for CsvFile { /* returns Record<CsvType> */ }

// Full CRUD on raw values
impl WritableValueSet for SurrealDB { /* accepts Record<AnySurrealType> */ }
// ReadableValueSet + WritableValueSet automatically unlocks ActiveRecordSet!

Each layer is opt-in. A CSV persistence never pretends it can build select queries.

The DataSource layer is progressive too. These traits aren’t a strict hierarchy — they combine independently, and each persistence implements only what its engine supports:

DataSource                        ← marker, all backends
 ├── TableSource                  ← CRUD, columns, conditions
 ├── ExprDataSource               ← execute expressions, defer()
 └── SelectableDataSource         ← query builder (Selectable)

TableExprSource                   ← count/sum/max/min as composable expressions
 requires: TableSource + ExprDataSource

TableQuerySource                  ← table definition → full query
 requires: TableSource + SelectableDataSource

Here’s what each persistence implements:

CSV
 └── TableSource (read-only, in-memory conditions)

REST API  (vantage-api-client)
 ├── TableSource (read-only, HTTP GET)
 └── ExprDataSource (basic resolve)

MongoDB
 ├── TableSource (full CRUD, native bson::Document conditions)
 └── SelectableDataSource (field projection, $match pipeline)
     └── aggregates via native $sum/$max/$min pipeline

Postgres / MySQL / SQLite  (via sqlx)
 ├── TableSource (full CRUD)
 ├── ExprDataSource (parametric SQL execution)
 ├── SelectableDataSource (JOINs, CTEs, window functions)
 └── TableQuerySource

SurrealDB
 ├── TableSource (full CRUD)
 ├── ExprDataSource (CBOR execution)
 ├── SelectableDataSource (SurrealQL builder)
 ├── TableExprSource (composable aggregates)
 ├── TableQuerySource
 └── vendor extensions (graph queries, RELATE, live queries)

There’s also MockTableSource in vantage-table and ImDataSource in vantage-dataset — a fully in-memory persistence backed by HashMap<String, IndexMap<String, Record<Value>>>. Great for tests and prototyping without any database.

Build your own persistence

Want to add your own? The Adding a New Persistence guide walks through each trait step by step — from type system to full CRUD to multi-backend CLI.


Persistence-specific Type Systems

The vantage-types crate is the foundation of 0.4. Each persistence defines its own type universe via the vantage_type_system! macro — no more funnelling everything through JSON:

vantage_type_system!(AnySurrealType, SurrealDB);   // CBOR-native types
vantage_type_system!(AnyMongoType, MongoDB);        // BSON-native types
vantage_type_system!(AnySqliteType, SqliteDB);      // CBOR with integer/real/text markers
vantage_type_system!(AnyPostgresType, PostgresDB);  // CBOR with full PG type coverage
vantage_type_system!(AnyMysqlType, MysqlDB);        // CBOR with MySQL type coverage

All SQL backends (SQLite, Postgres, MySQL) now use CBOR as their internal value representation — not JSON. This preserves type fidelity that JSON loses (integer vs float, binary blobs, precise decimals) while keeping the same vantage_type_system! ergonomics.

SQL type conversions

Each SQL backend has detailed type conversion tables covering chrono types (DATE, TIME, TIMESTAMP), numeric types (DECIMAL, BIGINT, FLOAT), and cross-type coercion rules. See the Type Conversions reference for exact round-trip behaviour per column type and Rust type.

Strict conversions replace silent casting — try_into() is explicit and fallible:

let val: i64 = surreal_value.try_into()?;   // fails if it's actually a string
let val: String = mongo_value.try_into()?;   // fails if it's actually a number

These types flow through the entire framework, not just storage. Columns carry their type as a generic parameter (Column<i64>, Column<String>) — surviving type-erasure via original_type so UI adapters can inspect what a column actually holds. AssociatedExpression<'a, DS, T, R> is parameterised by the value type T, so a count query on SurrealDB returns AnySurrealType while the same query on Postgres returns AnyPostgresType. AssociatedQueryable<R> then converts that into your expected Rust type — all type-checked at compile time.

// Column preserves type through erasure
let price = Column::<i64>::new("price").with_flag(ColumnFlag::Indexed);
let erased = Column::<AnyType>::from_column(price);
assert_eq!(erased.get_type(), "i64");  // original type survives

// AssociatedExpression carries the persistence's value type
let count: AssociatedExpression<'_, SurrealDB, AnySurrealType, usize> =
    table.get_expr_count();
let n: usize = count.get().await?;  // executes, converts AnySurrealType → usize

This means the full range of your persistence’s native types is preserved end-to-end — from column definition through query building to result extraction — without narrowing down to JSON variants.


Table — the interface you get for free

Once a persistence implements TableSource, the framework hands you Table<DB, Entity> — a fully-featured abstraction over your data with columns, conditions, ordering, pagination, relationships, and aggregates. You don’t build any of this yourself; it comes from vantage-table.

Table<DB, E> auto-implements a wide range of traits from the DataSet and ValueSet hierarchies: ReadableDataSet<E>, WritableDataSet<E>, InsertableDataSet<E>, ActiveEntitySet<E>, ReadableValueSet, WritableValueSet, InsertableValueSet, and ActiveRecordSet. All of these come for free once your persistence implements TableSource.

let products = Product::table(db)
    .with_condition(products["is_deleted"].eq(false))
    .with_order(products["price"].desc())
    .with_pagination(Pagination::ipp(25));

// ReadableDataSet, WritableDataSet, ActiveEntitySet — all auto-implemented
for (id, product) in products.list().await? { /* ... */ }
let count = products.get_count().await?;

Additionally, table.select() yields a vendor-specific query builder — SurrealSelect, SqliteSelect, PostgresSelect — giving you full access to the persistence’s native query capabilities when you need to go beyond what the generic Table API offers.

This dramatically simplifies implementing new persistences. You implement TableSource methods (read, write, aggregate) and get the entire Table API for free — conditions compose automatically, columns carry types, references traverse between tables, and AnyTable type-erasure just works.

graph LR
    P[Select Persistence]
    M[Create Model Layer]
    B[Build Business Logic]
    E[Export via FFI or API]
    P ==> M ==> B ==> E
    style P fill:#4a7c59,color:#fff
    style M fill:#2d6a8f,color:#fff
    style B fill:#8f5a2d,color:#fff
    style E fill:#7c2d8f,color:#fff

Pick your persistence (SurrealDB, Postgres, CSV — or several). Define entities and tables in a shared model crate. Build business logic against Table and DataSet traits — persistence-agnostic and testable with mocks. Finally, expose your model to other languages via FFI (C ABI, PyO3, UniFFI, WASM) or through API layers (Axum, gRPC). The model stays in Rust; consumers don’t need to know. Read more in Model-Driven Architecture and Three Paths for Developers.


ActiveEntity and ActiveRecord

The vantage-dataset crate introduces two flavours of the active record pattern:

ActiveEntity<D, E> — wraps a typed entity. Derefs straight to your struct, tracks the ID, and saves back to any WritableDataSet:

let mut user = users.get_entity(&id).await?.unwrap();
user.email = "new@example.com".to_string();  // modify via DerefMut
user.save().await?;                           // persists the change

ActiveRecord<D> — same idea, but schema-less. Works with Record<Value> instead of a concrete struct, perfect for dynamic tables or config-driven entities:

let mut rec = table.get_value_record(&id).await?;
rec["status"] = json!("active");
rec.save().await?;

Both auto-unlock via blanket impls — if your datasource implements ReadableDataSet + WritableDataSet, you get ActiveEntitySet for free. No extra code.

Get-or-create pattern

The API is designed for real-world patterns like get-or-create:

let mut user = users.get_entity(&id).await?
    .unwrap_or_else(|| users.new_entity(id, User::default()));
user.name = "Alice".into();
user.save().await?;

Record<V> — persistence-native value bags

Record<V> is an IndexMap<String, V> wrapper — but V is not serde_json::Value. It’s your persistence’s own type. A SurrealDB table returns Record<AnySurrealType>, Postgres returns Record<AnyPostgresType>, MongoDB returns Record<AnyMongoType>. This ensures the full range of your persistence’s native types is preserved — not narrowed down to what JSON can represent.

// Each persistence speaks its native types
let record: Record<AnySurrealType> = surreal_table.get_value(&id).await?;
let record: Record<AnyPostgresType> = pg_table.get_value(&id).await?;
let record: Record<AnyMongoType> = mongo_table.get_value(&id).await?;

// Type-safe extraction — respects the persistence's type boundaries
let name: String = record["name"].try_get::<String>().unwrap();

Record is the common currency across the framework — ReadableValueSet, WritableValueSet, ActiveRecord, and entity conversion all work through it. JSON conversion only happens at the boundary when you need AnyTable type-erasure (via from_table()).


CBOR everywhere

The SurrealDB client switched from JSON to binary CBOR, improving serialization performance and type fidelity. All backends followed — Postgres, MySQL, SQLite.


New persistences

MongoDB, CSV, and REST API persistences joined SurrealDB and SQLite. CSV evaluates conditions in-memory. MongoDB uses native BSON filters — no expression translation needed:

// Same handle_commands function — different persistences
handle_commands(SurrealDB::table("product")).await?;
handle_commands(CsvFile::table("products.csv")).await?;
handle_commands(MongoDB::table("product")).await?;

Unified error handling

vantage-core introduced VantageError with structured context — replacing the patchwork of Box<dyn Error> and .unwrap() calls:

use vantage_core::{error, util::error::Context, Result};

connection.connect()
    .with_context(|| error!("Failed to connect", dsn = &dsn, timeout = 30))?;

AnyTable goes universal

AnyTable::from_table() now wraps any datasource whose types convert to/from JSON, using an internal JsonAdapter for on-the-fly conversion. This means your generic code works with persistences that don’t even share a value type:

// Wrap a MongoDB table for use in generic code
let any: AnyTable = AnyTable::from_table(mongo_products);
let any: AnyTable = AnyTable::from_table(csv_products);
// Both work through the same interface

What’s still coming

Work in progress

Trait boundary refinements — Aggregates (get_count, get_sum) currently require SelectableDataSource but should only need TableSource, so non-query backends like MongoDB can use them directly. column_table_values_expr forces an ExprDataSource dependency that document-oriented backends don’t need.

SurrealDB reference traversal — IN subqueries return record objects instead of scalar values. Needs SELECT VALUE id — a SurrealDB-specific construct not yet in the generic Selectable trait.

Type system gaps — Vec<u8> (binary data) and Uuid need type trait implementations across backends. Bind/read paths exist — just missing the impl XxxType wiring.

Query builder — sql_fx!() macro for mixed-type function calls, Expression::empty() sweep, PostgreSQL ingress scripts.

Architecture — Transaction support, Table JOIN preserving conditions and resolving alias clashes, Condition::or() beyond two arguments, expression refactor (split Owned/Lazy).

Someday — Table aggregations (GROUP BY), disjoint subtypes pattern, replayable idempotent operations, “Realworld” example application.

The 0.4 philosophy

Don’t force every persistence into the same mould. Let each one implement what it can, carry its own types, and unlock API surface progressively. The framework adapts to the datasource — not the other way around.

What’s New in Vantage 0.5

Historical snippets

The snippets show the 0.5 API. They aren’t compiled against the current crates.

Version 0.4 made the type system precise — every backend carries its own types, and a persistence implements only what its engine supports. Version 0.5 answers the question that follows: how does code that doesn’t know your entity type talk to your data?

A CLI that lists any table, a web admin that draws forms from a YAML schema, a UI data grid pointed at whatever you hand it — none of them know your Product struct. 0.4’s answer was AnyTable: type-erasure funnelled through JSON. 0.5 replaces it with Vista, a universal, schema-bearing data handle, and builds an entire reactive stack on top of it.


Vista — the universal data handle

A Vista wraps any typed Table<DB, E> and erases both the backend and the entity. All data flows through Record<CborValue> — an ordered map of string keys to CBOR values. All schema lives on the Vista itself: columns (with types and flags), references, the id column, and a set of capability flags. Execution is delegated to a per-driver TableShell.

Table<SqliteDB, Product>   — typed entity, typed backend, compile-time safe
Vista                      — fully erased: schema-bearing, CborValue, no generics

Each backend ships a factory that turns a typed table into a Vista. The factory harvests columns, id field, title fields, and references from the table definition you already built — no extra mapping code:

use vantage_sql::prelude::*;
use vantage_vista::Vista;

let table = Product::table(db.clone());
let vista = SqliteVistaFactory::new(db).from_table(table)?;

For MongoDB it’s MongoVistaFactory, for AWS AwsVistaFactory — same shape, different import. The resulting Vista is identical regardless of which factory produced it. From there, everything is runtime introspection:

// Schema — no entity type required
for name in vista.get_column_names() {
    let col = vista.get_column(name).unwrap();
    println!("{}: {}", col.name, col.original_type);  // name: String, price: i64, ...
}
for (name, kind) in vista.list_references() {
    println!("ref: {} ({:?})", name, kind);           // ref: products (HasMany)
}

// Query — Vista delegates the value to the driver's native condition type
let mut v = vista.clone();
v.add_condition_eq("category_id", 1.into())?;
v.add_search("tart")?;                                 // fans across SEARCHABLE columns
v.add_order("price", SortDirection::Descending)?;
let rows = v.fetch_page(1).await?;

Conditions mutate; Table builds

Unlike Table’s .with_condition() (consume-and-return), Vista’s add_condition_eq / add_search / add_order mutate in place — it’s a runtime handle, not a builder. Search and order are replace semantics (calling again drops the previous one). Clone before narrowing if you need the unfiltered handle later.


CBOR, not JSON

Where AnyTable narrowed every value to serde_json::Value, Vista carries ciborium::Value end to end. CBOR preserves the type fidelity JSON loses — integer vs float, binary blobs, precise decimals — so a Record<CborValue> round-trips through the Vista layer without lossy hops. JSON conversion happens only at the boundary, when you actually need it (an HTTP response, for example), and it’s a one-liner.

This is the same carrier the backends already use internally (Surreal and SQL store CBOR; Mongo stores BSON), so wrapping a typed table into a Vista no longer crosses a JSON funnel.


Capabilities — the explicit contract

Not every backend can do everything. A CSV file can’t sort or search server-side; DynamoDB orders only by its declared sort key; a token-paginated REST API offers a forward cursor but no page numbers. Vista makes this explicit with VistaCapabilities — a struct of booleans where each driver declares exactly what it supports:

let caps = vista.capabilities();
if caps.can_search { v.add_search("query")?; }
if caps.can_fetch_page {
    let page = v.fetch_page(2).await?;          // random access
} else if caps.can_fetch_next {
    let (rows, token) = v.fetch_next(None).await?;  // forward cursor
}

Unsupported is an error, not a no-op

The flags aren’t suggestions — they’re a contract. Calling add_search() when can_search is false returns an Unsupported error. If a flag is true but the driver forgot to implement the method, you get Unimplemented instead. Both are VantageError variants you can match on. It’s better to fail clearly than to silently return an unfiltered result set — a principle 0.6 then drove through every remaining path.

UI adapters branch on these flags directly: a data grid checks can_fetch_page to decide between a scrollbar (random access) and a “load more” button (cursor-based).


Config-driven: YAML specs and Rhai scripting

A Vista no longer needs a hand-written Rust definition. Tables, columns, and relations can be declared in a YAML spec (VistaSpec) and materialized by the driver’s factory:

table: product
columns:
  - { name: name, flags: [title, searchable] }
  - { name: price }
references:
  - { name: orders, kind: has_many, foreign_key: product_id }

For everything YAML can’t express — vendor-specific expressions, derived sources, scripted reference traversal — there’s an optional Rhai DSL that compiles to native queries. The same script renders dialect-correct SQL across all three SQL backends:

let users = table("users").alias("u");
select()
    .from(users)
    .expression(users["name"])
    .where(users["age"] >= 18)
    .order_by(users["name"], "asc")
  • Automatic identifier quoting (backticks for MySQL, double quotes for Postgres/SQLite)
  • Dialect-aware primitives: date_format() → strftime() / TO_CHAR() / DATE_FORMAT()
  • group_concat() → GROUP_CONCAT (SQLite/MySQL) / STRING_AGG (Postgres)

SurrealDB gets its own Rhai vocabulary — graph traversal (graph()/recurse()), record ids (thing), $parent references, and SurrealDB-namespaced aggregates. A per-reference Rhai script can even override the default foreign-key traversal, evaluated lazily with the parent row in scope.

YAML primary, Rhai targeted

YAML stays the canonical, declarative table format. Rhai is a serializable escape hatch you reach for only when a relationship or source needs vendor expressions a YAML key can’t represent. Engine-less backends still understand a conventional uniform vocabulary (table, with_id, add_condition_eq, add_order) and only lose vendor-specific expression syntax — graceful degradation, not a hard requirement.


Contained relations and nested writes

Embedded objects and arrays — an order’s lines array, a row’s JSON column — now surface as a fully editable sub-Vista:

let order = Order::table(db)
    .with_contained_many("lines", |line| {
        line.with_column("product_id").with_column("quantity")
    });

SurrealDB backs contained relations with native nested objects and arrays; the SQL backends store them as JSON columns and patch the host column on writeback. Reads project the column into records; writes re-serialize the whole collection and patch the parent row. Contained records can even traverse out to real tables (line.product).

Insert learned to walk relations, too. Hand insert_value a record whose keys name a relation instead of a column, and Vista sequences the writes so foreign keys populate automatically: a has-one child is inserted first and its id stamped into the parent’s FK; has-many children are inserted after the parent with the parent’s id stamped into each. Arbitrary depth, same-persistence relations only.

Best-effort, non-atomic

Nested insert is best-effort and non-atomic — a mid-sequence failure leaves earlier writes committed. Transaction support is still on the roadmap.


Diorama — caching, events, and reactive views

The largest new subsystem is vantage-diorama, a layer that sits between a Vista and whatever consumes it. It does three things: caches transparently, injects capabilities the backend lacks, and routes writes wherever you want.

graph LR
    M[Master Vista] --> D[Dio]
    L[Lens<br/>cache + callbacks] --> D
    D --> F[Facade Vista]
    D --> S[Scenery]
    D -. cache + events .-> D
    style M fill:#4a7c59,color:#fff
    style L fill:#2d6a8f,color:#fff
    style D fill:#8f5a2d,color:#fff
    style S fill:#7c2d8f,color:#fff
    style F fill:#7c2d8f,color:#fff

Four words you’ll see throughout:

  • Vista — a single-backend data source (the master).
  • Lens — long-lived shared infrastructure: cache backend, lifecycle callbacks, refresh policy. Built once per application.
  • Dio — a Vista bound to a Lens. Owns the cache table, a write queue, an event bus, and a refresh task. Produced by lens.make_dio(vista).
  • Scenery — a reactive view onto a Dio (ordered tables, individual records, aggregates). The UI binds here.
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?;   // seed cache from master
            dio.cache().insert_values(rows).await?;
            Ok(())
        }})
        .refresh_every(Duration::from_secs(300))
        .build()?,
);

let products = lens.make_dio(products_vista).await?;
let mut v = products.vista();          // facade: reads from cache, writes through the queue
let rows = v.list_values().await?;     // cache hit — never touches the master

Capability injection

Diorama caches the full dataset locally and answers queries from it. So a read-only CSV Vista that can’t paginate, sort, or search server-side becomes one that can — the consumer sees a richer Vista than the backend actually supports. Register an on_write callback and the facade gains can_insert too, even though the master is read-only: writes land in the queue and you route them wherever you like (a Kafka topic, a different database).

Writes go through a queue as WriteOps; the cache updates immediately and persistence happens asynchronously. A broadcast event bus publishes DioEvents (record changed, inserted, removed, invalidated) that Sceneries subscribe to. Upstream changes — another user’s edit, a database trigger, a webhook — feed in as ChangeEvents through an on_event callback that reconciles them into the cache.

Two-pass progressive loading handles slow sources: a cheap list pass renders immediately, and expensive per-row detail hydrates lazily as rows scroll into view, keyed per query so filter/sort variants share the detail store without blocking the UI.


Cross-persistence traversal: VistaCatalog

A reference can cross a backend boundary — categories in Postgres, products in MongoDB. In 0.5 that’s no longer a Vista concern (a Vista is strictly single-backend). It moved up into vantage-vista-factory’s VistaCatalog: register a model loader per table name, then build_vista(name) materializes a Vista and traverse(relation, parent_row) resolves and narrows the related Vista regardless of which persistence backs it.

let catalog = VistaCatalog::new();
catalog.register("category", |name| postgres_factory.build(name));
catalog.register("product",  |name| mongo_factory.build(name));

let category = catalog.build_vista("category").await?;
let products = catalog.traverse("products", &category_row).await?;  // Postgres → MongoDB

More backends

0.5 widened the roster around the Vista abstraction:

CrateBackend
vantage-redbEmbedded key-value store (uses ColumnFlag::Indexed for real indexes)
vantage-awsDynamoDB and friends — Factory::for_name / from_arn return Vistas directly
vantage-cmdA shell script becomes a queryable table (separate list / detail scripts)
vantage-api-poolPooled REST API access

Each implements TableShell and slots into the same Vista surface, the same YAML/Rhai config, and the same Diorama caching — nothing downstream changes.


Migrating off AnyTable

AnyTable is gone. The carrier, AnyRecord, the CborAdapter, Table::get_ref (the AnyTable-returning one), Reference::resolve_as_any, and the model_cli runner were all deleted across the 0.5.x line.

The replacement, in one line

Anywhere you wrote AnyTable::from_table(table), write T::vista_factory().from_table(table)? to get a Vista. The typed Table::get_ref_as and Table::get_subquery_as survive untouched — they never went through AnyTable. For the row-driven case, prefer Table::get_ref_from_row.

The CLI runner story collapsed to one path: vista_cli (in vantage-cli-util), which has carried the full token grammar — operators, selectors, search, aggregates — since 0.4.5 and is what every in-tree consumer already uses.


What’s still coming

Work in progress

Transactions — nested insert and multi-step writes are best-effort and non-atomic today.

Live queries — can_subscribe is wired through the capability struct but SurrealDB live-query push is still a later pass; Diorama’s event bus is the interim path.

Multi-column ordering — add_order is single-column for now; the signature already accommodates the multi-column future.

Type system gaps — Vec<u8> (binary) and Uuid still need trait wiring across every backend.

The 0.5 philosophy

Keep the typed layer where you know your entity, and rise to a single universal handle — Vista — where you don’t. Let each backend advertise exactly what it supports, and let a caching layer fill the gaps. The framework adapts to the datasource; the consumer writes against one shape.

Migrating to 2.0

2.0 rebuilds the interface of every layer above Table<T, E>: vantage-vista, vantage-diorama and every driver’s TableShell. Two crates are gone, about twenty shell methods are gone, the Lens has four settings instead of eight hooks, and a Dio no longer changes after it is built. This guide maps each removed or renamed item to its replacement, page by page.

A typical port is mostly mechanical: fix the imports, rename the calls the compiler points at, then read the behaviour notes for the areas your code leans on. The behaviour notes matter more than the renames. A rename fails to compile; a changed default compiles and then does something else.

What changed and why

1.x had grown several ways to do most things, and a few ways to do them silently wrong: a Dio narrowed in place changed what every other holder saw, a paged view dropped the conditions its loader didn’t understand, a capability flag could claim a method the shell didn’t implement. 2.0 sorts every public type into one of three kinds and gives each kind one way to change:

KindTypesNarrowed byChanged by
ValueTable<T, E>, Vista, VistaQuery, Reference, ChangeFlashclone().with_*(), a new valueadd_* / set_* on a value you own
Shared handleLens, Diowith_* or derive(), a new handlenever after its builder
View stateTableScenery, RecordScenery, ValueScenery, Servoa builder opens a new oneset_*, by its one owner

Eleven rules hold that shape in place. The ones a migration runs into most:

  • One way to do each thing. No _counted, _ordered, _with or _sugared variants; a variant is a builder option or a parameter.
  • No silent defaults. A trait method either does the work or returns an Unsupported error.
  • Capabilities map one-to-one to methods, and a contract test calls each advertised one.
  • Narrowing is never in place on shared objects. &self on a Dio never changes what another holder sees.
  • #[non_exhaustive] on every public enum and data struct that may grow. Matches gain a _ => arm; structs are built through constructors.
  • One path per item. A type is reachable from its module or the crate root, never both, and a crate re-exports nothing from another crate outside its prelude.

The full list is in the spec (agents/specs/2026-10-11-vista-2.0/00-overview.md). Each page below opens with the rule or decision that drove its changes.

Crates and features

1.x2.0
vantage-vista-factorydeleted; vantage_vista::Catalog (references, catalog and rhai)
vantage-diorama-aggregatedeleted; vantage_diorama::aggregate (aggregates)
vantage_vista::mocks always compiledthe mocks feature
vantage_table::mocks, MockTableSource always compiledthe mocks feature
DebugTap, stats, Dio::diagnostics always compiledvantage-diorama’s diagnostics feature
Dio::take_write_worker_handle (#[doc(hidden)])vantage-diorama’s test-util feature

Neither deleted crate has a shim. Drop both from Cargo.toml, and turn on the features the code reads:

[dependencies.vantage-diorama]
features = ["diagnostics"]   # the debug stream, stats, Dio::diagnostics

[dev-dependencies.vantage-vista]
features = ["mocks"]         # MockShell

How to read this guide

PageCovers
Vistareads, conditions, order and search, capabilities, metadata, the TableShell trait
References, catalog and rhaiReference constructors, Catalog, resolvers, the rhai host API and script verbs
Dio and Lensthe Lens builder, loaders and refreshers, building and deriving a Dio, cache identity
Writes and ingressDio::flash, the writer, awaited facade writes, confinement, apply_change, reconcile
Sceneriestable, record and value sceneries: builders, setters, owned views, statuses
Aggregatesvantage_diorama::aggregate, value sceneries, derived Dios, Where matching
Surfacepreludes, private modules, #[non_exhaustive], features
Driversspec resolvers, keys: and column references:, quicksearch, per-driver notes
Downstreamwhat the vantage-ui and vantage-actions port has to change

Each change shows a “before” and an “after”. Every “before” block is compiled by the book’s tests and must fail with the error code on its fence, so a “before” that started compiling again would fail the build. A rename with no behaviour behind it goes in a table instead. If you are porting an application, start with Surface: most first-round compile errors are import paths, and the rest of the guide reads more easily once they are gone.

Why not keep the old name as an alias?

Because an alias is a second way to do the thing, forever. Rule 1 exists because 1.x had collected exactly those: fetch_page beside fetch_window, flash_patch beside flash, make_dio beside make_dio_as. Each pair looked harmless on its own, and together they meant two code paths to keep correct, two spellings in every example, and a reader who couldn’t tell which was preferred.

The usual answer is a deprecation cycle: keep the old name for a release, warn, then remove it. That is the right tool inside a major version, and a deprecated clone_shell would have been the right call for a 1.x release. 2.0 is a breaking release on purpose, and its one chance to remove the second ways without another major version. The compiler error is the migration checklist: each removed name fails at a line, and this guide names the replacement. A shim would turn that error into a warning that gets ignored, and leave the old path in the API for as long as anyone still calls it.

Checklist

  • vantage-vista-factory and vantage-diorama-aggregate are out of every Cargo.toml
  • the crates that use MockShell, DebugTap, stats or Dio::diagnostics enable mocks / diagnostics
  • the code compiles against the Surface rules: no private module paths, no prelude imports of items that left it
  • each page’s checklist is done for the areas the code uses
  • the behaviour notes on Writes and ingress and Sceneries are checked against the code that waits for a write or reads a view’s state

Vista

Vista is the type-erased Table<T, E>, and 2.0 makes it behave like one: it is Clone, owns its query, narrows with consuming with_* and changes in place only through add_* / set_* on a value you own. The TableShell behind it lost about twenty of its 44 methods. The ones that went were variants (fetch_page beside fetch_window), silent defaults (a can_* flag the shell never checked), or reads of the shell’s own state that metadata() now answers. Rule 3 drove the capability change: each Capability names one method family, and a contract test calls every one a shell advertises. The chapter to read alongside this page is Vista: the Universal Data Handle.

The snippets below run against an in-memory MockShell named teams, with rows a (red) and b (blue).

One read primitive

fetch_page, fetch_next, set_page_size and fetch_window_counted fold into fetch_window(offset, limit), which returns a Page carrying the rows and, when the shell knows it, the total. Cursor paging is fetch_cursor(token, limit).

use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let vista = teams();
let rows = vista.fetch_page(0, 10).await?;
Ok(())
}
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let vista = teams();
let page = vista.fetch_window(0, 10).await?;
let (rows, total) = (page.rows, page.total);
assert_eq!(rows.len(), 2);
let _ = total;
Ok(())
}

Every shell whose list works must answer fetch_window(0, n); Window now means “any offset”, and Vista refuses a later offset without it before the shell is called.

One condition method

add_eq_condition, add_op_condition and add_condition_eq are one add_condition(column, op, value) on an owned Vista, or with_condition to narrow a copy.

use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let mut vista = teams();
vista.add_eq_condition("name", "red")?;
Ok(())
}
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let red = teams().with_condition("name", FilterOp::Eq, "red")?;
assert_eq!(red.list_values().await?.len(), 1);
Ok(())
}

Search and order are set, not added

add_search / clear_search and add_order / clear_order become set_search(Option) and set_order(Option): None clears. A Vista holds one search and one order, so “add” described something the shells never did.

use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let mut vista = teams();
vista.add_order("name", SortDirection::Ascending)?;
Ok(())
}
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String").with_flag("orderable")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let mut vista = teams();
vista.set_order(Some(("name", SortDirection::Ascending)))?;
vista.set_search(None)?;
assert!(vista.query().order.is_some());
Ok(())
}

A query in, a residual out

with_query (strict) and with_query_partial are one with_query(&query), which returns the narrowed Vista and the residual: the clauses the shell didn’t take. Nothing is dropped silently; the caller evaluates the residual, or refuses it.

use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let query = VistaQuery::new().with_condition(Condition::new("name", FilterOp::Eq, "red"));
let (narrowed, residual) = teams().with_query_partial(&query)?;
Ok(())
}
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let query = VistaQuery::new().with_condition(Condition::new("name", FilterOp::Eq, "red"));
let (narrowed, residual) = teams().with_query(&query)?;
assert!(residual.is_empty());
let _ = narrowed;
Ok(())
}

Capabilities are a typed set

The can_* flags become vista.capabilities().has(Capability::…). can_fetch_page, can_fetch_next and can_set_page_size map to Window and Cursor; the traversal flags map to Traverse; can_count and can_invalidate have no replacement, because count always works and nothing invalidates a Vista.

use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let paged = teams().can_fetch_page();
Ok(())
}
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let paged = teams().capabilities().has(Capability::Window);
assert!(paged);
Ok(())
}

Renames

1.x2.0
get_column_names(), get_column(n), get_id_column(), get_title_columns()columns().keys(), column(n), id_column(), title_columns()
get_references(), list_references(), get_reference(n)references(), reference(n)
driver(), has_auto_id(), can_watch()driver_name(), the id column’s ColumnFlag::Auto, capabilities().has(Capability::Subscribe)
preview_query() (JSON with driver)preview() → Preview { query, note }; the driver is driver_name()
stream_vista_values, Vista::stream_valueslist_values()
get_vista_value_with_row, Vista::get_value_with_rowget_value(id); a row hint goes to an augmentation’s Fetch::Custom
shell get_vista_some_valuefetch_window(0, 1); Vista::get_some_value reads exactly that
shell insert_vista_return_id_valueTableShell::insert(None, record)
get_ref_target, get_contained_ref, contained(), get_ref_kinds, list_containedget_ref(relation, Option<&row>); the kind is reference(n)?.kind
aggregate_vista, Vista::aggregate, AggregateSpecremoved; see Aggregates
with_id(&mut self), set_namewith_id(self) -> Result<Vista>, with_name(self)
index_key, the narrowed flag, pub sourcequery().key(), is_narrowed(), map_shell
EmptyShell, Vista::emptyvantage_vista::mocks::MockShell (the mocks feature)
Column.original_type: StringOption<String>; Column::untyped(name) has no type
AnyExpression, ExpressionLikevantage-expressions

For shell authors

1.x TableShell2.0
clone_shellthe Clone supertrait (CloneTableShell)
columns(), references(), id_column()one metadata() -> &VistaMetadata
a &Vista parameter on shell methodsgone
add_raw_conditionadd_native_condition(NativeCondition)
add_search + clear_search, add_order + clear_ordersset_search(Option<&str>), set_order(Option<(&str, SortDirection)>)
capability_flag, default_errorcapabilities(), unsupported(Capability::…)
fetch_page, fetch_next, slicing a listingfetch_window -> Page, fetch_cursor, Page::from_listing (Drivers)
Preview::new(driver)Preview::new()
get_ref requireddefaults to unsupported(Capability::Traverse)
a refusal for a dotted columncondition_column_error(&metadata, column)
struct literals of VistaSpec, ColumnSpec, ReferenceSpec, ContainedYaml, NoExtrasVistaSpec::new(name), ColumnSpec::new(), NoExtras::default()
resolve_id_column(), resolve_id_column_or(d), foreign_key_or(r)resolve_id_column(default), foreign_key(relation)
VistaMetadata::with_column_at, ContainedShell, refuse_pathwith_columns_at; crate-private; condition_column_error
eval::path_get(row, "a.b")eval::cell_get(row, "a.b", &metadata)
contract::check_leading_window, Preview::default()run by contract::check_advertised; Preview::new()

Behaviour notes

  • One in-memory evaluator. vantage_vista::eval (Condition::matches, VistaQuery::matches, VistaQuery::sort_rows, cmp_rows, cell_get, cbor_cmp) is what MockShell, the facade and every local refinement use. Search is a case-insensitive substring of any cell’s text, numbers, bools, tagged values and nested cells included; a blank needle matches every row. Null and absent cells sort last in both directions, ties break on id, and 3 == 3.0.
  • Dotted paths. A condition on root.leaf that a shell can’t evaluate is refused as Unimplemented (“this shell doesn’t evaluate a dotted path”); with_query hands it back in the residual. Test is_unimplemented() rather than the message. An unknown column reads “Unknown column for condition” with a field key on every shell, and the contract’s dotted-path probe fails a shell that answers anything but a narrowing or that refusal.
  • MockShell gained calls() (MockCalls, shared by clones), get_only(), gated(..), get_log() and max_concurrent_gets(); replace hand-rolled counting and get-only test shells.
  • get_some_value reads fetch_window(0, 1) only, with no fallback to list().
  • A contained relation without a row: get_ref(rel, None) returns the bare target, with no records and no write capabilities.
  • Untyped columns. A YAML column without type: is Column::untyped; rhai columns() reports its type as (), and the rhai new_id gives it a UUIDv7. A numeric id column the rhai insert can’t fill (u128, usize, Option<i64>) now errors unless it is flagged auto or an id is passed.
  • Quicksearch reads Searchable columns, or every column when none is flagged. See Drivers.

Checklist

  • no fetch_page, fetch_next or set_page_size; windows are fetch_window → Page
  • every condition is add_condition / with_condition with a FilterOp
  • set_search / set_order take an Option; None clears
  • with_query callers handle the residual it returns
  • no can_* flags; capabilities().has(Capability::…)
  • metadata reads use the short names in the table above
  • a custom TableShell implements metadata(), derives Clone, and refuses with unsupported(..) / condition_column_error(..)

References, catalog and rhai

1.x had five ways to name “something that gives me a Vista for a name”: TargetResolver, ModelResolver, ModelLoader, ContainedRefResolver and each driver’s *SpecResolver alias. It had two relation types, vantage-vista’s Reference and vantage-vista-factory’s Relation, and a crate whose only job was a registry of loaders. 2.0 keeps one of each, following rule 1: one VistaResolver trait, one Reference, and a Catalog inside vantage-vista. The factory crate is deleted with no shim (decision 6), so each import fails and points here. The rhai layer follows rule 11: its items live under vantage_vista::rhai only, and its script verbs match the Rust ones. Read Relations: Vista, YAML and Rhai and Config-Driven Vistas for the current shape.

Reference constructors

Reference::new(name, target, kind, keys) and Reference::contained(name, kind, host) become one constructor per kind. A join can’t be built without a key, and a contained kind can’t carry one.

#![allow(unused)]
fn main() {
use vantage_vista::{JoinKey, Reference, ReferenceKind};
let players = Reference::new("players", "players", ReferenceKind::HasMany, vec![JoinKey::new("id", "team_id")]);
}
#![allow(unused)]
fn main() {
use vantage_vista::{JoinKey, Reference, ReferenceKind};
let players = Reference::has_many("players", "players", JoinKey::new("id", "team_id"));
let captain = Reference::has_one("captain", "players", JoinKey::to_target_id("captain_id"));
let kit = Reference::contains_many("kit", "kit_items");
assert!(players.kind == ReferenceKind::HasMany && captain.kind == ReferenceKind::HasOne);
assert!(kit.kind.is_contained());
}

A multi-key join comes from YAML keys: through ReferenceSpec::to_reference(relation, source_id); with_key is crate-private.

The catalog folds into vantage-vista

VistaCatalog becomes vantage_vista::Catalog. A loader receives the name it was asked for, and the catalog is a VistaResolver, so it goes anywhere a resolver is held. A name it doesn’t hold is an is_not_found() error.

use std::sync::Arc;
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Catalog, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
fn main() -> vantage_core::Result<()> {
use vantage_vista_factory::VistaCatalog;

let mut catalog = VistaCatalog::new();
catalog.register("teams", Arc::new(|| Ok(teams())));
let vista = catalog.build_vista("teams")?;
Ok(())
}
use std::sync::Arc;
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Catalog, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
fn main() -> vantage_core::Result<()> {
let catalog = Catalog::new().with("teams", |_: &str| Ok(teams()));
let vista = catalog.vista("teams")?;
assert!(catalog.vista("players").is_err());
let _ = vista;
Ok(())
}

A cross-model relation is a Reference, traversed as reference.narrow(catalog.vista(&reference.target)?, &row)?. Config-Driven Vistas has the full example.

One resolver trait

TargetResolver (Arc<dyn Fn(&str) -> Result<Vista>>) and api-client’s ModelResolver become Arc<dyn VistaResolver>. A closure still implements the trait when its parameter is typed &str; a call resolver(name) becomes resolver.vista(name).

use std::sync::Arc;
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Catalog, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
fn main() -> vantage_core::Result<()> {
use vantage_vista::rhai::TargetResolver;

let resolver: TargetResolver = Arc::new(|_: &str| Ok(teams()));
let vista = resolver("teams")?;
Ok(())
}
use std::sync::Arc;
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::{Catalog, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
fn main() -> vantage_core::Result<()> {
let resolver: Arc<dyn VistaResolver> = Arc::new(Catalog::new().with("teams", |_: &str| Ok(teams())));
let vista = resolver.vista("teams")?;
let _ = vista;
Ok(())
}

The rhai host API

The rhai items leave the crate root for vantage_vista::rhai, and DataVocab’s four constructors become DataVocab::new(terminals).with_resolver(resolver). Terminals is Describe or ReadWrite; a read-only host is ReadWrite with Writes::Denied(message), so every write throws that message.

use std::sync::Arc;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::Catalog;
fn main() {
let resolver: Arc<dyn VistaResolver> = Arc::new(Catalog::new().with("teams", |_: &str| Ok(Vista::new("teams", MockShell::new()))));
use vantage_vista::{DataVocab, Terminals, Writes};

let vocab = DataVocab::read_write(Some(resolver));
}
use std::sync::Arc;
use vantage_vista::mocks::MockShell;
use vantage_vista::prelude::*;
use vantage_vista::Catalog;
fn main() {
let resolver: Arc<dyn VistaResolver> = Arc::new(Catalog::new().with("teams", |_: &str| Ok(Vista::new("teams", MockShell::new()))));
use vantage_vista::rhai::{DataVocab, Terminals, Writes};

let vocab = DataVocab::new(Terminals::ReadWrite { limit: None, writes: Writes::Allowed })
    .with_resolver(resolver.clone());
let read_only = DataVocab::new(Terminals::ReadWrite {
    limit: Some(100),
    writes: Writes::Denied("this host is read-only".into()),
})
.with_resolver(resolver);
let _ = (vocab, read_only);
}
1.x2.0
vantage_vista::{DataVocab, Handle, RecordDraft, run_script, …}vantage_vista::rhai::{…}
vantage_diorama::rhai::{cbor_to_dynamic, dynamic_to_cbor, record_to_map}vantage_vista::rhai::{…}
DataVocab::describe(r), read(r, l), read_write(r), read_write_with(r, l, w)DataVocab::new(Terminals::Describe | ReadWrite { limit, writes }).with_resolver(r)
Terminals::Read { limit }Terminals::ReadWrite { limit, writes: Writes::Denied(msg) }
Handle::over, steps, Stepcrate-private; Handle::query() -> Result<VistaQuery>
Handle::with_base_vista(verb, FnOnce(&mut Vista))Handle::with_native_condition(verb, NativeCondition)
Handle::resolve(Option<&TargetResolver>), run_script(s, TargetResolver, ..), preview_scriptresolve(Option<&dyn VistaResolver>); the runners take Arc<dyn VistaResolver>
RecordDraft::{new_row, load, from_row}(handle, resolver, ..)the resolver rides on the handle: Drivers
eval_augment_source, AugmentSourceFn, augment_source_closure, eval_lazy_expression, lazy_value_closure, LazyValueFnremoved; Column::with_expression, or your own Source::Build closure
vantage_rhai::register_constantvantage_rhai::Namespace (ns.var(..), read as ns::name)
reject_dropped_narrowing(ast, what)reject_dropped_narrowing(ast, what, has_value)

Script changes

Scripts change in four places: the record verb, statement chaining, capability keys, and the backend query builders, which moved into namespaces so they can’t shadow table(name).

let o = table("order").record("o2");
o.status = "paid";
o.save();
if table("order").capabilities().can_insert { /* … */ }
self.where("status", "open"); self.sort("total", "desc");
select().from("product").field(ident("name"))
let o = table("order").record("o2");
o.status = "paid";
o.flash();
if table("order").capabilities().insert { /* … */ }
self.where("status", "open").sort("total", "desc")
sql::select().from("product").field(sql::ident("name"))
  • save() is flash() on rhai records and on the servo (decision 5): it sends an insert, or a patch of the changed fields only. save is “Function not found”.
  • A handle script’s value is its last statement. In modify:, augment and reference scripts, a statement that narrows a handle and drops it is an error when the script compiles; chain the calls or assign them. Watch if c { self.where(..) } with no else: end it with else { self }.
  • capabilities() keys come from Capability::iter() (insert, order, window, filter_ops, …). The dropped can_count, can_fetch_page, can_fetch_next, can_set_page_size, can_invalidate, can_traverse_* and can_build_ref_via_script read as (), so an old script doesn’t error; it reads unit. count() is always available.
  • Namespaced builders. SQL functions are sql:: for SQLite, PostgreSQL and MySQL; SurrealDB’s are surreal::, and its me anchor is surreal::me; MongoDB’s oid, date, doc are mongo::. The table("x") alias for ident is gone: write sql::ident("x"). Methods, indexers, operators and with_condition are unchanged.
  • references() reports the YAML kind names (has_one, has_many, contains_one, contains_many), and rejection() is #{ message }.
  • Writes are gated by the Vista. A denied or unsupported write throws (delete used to return false); upsert needs Update only; import_from needs Import; limit(n) reads fetch_window(0, n) with no fallback to a truncated list().

Renames

1.x2.0
Reference::contained(name, ReferenceKind::ContainsOne, host)Reference::contains_one(name, host) (contains_many likewise)
reference.narrow(target, row, coerce)reference.narrow(target, row); a driver re-tags ids in its own add_condition
Reference::foreign_key(), ReferenceKind::default()read reference.keys; name the kind
with_build_script(s) -> Self-> Result<Self>; refused on a contained kind
ContainedSpec, ContainedKind, the ContainedYaml loweringReference with a Contains* kind
ContainedRefResolver (keyed by relation name)build_contained_vista(.., Option<Arc<dyn VistaResolver>>), called with reference.target
ModelLoader, VistaCatalog::registera Catalog::with(name, loader) loader
VistaCatalog::{build_vista, has_model, model_names}catalog.vista(name) (is_not_found() for a missing name)
Relation, Relation::{single_key, multi_key, narrow}, traverse, traverse_fromReference, reference.narrow(catalog.vista(&reference.target)?, &row), or parent.get_ref(relation, Some(&row)) on one backend
vantage_diorama::prelude::VistaCatalogvantage_vista::Catalog
each shell’s “reference not found” errorvantage_vista::unknown_relation(relation), is_not_found()

Behaviour notes

  • A serialised Reference is validated: a contained kind needs host_column and no keys or build script; a join needs at least one key and no host_column.
  • Reference.target is a name a catalog holds: the spec name for YAML references, the built table’s name for typed ones (it used to be a Rust type name), so catalog.vista(&r.target) works on every driver.
  • A contained record’s resolver is called with reference.target (products), not the relation name (product), so a Catalog can be passed as it is.
  • Strict joins. Traversal narrows the target by the join’s to column, so the target must declare it. See Drivers.

Checklist

  • no vantage_vista_factory import; Catalog::new().with(..) and catalog.vista(name)
  • every Reference::new / Reference::contained is a per-kind constructor
  • resolvers are Arc<dyn VistaResolver>; closures type their parameter |name: &str|
  • rhai items are imported from vantage_vista::rhai; DataVocab::new(..).with_resolver(..)
  • scripts use flash(), chain their handle calls, read capabilities() by capability name, and call sql::, surreal:: and mongo:: builders

Dio and Lens

In 1.x a Lens had eight hooks, each handed a &Dio, and a Dio could be narrowed, re-pointed and augmented after it was built, by anyone holding it. Rule 5 ended that: a Dio is a shared handle, so &self never changes what another holder sees. Everything a Dio is (its master, its augmentations, its live follow) is chosen on a builder before build(), and narrowing derives a new Dio with its own cache identity. The Lens went the same way: four typed settings (Loader, Refresher, writer, Tuning), and a build() that checks the combination and returns Result<Arc<Lens>>. One query type, VistaQuery, flows through every load path, which is what fixed paged loads that ignored conditions. The chapter to read alongside is A Facade over a Custom API.

The Lens snippets use copy, an async fn copy(cx: LoaderCx) -> Result<()> that lists cx.master() and hands the rows to cx.reconcile.

The Lens builder

Lens::new() and LensBuilder::new() become Lens::builder(), and build() returns the Lens already in an Arc. The hooks become typed settings: on_start is Loader::eager, on_load_chunk is Loader::paged, on_refresh with refresh_every is Refresher::new(..).every(..). Every callback takes an owned LoaderCx instead of &Dio, so the let dio = dio.clone() dance goes.

use std::{sync::Arc, time::Duration};
use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx, Refresher};
use vantage_vista::prelude::*;
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Arc::new(
    Lens::new()
        .cache_in_memory()
        .on_start(|dio: &Dio| {
            let dio = dio.clone();
            async move { dio.reconcile(dio.master().list_values().await?).await }
        })
        .refresh_every(Duration::from_secs(60))
        .build()?,
);
Ok(())
}
use std::{sync::Arc, time::Duration};
use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx, Refresher};
use vantage_vista::prelude::*;
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens: Arc<Lens> = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(copy))
    .refresher(Refresher::new(copy).every(Duration::from_secs(60)))
    .build()?;
Ok(())
}

build() refuses a combination that would fail later, and names the setting in the error’s setting context: no cache, no tokio runtime, Loader::blocking without an eager start, or_eager on a non-paged loader, every(Duration::ZERO), a write queue or worker count of zero, a redb file that won’t open. LensBuildError is gone; match the context instead.

Building a Dio

make_dio(vista) and make_dio_as(vista, name) become lens.dio(vista), a builder, then .build().await?. There is no name argument: the cache identity comes from the vista (below). build() fails on a vista without an id column, naming the table, where 1.x fell back to "id".

use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?;
let dio = lens.make_dio(teams()).await?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?;
let dio = lens.dio(teams()).live().build().await?;
assert_eq!(dio.cached().list_values().await?.len(), 2);
Ok(())
}

.live() replaces dio.watch(): the follow starts at build, a subscribe failure fails the build, and over a master without Subscribe it does nothing. While a Dio with the same cache identity, augmentations (the same Arc) and live setting is held, build() returns that Dio.

Augmentation goes on the builder

Dio::augment(resolver, augmentations) after construction becomes DioBuilder::augment(..), and the resolver moves into each detail: Detail::Catalog(name) is Detail::Resolved { resolver, name }. A Fetch::Custom closure now receives (Vista, Record): the narrowed detail and a copy of the listed row.

use std::sync::Arc;
use vantage_diorama::{prelude::*, Augmentation, Cache, Detail, Fetch, MergeRule, Source};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Catalog, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder().cache(Cache::memory()).build()?;
let labels: Arc<dyn VistaResolver> = Arc::new(Catalog::new().with("labels", |_: &str| Ok(teams())));
let label = Augmentation::new(Detail::Catalog("labels".to_string()), Source::Id, Fetch::PerRow, MergeRule::lift(["name"]));
let dio = lens.dio(teams()).build().await?;
dio.augment(labels, vec![label]).await?;
Ok(())
}
use std::sync::Arc;
use vantage_diorama::{prelude::*, Augmentation, Cache, Detail, Fetch, MergeRule, Source};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Catalog, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder().cache(Cache::memory()).build()?;
let labels: Arc<dyn VistaResolver> = Arc::new(Catalog::new().with("labels", |_: &str| Ok(teams())));
let label = Augmentation::new(
    Detail::Resolved { resolver: labels, name: "labels".into() },
    Source::Id,
    Fetch::PerRow,
    MergeRule::lift(["name"]),
);
let dio = lens.dio(teams()).augment(vec![label]).build().await?;
let _ = dio;
Ok(())
}

Narrowing derives a new Dio

with_condition_eq and the mutating with_order changed every holder’s Dio. dio.derive() builds a new one and leaves the original alone; clauses the master refuses, and clauses on augmented columns, become the derived Dio’s residual(). The phase-3 Dio::with_query(&q) is dio.derive().with_query(&q).

use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
dio.with_condition_eq("name", "red").await?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let red = dio
    .derive()
    .with_condition(Condition::new("name", FilterOp::Eq, "red"))
    .build()
    .await?;
assert_eq!(dio.diagnostics().await?.cache_table, "teams");
assert!(red.diagnostics().await?.cache_table.starts_with("teams?"));
Ok(())
}

The derived Dio’s cache table is derived too: name when nothing narrows the vista, else name?<VistaQuery::key()>, with %, ? and control characters in the name percent-encoded. That replaces make_dio_as names, get_ref’s {target}-via-{relation}-{id} tables and hand-built -where- keys. A vista narrowed before lens.dio(..) gets its own table as well. Old cache files keep their old tables, which nothing opens again; sweep them with CacheBackend::list_tables and drop_table if the space matters.

Reading through the Dio

fetch_window_ordered(offset, limit, query) and its _counted twin become dio.fetch(window, &query), returning Fetched { page, refused }. A clause the master refuses comes back in refused instead of a tracing::warn! and a silent drop.

use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let (rows, total) = dio.fetch_window_ordered_counted(0, 10, &VistaQuery::new()).await?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn teams() -> Vista {
    let row = |n: &str| -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() };
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let fetched = dio.fetch(0..10, &VistaQuery::new()).await?;
let (rows, total) = (fetched.page.rows, fetched.page.total);
if !fetched.refused.is_empty() {
    // evaluate the refused clauses over a complete set, or refuse the read
}
assert_eq!(rows.len(), 2);
let _ = total;
Ok(())
}

Renames

1.x2.0
cache_at(path), cache_in_memory(), cache_source(b).cache(Cache::redb(path)), .cache(Cache::memory()), .cache(Cache::backend(b))
on_start(f) + on_start_blocking(b), on_load_chunk(f), bothLoader::eager(f).blocking(b), Loader::paged(f), Loader::paged(chunk).or_eager(start)
on_refresh(f) + refresh_every(d), cache_ttlRefresher::new(f).every(d), Refresher::max_age(ttl)
on_flash(f).writer(f): Writes and ingress
write_queue_capacity, viewport_debounce, augment_workers, refresh_on_open, LensDefaults.tuning(Tuning::default().with_write_queue(n).with_viewport_debounce(d)…)
on_event, Dio::handle_eventDio::apply_change
total_providerthe paged loader calls ChunkSink::set_total
on_list_page, on_load_detail, QueryDescriptoran Augmentation on lens.dio(v).augment(..)
activity_signal, Activity, ActivitySignal, standby_refresh_everyremoved (decision 3)
ChunkQuery handed to a chunk loaderthe view’s VistaQuery, by value
Dio::vista()Dio::cached(), the facade Vista
Dio::cache(), Dio::cache_table_name()LoaderCx::cache() / reconcile() in callbacks; dio.diagnostics().await?.cache_table
Dio::master() -> Arc<Vista>-> &Vista; also residual() and id_column()
Dio::reload(vista)build a new Dio over the new vista
LoaderCx::id_column()cx.master().id_column(), which build() guarantees
Augmentation::augment_row(.., &VistaCatalog)augment_row(dio_name, id_column, row), no resolver
Diorama, composition, master_aggregate, HotTier, dio::refresh, DioErrorremoved; none had a working use
LensBuilder: DefaultLens::builder() is the one entry point
LensCallbacks, SceneryChunkTarget, ChunkWrite, ChunkRow, FlushReport, the Dio*Callback aliasescrate-private

Behaviour notes

  • A paged loader is never called with a query it can’t answer. A paged view whose query leaves a clause the master doesn’t take (a refused condition, search or order, one on an augmented column, the Dio’s residual) is served from one whole copy made through the loader’s or_eager start; without one the view fails. A grid over a REST source without Order or Search needs Loader::paged(..).or_eager(..), and a Refresher to keep that copy fresh. The or_eager start runs only for a master without Window (it used to run for every Dio).
  • refresh() re-reads the way the set was loaded: a paged Dio announces DatasetChanged, an eager one re-runs the start, any other reconciles with master.list(). An augmented Dio runs its augmentation pass and then the refresher. There is no timer without Refresher::every, and max_age without every checks staleness at build and at open.
  • A live follow holds each pushed row against the Dio’s query: a row that left the set applies as a delete, and over native conditions every change re-reads the set. A follow that lags or loses its subscription re-reads, retrying with backoff; a failed re-read is DioEvent::LoadFailed { range: 0..0, .. }, and a failed refresh is DioEvent::RefreshFailed.
  • get_ref on a row without its join column is an error; a get_ref target inherits live but not augmentation.
  • A clause on a dotted path under an augmented column (detail.size) stays in the residual, where 1.x offered it to the master. A Dio derived with an order its master refused keeps it as the residual’s order: fetch names it in refused, and facade reads sort by it.
  • The facade advertises Search and FilterOps and evaluates what the master doesn’t take. Every facade read keeps only rows the residual admits; an order on its own reads the cache and sorts it, and only a condition or search the master takes sends the read to the master. preview().note names what is applied over the cache.

Checklist

  • every Lens is Lens::builder()….build()?, with no Arc::new around it
  • hooks are Loader, Refresher, writer and Tuning, each callback taking LoaderCx
  • every make_dio call is lens.dio(v).build().await?; .augment(..) and .live() are on the builder
  • no Dio is narrowed in place: dio.derive()…build().await?
  • dio.vista() is dio.cached(); window reads are dio.fetch(range, &query) and check refused
  • paged lenses over masters that refuse sort or search have an or_eager start

Writes and ingress

1.x wrote through a family of flash_* methods, a facade that returned before the write landed (reporting a failure only as an event), and an id-less insert that skipped the Lens writer. Changes from outside came in through patched, notify_record_changed, reconcile_values and handle_event. Rule 1 cut both sides to one call each. Outbound, every write is a ChangeFlash handed to Dio::flash, which joins the Dio’s write queue and returns once the write is confirmed or rolled back (decision 5: the outbound half of the photography lexicon). Inbound, one change is apply_change(ChangeEvent) and a full set is reconcile(snapshot). Read A Facade over a Custom API and Live Data for the current flow.

The snippets run over a Dio of teams (rows a red, b blue) whose eager loader copies the master; row(name) builds a one-column record.

One write call

flash_insert, flash_patch, flash_replace, flash_delete and flash_returning_id become dio.flash(ChangeFlash::…), which returns a FlashOutcome. ChangeFlash::new is private; each kind has its constructor (insert, insert_new, partial, replace, upsert, delete, clear), so a kind always comes with an id the queue can act on.

use vantage_diorama::{prelude::*, Cache, DioEvent, FlashOutcome, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
dio.flash_patch("a", row("green")).await?;
let id = dio.insert_returning_id(row("white")).await?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, DioEvent, FlashOutcome, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
dio.flash(ChangeFlash::partial("a", row("green"))).await?;
let id = dio.flash(ChangeFlash::insert_new(row("white"))).await?.id;
let a = dio.cached().get_value("a").await?.and_then(|r| r.get("name").cloned());
assert_eq!(a, Some(CborValue::Text("green".into())));
assert!(id.is_some());
Ok(())
}

insert_new replaces the crate-private insert_returning_id: the id-less insert now goes through the queue and through the Lens writer when there is one, and FlashOutcome::id is the id the row settled under.

The writer returns an outcome

on_flash becomes .writer(f). The callback takes (LoaderCx, ChangeFlash) and returns Result<FlashOutcome>: FlashOutcome::applied() confirms the write, .with_id(id) reports the id an id-less insert got (and re-keys the staged row onto it), .with_record(r) hands back the stored record.

use vantage_diorama::{prelude::*, Cache, DioEvent, FlashOutcome, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder()
    .cache(Cache::memory())
    .on_flash(|flash: ChangeFlash| async move {
        println!("journal: {:?}", flash.kind());
        Ok(())
    })
    .build()?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, DioEvent, FlashOutcome, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder()
    .cache(Cache::memory())
    .writer(|_cx: LoaderCx, flash: ChangeFlash| async move {
        println!("journal: {:?}", flash.kind());
        let outcome = FlashOutcome::applied();
        Ok(match flash.id() {
            Some(_) => outcome,
            None => outcome.with_id("j-1"), // an id-less insert: report the id it got
        })
    })
    .build()?;
let dio = lens.dio(teams()).build().await?;
let outcome = dio.flash(ChangeFlash::insert_new(row("green"))).await?;
let _ = outcome;
Ok(())
}

Writes on one Dio run one at a time, in order, and a writer must not flash the Dio it serves: that returns an error naming the Dio, where 1.x ran it inline. A writer that relied on concurrent calls now sees them one at a time per Dio. A writer that panics fails its flash, which is rolled back, and the queue carries on. A caller that stops waiting no longer cancels its write, and a rollback of stacked flashes restores the last confirmed row.

Changes from outside

patched, removed and notify_record_changed become apply_change(ChangeEvent); reconcile_value, reconcile_values and notify_dataset_changed become reconcile(snapshot), which makes the cache hold exactly that set; handle_event (which only dispatched to on_event) is gone.

use vantage_diorama::{prelude::*, Cache, DioEvent, FlashOutcome, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
dio.patched("a", row("green")).await?;
dio.reconcile_values(dio.master().list_values().await?).await?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, DioEvent, FlashOutcome, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
dio.apply_change(ChangeEvent::Updated { id: "a".into(), new: Some(row("green")) }).await?;
dio.reconcile(dio.master().list_values().await?).await?;
Ok(())
}

Updated { id, new: None } is the notify-only shape (notify_record_changed); Deleted { id } is removed(id); Invalidated re-reads the set. reconcile skips rows with a write in flight, deletes cached rows absent from the snapshot, stamps the refresh time and publishes one DatasetChanged. Inside a loader or refresher, cx.reconcile(rows) is the same call.

Facade writes are awaited

dio.cached() inserts, replaces, upserts, patches and deletes no longer return before the write lands. Each one awaits Dio::flash and returns once the write is confirmed, or with its error once the staged row is rolled back. Polling loops that waited for a facade write to show up can go, and the returned error has to be handled where it used to be ignored. The record a facade write hands back carries the id under the Dio’s id column, not a literal "id".

use vantage_diorama::{prelude::*, Cache, DioEvent, FlashOutcome, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let mut events = dio.subscribe_events();
dio.cached().insert_value("c", &row("green")).await?; // confirmed when this returns
dio.flash(ChangeFlash::delete("b")).await?;

while let Ok(event) = events.try_recv() {
    match event {
        DioEvent::RecordInserted { id, .. } => println!("+ {id}"),
        DioEvent::RecordRemoved { id, .. } => println!("- {id}"),
        DioEvent::RecordChanged { id, .. } => println!("~ {id}"),
        _ => {}
    }
}
Ok(())
}

A confirmed write publishes its settle event by membership: a delete is RecordRemoved, a write of a row the cache didn’t hold is RecordInserted, any other write RecordChanged. 1.x published RecordChanged for all three, so a subscriber that reacted to it after an insert or a delete handles the other two as well. A re-keyed row publishes RecordRemoved for the staged id and RecordInserted for the settled one. DioEvent::WriteFailed now announces only a failed write that staged no row (an id-less insert, Clear).

Writes are confined to the Dio’s set

A derived Dio’s writes are held to its full query. Diorama checks the residual itself: a record gets its Eq values filled in, and a record or patch result outside the set, or a write to an id held outside it, is Conflict. A patch of a row the set doesn’t hold is NotFound, and a delete of one writes nothing. Under a Lens writer the whole query is checked, and a Clear (facade delete_all) becomes one Delete per row in the set. A native import_values is refused as Conflict when one record falls outside the residual. A native condition can’t be judged in memory, so under a writer a Dio narrowed by one refuses every write, naming the clause. A write that relied on an Eq on an augmented column being filled into the record supplies the column itself. A set narrowed by a dotted path (address.city = x) records no invariant: a full-record write must hold x at address.city itself, and a patch must leave the row in the set, or it is Conflict. vantage-memory follows the same rule.

Renames

1.x2.0
flash_insert(id, r), flash_patch(id, f), flash_replace, flash_delete(id)flash(ChangeFlash::insert(id, r)), ::partial(id, f), ::replace, ::delete(id)
flash_returning_id, insert_returning_idflash(ChangeFlash::insert_new(r)).await?.id
ChangeFlash::new(FlashKind::Patch, Some(id), f)ChangeFlash::partial(id, f), one constructor per kind
writer returning Result<()>Result<FlashOutcome>: FlashOutcome::applied()
patched(id, r), removed(id), notify_record_changed(id)apply_change(ChangeEvent::Updated { id, new: Some(r) }), Deleted { id }, Updated { id, new: None }
reconcile_value(s), notify_dataset_changed, notify_seededreconcile(snapshot) or refresh(); the Dio announces a detached eager start itself
rhai record.save(), servo.save()flash()

Checklist

  • every write is dio.flash(ChangeFlash::…); no flash_* method or ChangeFlash::new
  • the Lens writer returns FlashOutcome, with .with_id(..) for id-less inserts
  • facade write callers handle the returned error and drop their polling
  • subscribe_events consumers handle RecordInserted and RecordRemoved, and end their match with _ =>
  • external changes go through apply_change or reconcile
  • writers over narrowed Dios expect one Delete per row in place of Clear

Sceneries

In 1.x two identical opens returned the same Arc, so one grid’s set_search changed what another grid showed, and an .exclusive() flag opted out. A scenery had two filter setters, a separate shown range, a cap wrapper and its own sort enum. 2.0 gives each scenery one owner: every open is its own view, and sharing moved underneath, to the fetched rows, so two views of one query still make one request (decision 7: only identical queries share). The filter vocabulary is vista’s (Condition, SortDirection), so a grid’s filter and a Dio’s narrowing are the same type. Limit, tail and search became builder options. Read Scenery: Reactive Views for the current shape.

The snippets open views on a Dio of teams (rows a red, b blue).

One sort direction, one condition type

Diorama’s SortDir and OpCondition are gone: use vantage_vista::SortDirection and vantage_vista::Condition, both in vista’s prelude.

#![allow(unused)]
fn main() {
use vantage_diorama::{OpCondition, SortDir};

let direction = SortDir::Asc;
}
#![allow(unused)]
fn main() {
use vantage_vista::prelude::*;

let direction = SortDirection::Ascending;
let not_blue = Condition::new("name", FilterOp::Ne, "blue");
let _ = (direction, not_blue);
}

The builder

where_eq and where_op are one filter(Condition); sort is order; initial_range is gone (the first viewport is 0..page_size); exclusive() is gone because every view is exclusive.

use vantage_diorama::{prelude::*, Cache, LoadState, Loader, LoaderCx, RecordStatus};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let view = dio
    .table_scenery()
    .where_eq("name", "red")
    .sort("name", SortDirection::Ascending)
    .exclusive()
    .open()
    .await?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, LoadState, Loader, LoaderCx, RecordStatus};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let view = dio
    .table_scenery()
    .filter(Condition::new("name", FilterOp::Eq, "red"))
    .order("name", SortDirection::Ascending)
    .page_size(50)
    .open()
    .await?;
let latest = dio.table_scenery().order("name", SortDirection::Ascending).tail(10).open().await?;
let _ = (view, latest);
Ok(())
}

CappedScenery::wrap(s, n) and ui-diorama’s open_tail(n) are .limit(n) and .tail(n) on the builder. tail needs an order, and the two together are refused.

The setters

set_sort(col, dir) is set_order(Option<(col, dir)>), with None clearing it. set_filters (eq pairs) and set_filter_terms (operator terms) are one set_filters(Vec<Condition>); an order-insensitively equal set is a no-op. set_shown_range merges into the viewport: set_viewport(loaded, shown), where an empty shown means nothing is on screen.

use vantage_diorama::{prelude::*, Cache, LoadState, Loader, LoaderCx, RecordStatus};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let view = dio.table_scenery().open().await?;
view.set_sort(Some("name".into()), SortDirection::Descending);
view.set_filter_terms(vec![Condition::new("name", FilterOp::Ne, "blue")]);
view.set_shown_range(Some(0..20));
if view.is_loading() { /* spinner */ }
Ok(())
}
use vantage_diorama::{prelude::*, Cache, LoadState, Loader, LoaderCx, RecordStatus};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let view = dio.table_scenery().open().await?;
view.set_order(Some(("name".into(), SortDirection::Descending)));
view.set_filters(vec![Condition::new("name", FilterOp::Ne, "blue")]);
view.set_viewport(0..50, 0..20);
match view.load_state() {
    LoadState::Loading => { /* spinner */ }
    LoadState::Failed(failure) => eprintln!("{failure}"), // shown where the grid shows "empty"
    _ => {}
}
Ok(())
}

LoadState::Failed(LoadFailure) is new, and LoadState is no longer Copy. A view fails, shows no rows and emits DioEvent::LoadFailed when the master rejects its query, or when it is a paged view whose query the master can’t answer and the loader has no or_eager start (Dio and Lens). LoadFailure.clause names the Clause: Filter(column), Order(column) or Search.

Record sceneries open through a builder

record_scenery(id).await becomes record_scenery(id).open().await, and the _sugared and _with variants become builder options. load_on_miss() is new: a cache miss reads the master, and caches the row only when the Dio admits it.

use vantage_diorama::{prelude::*, Cache, LoadState, Loader, LoaderCx, RecordStatus};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let record = dio.record_scenery_with("a", row("red")).await?;
let ready = matches!(record.status(), RecordStatus::Fresh | RecordStatus::Stale);
Ok(())
}
use vantage_diorama::{prelude::*, Cache, LoadState, Loader, LoaderCx, RecordStatus};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let record = dio.record_scenery("a").seed(row("red")).open().await?;
let ready = match record.status() {
    RecordStatus::Fresh => true,
    RecordStatus::Loading | RecordStatus::Incomplete => false,
    _ => false,
};
let _ = ready;
Ok(())
}

RecordStatus::Stale, RowStatus::{Stale, Loading, NotFound}, ValueStatus::Stale and EnrichedRecord::dirty_fields are removed; RecordStatus::Loading and Incomplete are now produced. The status enums are #[non_exhaustive], so a match ends in _ =>.

Renames

1.x2.0
request_refresh() on table, record and value sceneriesrefresh_source()
filter_terms()view_stats().filters (the Dio’s residual first, then the view’s)
terms_supported(&[OpCondition]), count_where(Vec<OpCondition>)filters_supported(&[Condition]), count_where(Vec<Condition>)
record_scenery_sugared(id, s)record_scenery(id).sugar(s).open()
SceneryDiagnostic::refcountshared_by, one per open view
ViewStats.order: Option<(String, SortDir)>Option<(String, SortDirection)>, the view’s declared order
TableScenery::demanded_columnsremoved from the trait
default bodies on TableScenery methodsevery method required; row_by_id(id) is new

Behaviour notes

  • Owned views. Two opens are two handles; Arc::ptr_eq on them is false. With diagnostics, dio.diagnostics().await?.sceneries[i].shared_by counts the views sharing one set of rows.
  • A scenery on a derived Dio layers the Dio’s residual under its own clauses and, with no sort of its own, keeps the Dio’s order.
  • Local refinement uses vista’s evaluator: cells are read with cell_get, 3 == 3.0, null and absent cells sort last, ties break on id. Two-pass views and titles-only pickers now apply their conditions, search and the Dio’s residual over the listed index; a picker whose query names an augmented column fails, since it never hydrates.
  • Augmented clauses hydrate first. A refined two-pass view over a cold cache runs the detail pass for every listed row before it settles, and reads Partial while a row’s detail fails.
  • A paged view’s loader receives every clause (filters, search, order) in its VistaQuery; set_filters on a paged view refetches the viewport. A total stated under a condition is not remembered as the table’s total.
  • search_supported() is true for a paged view with an eager fallback, so a UI shows its search box there.
  • Record, servo and value reads see only rows the Dio’s residual admits: a row outside a derived Dio’s set is NotFound or absent.

Checklist

  • no SortDir or OpCondition; SortDirection and Condition from vantage-vista
  • builders use filter, order, limit, tail, search; no exclusive() or initial_range
  • set_order(Option<..>), set_filters(Vec<Condition>), set_viewport(loaded, shown)
  • is_loading() is load_state() == LoadState::Loading, and LoadState::Failed is shown
  • record_scenery(id)…open().await?; status matches end in _ =>
  • request_refresh is refresh_source

Aggregates

1.x had two aggregate vocabularies: diorama’s own (value_scenery().count(), .sum(c), CustomAggregate) and the vantage-diorama-aggregate crate’s (Count, Sum::new, CountWhere, AggregateLens, DerivedDio), with a second Lens that persisted every value in its own cache table. 2.0 keeps one vocabulary, folded into vantage_diorama::aggregate (decision 6, no shim). A value scenery takes an Aggregation; a derived table comes from dio.aggregate(..) and is a plain Dio. Nothing is persisted: the source’s cache is the warm start, and a reopen recomputes. Read Scenery: Reactive Views for value sceneries in use.

The snippets run over a Dio of teams (rows a red, b blue).

The crate is a module

Drop the vantage-diorama-aggregate dependency and import from vantage_diorama::aggregate. value_scenery takes the aggregation, and the column aggregates are built with of.

use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
use vantage_diorama_aggregate::{Conditions, Count, Max, Where};

let last = dio.value_scenery(Max::new("name")).open().await?;
let red = dio
    .value_scenery(Where::new(Conditions::new().eq("name", "red"), Count::rows()))
    .open()
    .await?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
use vantage_diorama::aggregate::{Conditions, Count, Max, Where};

let last = dio.value_scenery(Max::of("name")).open().await?;
let red = dio
    .value_scenery(Where::new(Conditions::new().eq("name", "red"), Count::rows()))
    .open()
    .await?;
let _ = (last.value(), red.value());
Ok(())
}

A log filter on the old crate’s target moves too: RUST_LOG=vantage_diorama_aggregate=debug is RUST_LOG=vantage_diorama::aggregate=debug, and vantage_diorama=warn now covers aggregate warnings.

A derived table is a Dio

AggregateLens::in_memory().build()?.derive(&src, name, agg) becomes src.aggregate(agg).await?, and derive_value(&src, name, alias, agg) becomes src.aggregate(AsRows::new(alias, agg)). The returned Dio is the handle; DerivedDio, its dio() and its Deref are gone. Dropping every clone stops the recomputation.

use vantage_diorama::aggregate::{AsRows, Count};
use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let aggregates = AggregateLens::in_memory().build()?;
let counted = aggregates.derive(&dio, "teams.count", AsRows::new("n", Count::rows())).await?;
Ok(())
}
use vantage_diorama::aggregate::{AsRows, Count};
use vantage_diorama::{prelude::*, Cache, Loader, LoaderCx};
use vantage_types::Record;
use vantage_vista::{mocks::MockShell, prelude::*, Column, VistaMetadata};
fn row(n: &str) -> Record<CborValue> { [("name".to_string(), CborValue::Text(n.into()))].into_iter().collect() }
fn teams() -> Vista {
    let meta = VistaMetadata::new().with_column(Column::new("id", "String").with_flag("id")).with_column(Column::new("name", "String")).with_id_column("id");
    Vista::new("teams", MockShell::new().with_metadata(meta).with_record("a", row("red")).with_record("b", row("blue")))
}
async fn copy(cx: LoaderCx) -> vantage_core::Result<()> { let rows = cx.master().list_values().await?; cx.reconcile(rows).await }
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let dio = Lens::builder().cache(Cache::memory()).loader(Loader::eager(copy)).build()?.dio(teams()).build().await?;
let counted = dio.aggregate(AsRows::new("n", Count::rows())).await?;
let table = counted.cached(); // a Vista like any other
let _ = table;
Ok(())
}

Every aggregate of one source is named <source>#aggregate in the debug tap and diagnostics, whatever the aggregation, so two aggregates of one source can’t be told apart by name. They are still two Dios on two private Lenses and never share a cache.

A custom aggregation

value_scenery().custom(f), CustomAggregate and boxed_custom_aggregate become an impl Aggregation. The trait gained the schema on compute and a required reads(), which tells the Dio what the computation reads: Reads::Count (the row count only), Reads::Columns(..) or Reads::Rows. GroupReducer::reduce and the Reduce closure gain the schema the same way, and GroupReducer gains reads().

#![allow(unused)]
fn main() {
use vantage_diorama::aggregate::{Aggregation, Reads, Rows};
use vantage_vista::{CborValue, VistaMetadata};

/// The length of the longest `name`.
struct Longest;

impl Aggregation for Longest {
    type Output = CborValue;

    fn compute(&self, rows: &Rows, _schema: &VistaMetadata) -> CborValue {
        let longest = rows.values().filter_map(|r| r.get("name")?.as_text().map(str::len)).max();
        longest.map_or(CborValue::Null, |n| CborValue::from(n as i64))
    }

    fn reads(&self) -> Reads {
        Reads::Columns(vec!["name".to_string()])
    }
}
let _ = Longest;
}

AggregateOutput is sealed: an aggregation returns CborValue (a scalar, for value sceneries) or DerivedRows (a table, for dio.aggregate).

Where compares by the column’s type

Where and the column reductions convert an operand by the column’s declared type, and compare an untyped column as given. 1.x fell back to comparing rendered text, so a schemaless source loses that fallback (VAN-384).

ConditionRow1.x2.0
.eq("code", "200"){code: 200}, column untypedmatchedno match: declare type: i64, or write .eq("code", 200)
.eq("code", "200"){code: 200}, column i64matchedmatches (typed columns coerce both ways, bool included)
.eq("price", "9.99")a tag-10 decimal cellmatchedno match: write the operand in the cell’s type
.eq("id", "user:1")a record-id cellmatchedno match
.eq("a.b", x){a: {b: x}}matched the pathmatches the path; a literal "a.b" key only when the schema declares a.b

A derived GroupBy key column takes the source key’s type (it was always "String"), and is untyped when the source key has none.

Renames

1.x2.0
value_scenery().count(), .sum(c), .max(c), .min(c)value_scenery(Count::rows()), Sum::of(c), Max::of(c), Min::of(c)
value_scenery().count_where(pairs), CountWhere::new(conds)value_scenery(Where::new(Conditions::new().eq(..), Count::rows()))
Sum::new, Avg::new, Min::new, Max::new, Distinct::newSum::of and so on
AggregateLens::value(&src, name, agg)src.value_scenery(agg).open().await?
AggregateLens::{at, in_memory, with_backend, retain, close}, AggregateLensBuilderremoved: no persistence
AggregateLensBuilder::debounce(d)Tuning::default().with_aggregate_debounce(d) on the source’s Lens
AggregateValue, seed_from_cache, the per-aggregate cache tablenone; a reopen recomputes from the source’s cache
Aggregation::compute(rows)compute(rows, schema) + reads()
GroupReducer::reduce(key, rows), Reduce::new(|k, rows| ..)reduce(key, rows, schema) + reads(), Reduce::new(columns, |k, rows, schema| ..)
AggregationCatalog::group_by → GroupBy<Reduce<impl Fn>>GroupBy<GroupReduction>
Conditions::matches(&row), narrow(rows)matches(&row, &schema), narrow(rows, &schema)
AggregationSpec { column, conditions }AggregationSpec::new(column, conditions)
core Aggregate, CustomAggregateFuturean impl Aggregation

Behaviour notes

  • An aggregate over a derived Dio counts only the rows its narrowing admits: it reads the source through cached(), residual included.
  • A recomputation writes only the rows whose value changed (through Dio::reconcile), and still publishes one DatasetChanged.
  • Reads::Rows on an augmented Dio hydrates every cached row on each recompute; prefer Reads::Columns when the computation names its columns.

Checklist

  • no vantage_diorama_aggregate import or dependency
  • every value_scenery() call takes an aggregation; column aggregates use of
  • no AggregateLens, DerivedDio or derive_value: src.aggregate(..) returns a Dio
  • custom aggregations take the schema and implement reads()
  • Where conditions over untyped columns either declare the column type or write the operand in the cell’s own type

Surface

Most first-round compile errors in a port are paths. Rule 11 gives every public item one path: its module or the crate root, never both, and no crate re-exports another crate’s items outside its prelude. Rule 8 made the internal modules private. The preludes shrank to what nearly every user writes, each item under its home crate. Rule 7 put #[non_exhaustive] on the enums and data structs that may grow, so matches gain a _ => arm and structs are built through constructors. Rule 9 moved test and debug helpers behind features. None of this changes behaviour; it changes where things are found.

The preludes

CratePrelude
vantage-vistaVista, VistaQuery, Condition, FilterOp, SortDirection, Capability, VistaResolver, CborValue, and the vantage-dataset value-set traits
vantage-dioramaLens, Dio, ChangeFlash, ChangeEvent, TableScenery, RecordScenery, ValueScenery, Servo, Aggregation

Everything else is imported from the crate root. Diorama’s prelude no longer reaches Cache, Loader, LoaderCx, Refresher, Tuning, Augmentation, Detail, Fetch, MergeRule, Source, DioEvent, Generation, WriteCapabilities, CacheBackend, CacheStatus, CacheTable, RedbCache, FlashKind, FlashOutcome, EnrichedRecord, RecordStatus, RowStatus, IdStrategy, ServoStatus or vista’s VistaCatalog. Vista’s no longer reaches VistaFactory, TableShell or VistaCapabilities.

use vantage_diorama::prelude::*;
use vantage_vista::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {

let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()?;
Ok(())
}
use vantage_diorama::{prelude::*, Cache, Loader};
use vantage_vista::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {

let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()?;
let _ = lens;
Ok(())
}

A factory or shell author imports the traits from the root:

#![allow(unused)]
fn main() {
use vantage_vista::prelude::*;

fn register<F: VistaFactory>(_factory: F) {}
}
#![allow(unused)]
fn main() {
use vantage_vista::{prelude::*, VistaFactory};

fn register<F: VistaFactory>(_factory: F) {}
}

Private modules

Diorama’s augment, dio, lens, ops, scenery and servo modules are private; every public item they held is at the crate root.

use vantage_diorama::scenery::{TableScenery, RecordStatus};
use vantage_diorama::LoadState;

fn ready(view: &dyn TableScenery) -> bool {
    view.load_state() == LoadState::Complete
}
fn main() { let _ = RecordStatus::Fresh; }
use vantage_diorama::{TableScenery, RecordStatus};
use vantage_diorama::LoadState;

fn ready(view: &dyn TableScenery) -> bool {
    view.load_state() == LoadState::Complete
}
fn main() { let _ = RecordStatus::Fresh; }
1.x2.0
vantage_diorama::scenery::{table, record, value, sugar, enriched_record}::Xvantage_diorama::X
vantage_diorama::lens::CacheTable, lens::Cache, …vantage_diorama::CacheTable, …
vantage_diorama::dio::event_bus::DioEvent, ops::change_flash::ChangeFlashvantage_diorama::DioEvent, vantage_diorama::ChangeFlash
vantage_diorama::servo::Servo, augment::Augmentationvantage_diorama::Servo, vantage_diorama::Augmentation
vantage_diorama::{DebugTap, ProcessStats, process_stats}vantage_diorama::debug::{…} (the diagnostics feature)
vantage_diorama::VistaCapabilitiesvantage_vista::VistaCapabilities
vantage_diorama::debug::{dur, bytes, pct}crate-private; debug::num stays public
DioShell, dio::shellcrate-private; use the Vista from dio.cached()
vantage_vista::{DataVocab, Handle, RecordDraft, run_script, …}vantage_vista::rhai::…
vantage_memory::vista::{Catalog, MemoryTableShell, factory::…}vantage_memory::{MemoryCatalog, MemoryTableShell, MemoryVistaFactory, …}
pub mod dio::{worker, event_bus, impls, shell, hot_tier, refresh}crate-private

Matches gain a wildcard arm

An exhaustive match on a diorama enum stops compiling, because a new variant is no longer a breaking change. The enums this hits most: FlashKind, ChangeEvent, DioEvent, LoadState, Clause, RecordStatus, RowStatus, ValueStatus, ServoStatus, IdStrategy, CacheStatus, Detail, Fetch, Source, and vista’s Capability, FilterOp, ColumnFlag, ReferenceKind, ReferenceSugar, Terminals and Writes. == and matches! are unaffected.

use vantage_diorama::{prelude::*, FlashKind};
fn label(flash: &ChangeFlash) -> &'static str {
    match flash.kind() {
        FlashKind::Insert => "insert",
        FlashKind::Patch => "patch",
        FlashKind::Replace => "replace",
        FlashKind::Upsert => "upsert",
        FlashKind::Delete => "delete",
        FlashKind::Clear => "clear",
    }
}
fn main() { let _ = label(&ChangeFlash::clear()); }
use vantage_diorama::{prelude::*, FlashKind};
fn label(flash: &ChangeFlash) -> &'static str {
    match flash.kind() {
        FlashKind::Insert => "insert",
        FlashKind::Patch => "patch",
        FlashKind::Replace => "replace",
        FlashKind::Upsert => "upsert",
        FlashKind::Delete => "delete",
        FlashKind::Clear => "clear",
        _ => "write",
    }
}
fn main() { assert_eq!(label(&ChangeFlash::clear()), "clear"); }

Structs are built through constructors

A struct literal of a #[non_exhaustive] struct from another crate doesn’t compile. Reading its public fields still works.

use std::sync::Arc;
use vantage_diorama::{Augmentation, Detail, Fetch, MergeRule, Source};
use vantage_vista::{mocks::MockShell, Vista};
fn main() {
let sizes = Arc::new(Vista::new("sizes", MockShell::new()));
let size = Augmentation {
    detail: Detail::Fixed(sizes),
    source: Source::Id,
    fetch: Fetch::PerRow,
    merge: MergeRule { columns: vec!["size".to_string()] },
};
}
use std::sync::Arc;
use vantage_diorama::{Augmentation, Detail, Fetch, MergeRule, Source};
use vantage_vista::{mocks::MockShell, Vista};
fn main() {
let sizes = Arc::new(Vista::new("sizes", MockShell::new()));
let size = Augmentation::new(Detail::Fixed(sizes), Source::Id, Fetch::PerRow, MergeRule::lift(["size"]));
assert_eq!(size.merge.columns, ["size"]);
}
StructBuild it with
Augmentation, MergeRule, AggregationSpecAugmentation::new(..), MergeRule::lift(cols) / lift_all(), AggregationSpec::new(..)
EnrichedRecordEnrichedRecord::fresh(r), incomplete, pending_write, write_failed, detail_failed
TuningTuning::default().with_*(..)
VistaSpec, ColumnSpec, ReferenceSpec, ContainedYaml, NoExtrasVistaSpec::new(name), ColumnSpec::new(), deserialise, NoExtras::default()
ImportOutcome, DioDiagnostics, SceneryDiagnostic, Fetched, FlashOutcome, ViewStatsread their fields; FlashOutcome::applied()

Traits you implement

The open extension traits lost their default bodies (rule 2), so an implementation states every method:

  • TableShell: metadata(), Clone, and unsupported(..) for what it doesn’t do; see Vista.
  • CacheBackend and CacheTable: close, list_tables, drop_table and name on the backend; meta_total, meta_refreshed_at and meta_copied_at with their setters, and the *_with_status reads and writes, on the table. A wrapper forwards each one, status included, and clear forgets the refresh and copy stamps. A backend that can’t enumerate or drop tables returns an error.
  • TableScenery: every method, including row_by_id; demanded_columns left the trait.
  • Aggregation and GroupReducer: reads(), and the schema parameter (Aggregates).

Features

FeatureGates
vantage-diorama diagnosticsdebug (DebugTap, num, ProcessStats, process_stats), stats, Dio::debug_tap, LensBuilder::debug_datasource, Dio::diagnostics, DioDiagnostics, SceneryDiagnostic; a default build records no fetch ledger
vantage-diorama test-utilDio::take_write_worker_handle
vantage-vista mocksvantage_vista::mocks (MockShell, MockCalls); it enables tokio’s sync
vantage-table mocksvantage_table::mocks, prelude::MockTableSource

Dio::live_table_scenery_count() is dio.diagnostics().await?.sceneries.len(), and diagnostics() returns a Result: a cache that can’t count its rows is an error naming the table, where it read as cache_rows: 0.

Checklist

  • imports outside the preludes come from the crate root
  • no vantage_diorama::{scenery, lens, dio, ops, servo, augment}:: paths
  • every match on a vista or diorama enum ends in _ =>
  • no struct literal of a #[non_exhaustive] struct
  • diagnostics, test-util and mocks are enabled where their items are used

Drivers

The drivers’ own factory APIs (from_table, from_yaml, with_resolver) keep their shape; what changed is underneath and around them. Rule 1 removed the per-driver resolver aliases in favour of one SpecResolver<S> trait, and rule 2 removed the places where a driver quietly ignored part of a spec: keys: lists, column-level references:, and the searchable flag. Each now means the same thing on every driver. The changes from the driver cleanup (PR #437) are on this page too, since they land in the same release. Read Config-Driven Vistas and Adding a New Persistence for the current driver surface.

The spec resolver is a trait

SqliteSpecResolver, PostgresSpecResolver, MysqlSpecResolver, SurrealSpecResolver, MongoSpecResolver and GraphqlSpecResolver are gone. A factory holds Arc<dyn SpecResolver<XVistaSpec>>, and any Fn(&str) -> Option<S> closure implements the trait.

use std::collections::HashMap;
use std::sync::Arc;
use vantage_sql::prelude::*;
use vantage_sql::sqlite::vista::SqliteVistaSpec;
use vantage_sql::sqlite::vista::SqliteSpecResolver;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let specs: HashMap<String, SqliteVistaSpec> = HashMap::from([("team".to_string(), SqliteVistaSpec::new("team"))]);

let resolver: SqliteSpecResolver = Arc::new(move |name| specs.get(name).cloned());
let factory = db.vista_factory().with_resolver(resolver);
Ok(())
}
use std::collections::HashMap;
use std::sync::Arc;
use vantage_sql::prelude::*;
use vantage_sql::sqlite::vista::SqliteVistaSpec;
use vantage_vista::SpecResolver;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let specs: HashMap<String, SqliteVistaSpec> = HashMap::from([("team".to_string(), SqliteVistaSpec::new("team"))]);

let resolver: Arc<dyn SpecResolver<SqliteVistaSpec>> =
    Arc::new(move |name: &str| specs.get(name).cloned());
let factory = db.vista_factory().with_resolver(resolver);
let _ = factory;
Ok(())
}

Type the closure’s parameter. Arc::new(move |name| ..) doesn’t infer a parameter that takes every lifetime, and fails to coerce with “implementation of Fn is not general enough”. The same holds for api-client’s ModelResolver, now with_model_resolver(Arc<dyn VistaResolver>), and for any |name: &str| closure held as a VistaResolver.

Why a trait and not the alias?

An alias for Arc<dyn Fn(&str) -> Option<S>> only admits closures. A trait admits a type of your own, backed by a folder of YAML files or a hot-reloading inventory, with a resolve method you can test. The cost is the parameter annotation above, which the alias inferred for you.

The resolver rides on the handle

RecordDraft::{new_row, load, from_row} drop their resolver argument. Handle::with_resolver is public, and a named handle resolves through the resolver it carries.

use std::sync::Arc;
use indexmap::IndexMap;
use vantage_vista::rhai::{Handle, RecordDraft, Writes};
use vantage_vista::{mocks::MockShell, prelude::*, Catalog};
fn main() {
let resolver: Arc<dyn VistaResolver> = Arc::new(Catalog::new().with("row", |_: &str| Ok(Vista::new("row", MockShell::new()))));
let values: IndexMap<String, CborValue> = IndexMap::new();
let draft = RecordDraft::from_row(Handle::named("row"), Some(resolver), Writes::Allowed, "r1".to_string(), values);
}
use std::sync::Arc;
use indexmap::IndexMap;
use vantage_vista::rhai::{Handle, RecordDraft, Writes};
use vantage_vista::{mocks::MockShell, prelude::*, Catalog};
fn main() {
let resolver: Arc<dyn VistaResolver> = Arc::new(Catalog::new().with("row", |_: &str| Ok(Vista::new("row", MockShell::new()))));
let values: IndexMap<String, CborValue> = IndexMap::new();
let handle = Handle::named("row").with_resolver(resolver);
let draft = RecordDraft::from_row(handle, Writes::Allowed, "r1".to_string(), values);
let _ = draft;
}

A caller holding an Option<Arc<dyn VistaResolver>> attaches it only when present: match resolver { Some(r) => handle.with_resolver(r), None => handle }.

Specs mean the same on every driver

  • keys: is honoured. A reference with a multi-column keys: list narrows on every pair. SQL, MongoDB and SurrealDB used to narrow on foreign_key alone and silently ignore the rest; vantage-memory used to refuse keys:. A spec without keys: lowers exactly as before.
  • Column-level references: reach every driver’s metadata. client_id: { references: client } is a has-one named after the column, with the column as its foreign key; the full form { table, kind, foreign_key } keeps its own kind. A references: entry of the same name wins. Before, only the table registered it, so it was missing from the Vista’s references() on MongoDB and named by the built table on SurrealDB. MongoDB now applies its “a key is stored under its own field name” check to these too.
  • Reference.target is the spec name (table:), even when the spec reads another physical table, and a typed relation’s target is its built table’s name, so catalog.vista(&r.target) works everywhere.

Declare the foreign key on the target

Traversal narrows the target by the join’s to column, so the target has to declare it. A has-many target that leaves out its foreign key column fails the traversal with an error naming the relation and the column, where 1.x injected or ignored it:

name: product
columns:
  id: { type: string, flags: [id] }
  name: { type: string }
  bakery_id: { type: string }   # the has-many `bakery.products` narrows by this

Quicksearch reads the searchable columns

Search reads the columns flagged Searchable, or every column when none is. The evaluator, MockShell and every driver follow the rule through VistaMetadata::search_columns (vista) and Table::search_columns (vantage-table). A SQL table with only name flagged no longer searches every column; a MongoDB spec with no flag now searches every column, where it matched nothing.

#![allow(unused)]
fn main() {
use vantage_vista::{Column, ColumnFlag, VistaMetadata};

let metadata = VistaMetadata::new()
    .with_column(Column::new("id", "String").with_flag(ColumnFlag::Id))
    .with_column(Column::new("name", "String").with_flag(ColumnFlag::Searchable))
    .with_column(Column::new("notes", "String"))
    .with_id_column("id");
assert_eq!(metadata.search_columns(), ["name"]);
}

GraphQL searches the flagged columns, or every text column but the id when none is flagged. Kubernetes drops searchable_titles and searches every declared column. MemoryCondition::Search(t) is MemoryCondition::search(t), building a Search { text, columns } that reads every cell.

Any window from a listing

A shell that can only list builds its window with Page::from_listing(rows, offset, limit): any offset, the listing’s length as the total. It replaces csv’s, cmd’s and kubernetes’ own slicing and the interim Page::leading_window. Vista still refuses an offset above zero without Window before the shell is called.

use vantage_types::Record;
use vantage_vista::{CborValue, Page};
fn main() -> vantage_core::Result<()> {
let listing: Vec<(String, Record<CborValue>)> = ["a", "b", "c"].iter().map(|id| (id.to_string(), Record::new())).collect();
let page = Page::leading_window(listing, 0, 2)?;
Ok(())
}
use vantage_types::Record;
use vantage_vista::{CborValue, Page};
fn main() -> vantage_core::Result<()> {
let listing: Vec<(String, Record<CborValue>)> = ["a", "b", "c"].iter().map(|id| (id.to_string(), Record::new())).collect();
let page = Page::from_listing(listing, 2, 2);
assert_eq!((page.rows.len(), page.total), (1, Some(3)));
Ok(())
}

Renames

1.x2.0
vantage_memory::vista::Catalog, Catalog::shell(n), Catalog::get(n)MemoryCatalog, a VistaResolver: catalog.vista(n) (is_not_found() for an unknown name)
MemoryTableShell::with_untyped_columns(cols), capabilities_for_memory()Column::untyped(name); vista.capabilities()
vantage-table Reference::cardinality() -> Cardinality, foreign_key()Reference::vista_reference(name, target, source_id) -> vantage_vista::Reference
Table::with_spec_references(&spec.references, ..)with_spec_references(&spec, ..); table::spec::spec_references(spec, source_id) for metadata
Table::name_join_targets, name_spec_targets, Reference::target_type_name()removed; target_table_name(data_source)
ContainedRelation::new(name, host, kind, id_column, build)ContainedRelation::one(name, host, build), many(name, host, id_column, build)
get_contained_ref(rel, row, parent_id, ..)get_contained_ref(rel, Option<(&row, id)>, ..); None is the bare target
MockReference::new(cardinality, fk) (public)test-only
serialised Preview {driver, query, note}{query, note}; add driver from vista.driver_name() yourself
faker shape YAML capabilities fetch_next, fetch_window, count, fetch_pagecursor, window; count and fetch_page removed
request-parameter columns flagged hiddenflags: [hidden, request_param] (ColumnFlag::RequestParam)
temp_remove_condition(h) then temp_add_condition(c)replace_temp_condition(old, new); removing a stale handle is is_not_found()
build_contained_vista(.., writeback, ..)writeback: Option<ContainedWriteback>; None builds the bare target
register_constructors!(engine, value: V) and the other vantage-sql macrosregister_constructors!(engine, ns, value: V); register_engine! is unchanged

Behaviour notes

  • Dotted conditions. SQL refuses a dotted or imported column (“can’t be filtered in SQL”); MongoDB and SurrealDB evaluate root.leaf natively when root is declared; GraphQL with push-down refuses an undeclared dotted path in add_condition.
  • SurrealDB re-tags a record id held as text on the id column and on record-link columns (typed thing/record, or read by one of the table’s own has-one references) for every caller; any other column compares text as text.
  • GraphQL types each text operand by its column, so an integer column takes _eq: 5, and a non-integer text on one is an error before any request.
  • REST, GraphQL and AWS answer fetch_window(0, n) from one unpaged listing.
  • AWS and cmd apply request-parameter prefix filters (PathPrefix, S3 prefix, logGroupNamePrefix) to typed tables only; a YAML vista over them doesn’t.
  • REST, AWS and cmd client-side filters pass a row lacking the field only for a request_param column, a {placeholder} or an undeclared field; a hidden column is a row field.
  • cmd answers get(id) only with the row keyed by id, or a single id-less row; on a narrowed set, get returns nothing for ids outside it.
  • vantage-memory and faker refuse a condition on a column the vista doesn’t declare. Memory conforms seeded cells to their declared type and creates no table for an unknown traversal target. Faker counts a write only when it changed a row, and sims chain their handle calls.
  • spacetimedb refuses IN, NOT IN and LIKE (evaluated locally), advertises no writes on a keyless table, refuses writes through a narrowed handle and serves only the first window.
  • REST conditions accept declared columns and endpoint placeholders only; a non-scalar Eq value is refused, so it is evaluated locally.
  • YAML flags reach the typed column. searchable, mandatory, label, indexed, hidden and request_param all carry through Column::from_spec, and drivers with column flags (MongoDB, SurrealDB, GraphQL, SQL) show them on the Vista column.
  • Unknown relations are vantage_vista::unknown_relation on every driver: “unknown relation”, is_not_found().

Checklist

  • no *SpecResolver alias; Arc<dyn SpecResolver<XVistaSpec>> with |name: &str| closures
  • RecordDraft calls carry the resolver on the handle: Handle::named(..).with_resolver(r)
  • specs with keys: or column references: are checked against the traversals they now drive
  • has-many targets declare their foreign key column
  • the columns a search should read are flagged searchable
  • custom shells window a listing with Page::from_listing

Downstream: vantage-ui and vantage-actions

This page is the work list for the port that follows 2.0: vantage-ui, its vantage-actions dependency (through vantage-lib), the skills bundle and the example apps. It is grouped by area and names files where the earlier phases found them. Nothing here is compiled against vantage-ui; the other pages hold the snippets. The file paths are relative to each repository and were found by read-only greps, so treat them as a starting point, not a complete list.

vantage-actions

  • src/row.rs, read_only_row: the resolver moves onto the handle. RecordDraft::from_row(Handle::named("row"), Some(no_table), Writes::Denied(denial.into()), id, values) becomes RecordDraft::from_row(Handle::named("row").with_resolver(no_table), Writes::Denied(denial.into()), id, values).
  • Any resolver closure it builds types its parameter: Arc::new(|name: &str| ..). An untyped one fails with “implementation of Fn is not general enough”.

vantage-ui: resolvers and rhai

  • crates/app/vantage-ui/src/infra/actions/run/body.rs: BodyRow::Writable builds Handle::named(table), attaches the Option<Arc<dyn VistaResolver>> with match resolver { Some(r) => handle.with_resolver(r), None => handle }, and calls RecordDraft::from_row(handle, Writes::Allowed, id, fields).
  • Every SpecResolver<X> in type position is Arc<dyn SpecResolver<X>>, and every closure behind one is |name: &str|. vantage-lib’s callers need the same.
  • Quick filters (quick-filters/script.rs): Handle::steps() and Step are crate-private. Replace the steps loop with let q = handle.query()?; and derive the Dio with dio.derive().with_query(&q).build().await?. Quick filters refused sort() before; to keep that, reject q.order.is_some(). ref() and limit() errors now come from query().
  • Form submit and the wizard proxy: vantage_diorama::rhai::{cbor_to_dynamic, dynamic_to_cbor, record_to_map} are vantage_vista::rhai::…; DataVocab::read(..) and friends are DataVocab::new(Terminals::ReadWrite { limit, writes }).with_resolver(r).
  • Rhai capabilities() keys come from Capability::iter() (insert, order, window, …); the old can_* keys read as ().

vantage-ui: Lens and Dio

  • The lens module: Lens::new() / LensBuilder::new() → Lens::builder(); cache_at, cache_in_memory, cache_source → .cache(Cache::…); the hooks → Loader, Refresher, writer; LensBuildError → the error’s setting context; make_dio / make_dio_as → lens.dio(vista).build().await?.
  • Its own cache TTL map → Refresher::max_age(ttl).
  • Hand-built -where- cache keys go: a derived Dio’s cache table is name?<query key>. Old redb tables are orphaned; sweep them with CacheBackend::list_tables / drop_table if needed.
  • dio.vista() → dio.cached() everywhere.
  • Every place that narrowed a Dio (with_condition_eq, the mutating with_order, quick filters) becomes one dio.derive()…build().await?.
  • Dio::augment(resolver, ..) → lens.dio(v).augment(..), with each Detail::Catalog(name) as Detail::Resolved { resolver, name }; custom fetch closures take the listed row.
  • Entity: dio.watch() → .live() on the builder.
  • Inspection: Dio::cache_table_name() → dio.diagnostics().await?.cache_table.
  • Grids over REST sources without Order or Search fail on sort or search until their lens gives the paged loader an or_eager start and a Refresher; otherwise show the failure.
  • !vista.can_fetch_page(), used as “API-backed”, is !vista.capabilities().has(Capability::Window).

vantage-ui: writes and events

  • The crud widget and adapters: facade writes return once confirmed or rolled back; drop the polling loops and handle the returned error.
  • flash_insert / flash_patch / flash_delete → dio.flash(ChangeFlash::…); handle_event, patched, notify_*, reconcile_values → apply_change / reconcile.
  • subscribe_events consumers handle RecordInserted and RecordRemoved, not only RecordChanged, and may see LoadFailed with an empty range from a live follow.
  • Matches on FlashKind, ChangeEvent and ServoStatus (and every other diorama enum) need a _ => arm.

vantage-ui: sceneries and widgets

  • SortDir → SortDirection; OpCondition → Condition; builder where_eq / where_op → filter, sort → order; set_sort → set_order(Option<..>); set_filter_terms → set_filters(Vec<Condition>); set_shown_range → the second range of set_viewport; request_refresh → refresh_source; is_loading() → load_state() == LoadState::Loading.
  • App routing: CappedScenery / open_tail → builder limit / tail.
  • App and form: record_scenery(id).await, _sugared, _with → record_scenery(id)…open().
  • The inspector: SceneryDiagnostic::refcount → shared_by.
  • Show LoadState::Failed(failure) where a grid shows its empty state.
  • Value sceneries and dashboards: value_scenery().count() and the AggregateLens paths → value_scenery(Count::rows()), dio.aggregate(..), imported from vantage_diorama::aggregate.

vantage-ui: paths, features and diagnostics

  • vantage_diorama::scenery::… paths → the crate root.
  • shell/view_summary.rs reads debug::num, the one formatter that stays public; dur, bytes and pct are crate-private.
  • Enable vantage-diorama/diagnostics where the app opens the debug stream (debug_datasource), logs the session summary or reads diagnostics().
  • Rust ShapedShell and map_shell call sites (form-tester, select-tester, the grid’s dio.rs, select-vendored): pub source is gone; wrap with map_shell.
  • vantage-lib slot.rs and the test hosts: vantage_memory::vista::Catalog → MemoryCatalog.
  • The csv-dio and sqlite-dio loaders: struct literals of VistaSpec, ColumnSpec, ReferenceSpec → VistaSpec::new(name), ColumnSpec::new().

vantage-ui: YAML, scripts and the skills bundle

  • Shape YAML capabilities: fetch_next → cursor, fetch_window → window; count and fetch_page are removed.
  • The Preview.driver key is gone from serialised previews; the MCP preview adds driver from vista.driver_name() itself. The skills that describe preview output change with it.
  • Record and servo save() → flash() in scripts and skills.
  • SQL scripts (rhai: sources, base: transforms, mysql: { modify }, reference rhai:, select query: options) prefix each builder function with sql::; SurrealDB scripts with surreal::, me → surreal::me; MongoDB’s oid, date, doc with mongo::. table("x") as an identifier is sql::ident("x"). A missed call fails at build with “Function not found”.
  • The skills bundle (crates/domain/skills/resources/, bump metadata.version): vantage-rhai, vantage-queries, vantage-dashboards, vantage-persistence-sqlite, -postgres, -mysql, -surrealdb, -rest, vantage-debugging, vantage-components and vantage-quality reference the old builder names, can_* capability keys or preview keys.
  • vantage-ui-examples: apps/breg-bakery/table/bakery-surreal/*.yaml, apps/cashgpt/table/*.yaml and apps/cashgpt/page/*.yaml, apps/real-world/table/*.yaml use the global builder names.
  • Has-many targets declare their foreign key column; a target that leaves it out fails traversal with an error naming the relation and the column.
  • Flag the columns a search should read searchable.
  • Untyped JSON columns under a Where aggregate need a declared type, or operands written in the cell’s own type.
  • Generated schema descriptions (page-schema-1.json, table-schema-1.json) come from doc comments; regenerate them after the port.

vantage-lib

  • Its vista_loader/references.rs lowers column references: sugar itself, naming the relation after the target (author_id: { references: author } gives author), where vantage-table and vantage-memory name it after the column (author_id). Pick one name before vantage-lib drops its copy for vantage_table::table::spec::spec_references.

Checklist

  • vantage-actions read_only_row carries the resolver on the handle
  • vantage-ui compiles with no vista(), make_dio, steps() or SortDir
  • every diorama enum match in vantage-ui has a _ => arm
  • the skills bundle and the example YAML use flash(), namespaced builders and the new capability keys, and metadata.version is bumped
  • the cache TTL map and -where- keys are deleted

Expressions & Queries

Vantage builds queries without string concatenation. The vantage-expressions crate provides a type-safe, composable expression system that works across all persistence backends — SQL, SurrealDB, MongoDB, CSV, and anything you add yourself.


The core idea

An Expression<T> is a template string with typed parameters:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::identifier::ident;
use vantage_sql::sqlite_expr;
let expr = sqlite_expr!("SELECT {} FROM {} WHERE {} > {}",
    (ident("name")), (ident("product")), (ident("price")), 100i64);
// → SELECT "name" FROM "product" WHERE "price" > ?1  (with 100 bound as i64)
assert_eq!(expr.preview(), r#"SELECT "name" FROM "product" WHERE "price" > 100"#);
}

Parameters are never interpolated into the string. They’re carried separately, each tagged with a type marker from your persistence’s type system. The bind layer uses these markers to call the right driver method — bind_i64, bind_str, bind_bool — no guessing, no silent coercion. (preview() renders the parameters inline for reading; it is not what is sent.)

Three kinds of parameters:

  • Scalar — a typed value: 42i64, "hello", true
  • Nested — another expression, composed into the template
  • Deferred — a closure that executes later (cross-database resolution)

Vendor macros

Each persistence provides a convenience macro that produces Expression<AnyType> with the correct type wrapping:

#![allow(unused)]
fn main() {
use vantage_sql::{mysql_expr, postgres_expr, sqlite_expr};
use vantage_surrealdb::surreal_expr;
let e = sqlite_expr!("SELECT * FROM product WHERE price > {}", 100i64);
let e = surreal_expr!("SELECT * FROM product WHERE price > {}", 100i64);
let e = postgres_expr!("SELECT * FROM product WHERE price > {}", 100i64);
let e = mysql_expr!("SELECT * FROM product WHERE price > {}", 100i64);
let _ = e;
}

Same syntax, different type universes. The compiler ensures you can’t accidentally mix a Expression<AnySqliteType> into a SurrealDB query.


Composing expressions

Expressions nest naturally. Parenthesised arguments call .expr() automatically:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::identifier::ident;
use vantage_sql::sqlite_expr;
let condition = sqlite_expr!("{} > {}", (ident("price")), 100i64);
let query = sqlite_expr!("SELECT {} FROM {} WHERE {}",
    (ident("name")), (ident("product")), (condition));
assert_eq!(query.preview(), r#"SELECT "name" FROM "product" WHERE "price" > 100"#);
}

The ExpressionFlattener collapses all nesting into a single flat template with positional parameters — each one still carrying its type marker.

For building lists (e.g. multi-row INSERT), use Expression::from_vec:

#![allow(unused)]
fn main() {
use vantage_expressions::Expression;
use vantage_sql::sqlite_expr;
let row1 = sqlite_expr!("({}, {})", "tart", 220i64);
let row2 = sqlite_expr!("({}, {})", "pie", 299i64);
let rows = Expression::from_vec(vec![row1, row2], ", ");
let _ = rows;
}

Identifier quoting

SQL identifiers need quoting — and each database uses different quote characters. The Identifier struct handles this by implementing Expressive<T> for each backend type:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::identifier::ident;
use vantage_sql::{mysql_expr, sqlite_expr};
// Quoting adapts to the expression's type context
sqlite_expr!("SELECT {}", (ident("name")));      // → SELECT "name"
mysql_expr!("SELECT {}", (ident("name")));        // → SELECT `name`

// Qualified identifiers
sqlite_expr!("SELECT {}", (ident("name").dot_of("u")));  // → SELECT "u"."name"

// Aliases
mysql_expr!("SELECT {}", (ident("name").with_alias("n"))); // → SELECT `name` AS `n`
}

ExprDataSource — executing expressions

The ExprDataSource<T> trait connects expressions to a live database. Both calls below read a product table, so the block is compiled but not run:

use vantage_expressions::{AssociatedQueryable, ExprDataSource};
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
use vantage_core::Context;

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let expr = sqlite_expr!("SELECT name FROM product");
// Execute directly
let result: AnySqliteType = db.execute(&expr).await?;

// Associate with an expected return type
let count: i64 = db.associate::<i64>(sqlite_expr!("SELECT COUNT(*) FROM product"))
    .get().await?;
let _ = (result, count);
Ok(())
}

AssociatedExpression<'a, DS, T, R> carries both the expression and a reference to the datasource. Call .get() (from the AssociatedQueryable trait) to execute and convert in one step. The return type R is checked at compile time.


Deferred expressions — cross-database values

Sometimes a query on one database needs a value from another. defer() wraps a query as a closure that resolves at execution time. The block runs both queries against tables it doesn’t create, so it is compiled but not run:

use vantage_expressions::{ExprDataSource, Expression, ExpressiveEnum};
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
use vantage_core::Context;

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let config_db = SqliteDB::connect("sqlite:config.db").await.context("connect")?;
let shop_db = SqliteDB::connect("sqlite:shop.db").await.context("connect")?;
// Query config_db for a threshold — but don't execute yet
let threshold = config_db.defer(
    sqlite_expr!("SELECT value FROM config WHERE key = {}", "min_price")
);

// Use the deferred value in a query against shop_db
let expensive = Expression::<AnySqliteType>::new(
    "SELECT name FROM product WHERE price >= {}",
    vec![ExpressiveEnum::Deferred(threshold)],
);
let result = shop_db.execute(&expensive).await?;
// 1. Resolves deferred → calls config_db, gets 150
// 2. Binds 150 as a scalar parameter
// 3. Executes against shop_db
let _ = result;
Ok(())
}

This is not a subquery — the deferred query runs first, produces a concrete value, and that value gets bound as a regular parameter.


Selectable — the query builder interface

The Selectable<T> trait is the standard interface for building SELECT queries. Each persistence provides its own SELECT struct (SqliteSelect, SurrealSelect, PostgresSelect) implementing this trait:

#![allow(unused)]
fn main() {
use vantage_expressions::{Order, Selectable};
use vantage_sql::primitives::identifier::ident;
use vantage_sql::sqlite::statements::SqliteSelect;
use vantage_sql::sqlite_expr;
let select = SqliteSelect::new()
    .with_source("product")
    .with_field("name")
    .with_field("price")
    .with_condition(sqlite_expr!("{} = {}", (ident("is_deleted")), false))
    .with_order(sqlite_expr!("{}", (ident("price"))), Order::Asc)
    .with_limit(Some(10), None);
let _ = select;
}

Builder methods come free from the trait — with_source, with_field, with_condition, with_order (an expression and an Order: Order::Asc, Order::Desc, optionally .nulls_last()), with_limit. You only implement the mutating methods (add_field, add_where_condition, etc.).

Aggregate shortcuts clone the query and replace fields:

#![allow(unused)]
fn main() {
use vantage_expressions::Selectable;
use vantage_sql::primitives::identifier::ident;
use vantage_sql::sqlite::statements::SqliteSelect;
use vantage_sql::sqlite_expr;
let select = SqliteSelect::new().with_source("product").with_field("name");
let count = select.as_count();                              // SELECT COUNT(*) FROM ...
let total = select.as_sum(sqlite_expr!("{}", (ident("price")))); // SELECT SUM("price") FROM ...
let _ = (count, total);
}

SelectableDataSource — wiring it up

SelectableDataSource<T> connects the query builder to execution: it names the backend’s SELECT type, hands out an empty one, and runs a finished one. Here it is for ShopDB, a wrapper around a SQLite connection, mirroring the impl vantage-sql ships for SqliteDB:

#![allow(unused)]
fn main() {
use vantage_core::Result;
use vantage_expressions::traits::datasource::DataSource;
use vantage_expressions::{ExprDataSource, Expressive, SelectableDataSource};
use vantage_sql::prelude::SqliteCondition;
use vantage_sql::sqlite::statements::SqliteSelect;
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};

pub struct ShopDB(SqliteDB);

impl DataSource for ShopDB {}

impl SelectableDataSource<AnySqliteType, SqliteCondition> for ShopDB {
    type Select = SqliteSelect;

    fn select(&self) -> Self::Select {
        SqliteSelect::new()
    }

    async fn execute_select(&self, select: &Self::Select) -> Result<Vec<AnySqliteType>> {
        let result = self.0.execute(&select.expr()).await?;
        // The result is an array of row objects: one value per row.
        match result.value() {
            ciborium::Value::Array(rows) => {
                Ok(rows.iter().map(|row| AnySqliteType::untyped(row.clone())).collect())
            }
            _ => Ok(vec![result]),
        }
    }
}
}

Once implemented, table.select() returns your vendor-specific builder pre-populated with the table’s columns, conditions, and ordering — ready for execution or further customisation. The full walkthrough for a new backend is Step 4: Query Builder.


Expressive trait

Anything that implements Expressive<T> can be used inside an expression. This includes:

  • Columns — table["price"]
  • Operations — table["price"].gt(100)
  • Identifiers — ident("name")
  • Query builders — select.expr()
  • Sort orders — table["name"].desc()
  • Scalar values — 42i64, "hello", true
  • Closures — that’s what defer() returns

You can implement Expressive<T> for your own types to make them composable into the expression system.

Because conditions are themselves Expressive, operations chain across type boundaries: price.gt(10) returns a SqliteCondition, and .eq(false) works on it — reading “(price > 10) = 0”. Type safety is enforced on the first operation, at the column level; once you hold a condition, any value compatible with the backend’s any-type is accepted (price.gt(10).eq("foobar") compiles by design). Columns of the same type compare directly (price.eq(price.clone())), and operations take ownership of their arguments — clone a column you plan to reuse across conditions.

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
let price = Column::<i64>::new("price");
let cond = price.gt(10i64).eq(false);
assert_eq!(cond.into_expr().preview(), "price > 10 = 0");
}

Checklist

To build and run a query with expressions:

  1. Pick the vendor macro for your backend — sqlite_expr!, postgres_expr!, mysql_expr!, surreal_expr! — so every parameter carries that backend’s type marker.
  2. Compose with parenthesised arguments, ident(..) for names, and Expression::from_vec for lists.
  3. Build SELECTs through Selectable: with_source, with_field, with_condition, with_order(expr, Order::Asc), with_limit, and the as_count/as_sum shortcuts.
  4. Execute through ExprDataSource: execute for a raw result, associate::<R>(..).get() for a typed one, defer for a value one database computes for another.

Records: Traversal, Invariants & Hooks

Once a backend implements TableSource (see Adding a New Persistence), vantage-table gives every Table<Db, Entity> a uniform write pipeline. On each write the record flows through three stages before it reaches the datasource:

record  →  lifecycle hooks (before)  →  set invariants  →  datasource write  →  hooks (after)

This page covers the consumer-facing features built on that pipeline: traversing a relation from a loaded record, the foreign-key invariants that traversal sets up, and the lifecycle hooks you can attach for audit, validation, soft-delete, and after-effects. They are backend-agnostic — everything here works the same on SQLite, Postgres, MySQL, Mongo, etc.

Traversing from a loaded record: get_ref

Table::get_ref_as / get_ref_from_row traverse a relation from a table (both covered in Relations & Traversal). The GetRefExt trait adds the record-level equivalent: traverse straight from a loaded ActiveEntity (typed) or ActiveRecord (untyped). The block reads and writes a launch database, so it is compiled but not run:

use vantage_dataset::prelude::ActiveEntitySet;
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Launch {
    pub name: String,
}

impl Launch {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Launch> {
        Table::new("launch", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("launch_crew", "launch_id", LaunchCrew::table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct LaunchCrew {
    pub launch_id: Option<String>,
    pub astronaut_id: Option<String>,
    pub role: Option<String>,
}

impl LaunchCrew {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, LaunchCrew> {
        Table::new("launch_crew", db)
            .with_id_column("id")
            .with_column_of::<String>("launch_id")
            .with_column_of::<String>("astronaut_id")
            .with_column_of::<String>("role")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:launches.db").await.context("connect")?;
let launches = Launch::table(db);
let (id, a) = ("apollo-11", "armstrong".to_string());
use vantage_table::prelude::GetRefExt;

let launch = launches.get_entity(id).await?.expect("launch");

// A child set scoped to this launch — and carrying its foreign key (see invariants below).
let crew = launch.get_ref::<LaunchCrew>("launch_crew")?;
crew.insert_return_id(&LaunchCrew { astronaut_id: Some(a), role: Some("Pilot".into()), ..Default::default() }).await?;
Ok(())
}

get_ref::<E2>(relation) returns a Table<T, E2> scoped to the parent. For a typed ActiveEntity the entity’s id is injected into the row before traversal (so has-many relations resolve); an untyped ActiveRecord already holds the raw row and forwards directly. The same method exists on both handles — GetRefExt carries blanket implementations for each, generic over any TableSource (the handles themselves live in vantage-dataset and know nothing about Table, which is why this arrives as an extension trait).

Safe writes

Every write is retry-safe, and a write through a narrowed table only touches rows in it. An unnarrowed table is the only way to write anywhere.

The set is the table’s conditions, equality and operator alike. For a Vista or a Rhai handle it is every narrowing step that decides membership.

operationrow in the setrow outside the setno such row
delete(id)deleted; Ok(())left alone; Ok(())Ok(())
insert(id, rec)nothing written; returns the stored rowConflict, where the backend can tellinserted; the record must fit the set
patch(id, partial)applied; Conflict if the row would leave the setNotFoundNotFound
replace(id, rec)applied; Conflict if the row would leave the setConflict, where the backend can tellcreated; the record must fit the set
importskippedrejectedinserted; rejected if it doesn’t fit the set
delete_all()deletes exactly the set——

A write that isn’t allowed fails with ErrorKind::Unsupported: writes are denied, the capability is off, or the backend can’t apply one of the set’s conditions. In Rhai, delete returns false instead and the other verbs throw.

The data source enforces the set: SQL adds the conditions to the statement and checks a written row with a one-row probe; SurrealDB and MongoDB do the same in their own query language; memory, redb, DynamoDB and Dio evaluate the conditions on the row. A source that can’t apply or evaluate a condition refuses the write.

Ids: a record without an id gets a client-made UUIDv7 before the first attempt (Rhai insert, a new record, Servo IdStrategy::Uuid, IdGenerator::UuidV7), so a retry lands on the idempotent insert. A table whose ids the server makes declares it (Table::with_auto_id, YAML flags: [id, auto]) and inserts through insert_return_id, which is not retry-safe.

Eventually-consistent backends

A backend that can’t see a row before it writes can’t tell an insert of an id held outside the set from a new row. Such a backend documents what it does instead; vantage never overwrites silently. DynamoDB inserts with a conditional put, so it does detect the clash.

There are no transactions. replace and patch check the row and then write it, so a concurrent writer can change the row between the two. MySQL can’t run an UPDATE or DELETE whose condition subqueries the table being written (error 1093), so a set defined that way can’t be written through on MySQL.

Set invariants

A table narrowed by a literal column = value is a set, and every row written into it must conform to that definition. Vantage records the pair as an invariant and enforces it on write. The most common source is relationship traversal: launch.get_ref::<LaunchCrew>("launch_crew") narrows the child set by launch_id = <this launch>, so an inserted crew row’s launch_id is filled automatically.

Invariants are registered automatically wherever the scope is a plain column = value:

  • Table::with_id(id) — narrows by the id column.
  • has-many / has-one traversal (Reference::resolve_from_row) — narrows by the foreign key.
  • every literal column = value passed to add_condition / with_condition.
  • a Vista’s add_condition with FilterOp::Eq, which Vista::get_ref uses for each join key.
  • Rhai where(col, value).

Expression scopes never register an invariant. You can also set one explicitly with Table::with_invariant(column, value) / add_invariant.

On insert and replace, each invariant column is resolved by a four-way rule:

record’s value for the columnresult
absentset to the invariant value
present but nullset to the invariant value
present and equalkept
present and conflictingthe write is rejected with an error

So a child row inserted through a relation needs no foreign key (it’s filled), may state the matching one (kept), but cannot smuggle in a different one (error — it doesn’t belong to this set).

A patch never fills an invariant column. A present value must match it; a different or null value is rejected (Conflict), because it would move the row out of the set. Moving a row between sets goes through a handle whose set holds both.

use vantage_dataset::prelude::ActiveEntitySet;
use vantage_sql::prelude::*;
use vantage_table::prelude::GetRefExt;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Launch {
    pub name: String,
}

impl Launch {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Launch> {
        Table::new("launch", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("launch_crew", "launch_id", LaunchCrew::table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct LaunchCrew {
    pub launch_id: Option<String>,
    pub astronaut_id: Option<String>,
    pub role: Option<String>,
}

impl LaunchCrew {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, LaunchCrew> {
        Table::new("launch_crew", db)
            .with_id_column("id")
            .with_column_of::<String>("launch_id")
            .with_column_of::<String>("astronaut_id")
            .with_column_of::<String>("role")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:launches.db").await.context("connect")?;
let launches = Launch::table(db);
let launch = launches.get_entity("apollo-11").await?.expect("launch");
let a = "armstrong".to_string();
let crew = launch.get_ref::<LaunchCrew>("launch_crew")?;

// launch_id absent → filled from the set:
crew.insert_return_id(&LaunchCrew { astronaut_id: Some(a), role: Some("Pilot".into()), ..Default::default() }).await?;

// launch_id set to a *different* launch → Err:
assert!(crew.insert_return_id(&LaunchCrew { launch_id: Some("other".into()), ..Default::default() }).await.is_err());
Ok(())
}

Backends and InvariantValue

Enforcement needs two operations on a backend’s value type — a null check and an equality check — provided by the InvariantValue trait (vantage-types). The vantage_type_system! macro emits it for every generated Any*Type; pass a null_when: pattern (e.g. null_when: ciborium::Value::Null for the SQL backends) so genuine nulls are recognised. Non-nullable value types (e.g. CSV’s String) simply never match the null branch.

Lifecycle hooks

Attach async callbacks around writes with Table::with_hook(Hook::…). Hooks are how audit stamps, normalization, validation, soft-delete, and after-effects are expressed — generically, on any backend.

use vantage_sql::prelude::*;
use vantage_table::table::{BeforeCx, BoxFuture};
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Launch {
    pub name: String,
}

impl Launch {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Launch> {
        Table::new("launch", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<String>("created")
    }
}

fn stamp_created(cx: BeforeCx<'_, SqliteDB>) -> BoxFuture<'_, VantageResult<()>> {
    Box::pin(async move {
        cx.record.insert("created".into(), AnySqliteType::new("2026-10-14".to_string()));
        Ok(())
    })
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
use vantage_table::prelude::{Hook, Phase};
use vantage_table::table::before_hook;

let launches = Launch::table(db).with_hook(Hook::BeforeInsert(Phase::Populate, before_hook(stamp_created)));
let _ = launches;
Ok(())
}

The Hook variants

One enum carries a placement-specific closure. Each hook receives one context struct holding what’s available at its stage, so new fields can be added without breaking existing hooks:

variantwhenreceivesmay
BeforeInsert(Phase, _)before an insertBeforeCx { record: &mut Record, id: None, stored: None, context, table }mutate / Err to cancel
BeforeUpdate(Phase, _)before replace/patchBeforeCx, with id, the stored row and patch (a patch’s record holds only the patched fields; a replace’s is the whole new row)mutate / Err to cancel
BeforeSave(Phase, _)before insert and updateBeforeCxmutate / Err to cancel
BeforeDelete(_)before a deleteDeleteCx { id, record (former), context, table }Err to veto, or HookReturn::Handled to take over
AfterInsert / AfterUpdate / AfterSaveafter the write commitsAfterCx { id, record, before, context, table }side-effects
AfterDelete(_)after a deleteAfterCx (record is the former row)side-effects
BeforeDeleteAll(_) / AfterDeleteAll(_)around delete_allDeleteAllCx { context, table }Err to cancel (before) / side-effects
BeforeSelect(_)before a read: list, get, stream, count, sum/max/minSelectCx { context, table }Err to refuse the read

The table handed to a hook is entity-erased, so it can traverse relations (get_ref) and reach the datasource — enough for cross-row validation and after-effects. context is the WriteContext in scope for the write, if any.

Ordering and control flow

  • Before-write hooks run ahead of invariant enforcement, ordered by Phase: Normalize → Populate (the default) → Validate, then registration order within a phase. So normalize inputs, then derive/stamp fields, then validate the final record.
  • Returning Err from any before-hook cancels the operation before anything is written.
  • BeforeDelete returning HookReturn::Handled skips the real delete and reports success — this is how soft-delete works (patch a deleted marker, return Handled). A delete with hooks loads the row once so before/after hooks see its contents. A delete with hooks on a row outside the set runs no hooks and deletes nothing.
  • An update with after-update hooks reads the row first, through the set, so cx.before holds the row as it was; without such hooks there is no extra read.
  • delete_all runs BeforeDeleteAll / AfterDeleteAll once for the whole set, never the per-row delete hooks. The hook gets the table, whose conditions() describe the set; an error from the before hook cancels it. A soft-delete BeforeDelete doesn’t cover it: delete_all still removes the rows, so a soft-deleting table needs a BeforeDeleteAll that refuses it.
  • After-hooks run for side-effects only. Vantage favours idempotence over transactions: an after-hook failure surfaces an error but does not roll back the committed write — design after-effects to be safe to retry.

Read hooks

BeforeSelect is the one hook on the read side. It runs before every read that returns rows or facts about rows, so a rule like “only signed-in callers read invoices” lives on the table rather than in every handler that lists it. A stream yields the hook’s error as its first item. The hook reads cx.context (the same WriteContext writes see, so one scope covers a request’s reads and writes) and cx.table.conditions(), which describe the set about to be read:

#![allow(unused)]
fn main() {
use vantage_sql::sqlite::SqliteDB;
use vantage_table::table::{SelectCx, select_hook};

let signed_in_only = select_hook(|cx: SelectCx<'_, SqliteDB>| {
    Box::pin(async move {
        match cx.context.and_then(|c| c.principal.as_ref()) {
            Some(_) => Ok(()),
            None => Err(vantage_core::error!("sign in to read this table")),
        }
    })
});
let _ = signed_in_only;
}

It does not run when the table is only embedded in another query: related_in_condition, column_table_values_expr, get_*_query and get_expr_* build expressions, not reads of this table. A rule that must hold inside a subquery belongs in the table’s conditions, added when the table is built (see the build injector in Config-Driven Vistas).

A vista shell that reads the data source directly, without going through Table (a SQL count, a REST window, a live watch), calls Table::check_read first, so the hook guards those reads too.

Reads a write makes run the hook as well, so a refusing BeforeSelect also refuses:

  • an update once the table has an after-update hook, since the update reads cx.before first;
  • a delete once the table has delete hooks, since the delete reads the row for them;
  • a write whose backend reads the row back after it (an insert, replace or patch that returns the stored row).

So registering an audit hook can change whether a caller allowed to write but not to read can update. That is deliberate: a refused read must not leak the former row into a hook.

Reads served by a Dio’s cache are the exception; see the Dio note below.

Writing a hook

Hooks are boxed async closures over their context struct. before_hook, before_delete_hook and after_hook wrap a closure or a free fn into the hook type; their bounds guide closure inference:

#![allow(unused)]
fn main() {
use vantage_types::Record;
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::prelude::HookReturn;
use vantage_table::table::{BeforeCx, DeleteCx, before_delete_hook, before_hook};
use vantage_dataset::WritableValueSet;

fn now_iso8601() -> String {
    "2026-10-14T09:00:00Z".to_string()
}

// before-insert: stamp an audit field
let stamp_created = before_hook(|cx: BeforeCx<'_, SqliteDB>| {
    Box::pin(async move {
        cx.record.insert("created".into(), AnySqliteType::new(now_iso8601()));
        Ok(())
    })
});

// before-delete: soft-delete instead of removing the row
let soft_delete = before_delete_hook(|cx: DeleteCx<'_, SqliteDB>| {
    Box::pin(async move {
        let mut patch = Record::new();
        patch.insert("deleted".into(), AnySqliteType::new(now_iso8601()));
        cx.table.patch_value(cx.id.clone(), &patch).await?;
        Ok(HookReturn::Handled)
    })
});
let _ = (stamp_created, soft_delete);
}

Who made a write comes from the WriteContext: a host wraps its work in WriteContext::new().with_principal(p).with_group(g).scope(fut), and every write awaited inside it hands the context to its hooks as cx.context. Outside a scope, cx.context is None. The context and the hook context structs are #[non_exhaustive]: build a WriteContext with new() and the with_* builders, and read a cx by field or destructure it with .., so a field added later breaks neither.

Writes through a Dio need one more step, because a Dio runs them on its own worker task where the caller’s scope doesn’t reach. Dio::flash records the context in scope where it is called on the ChangeFlash, and the worker runs the write inside it, so the master table’s hooks (and a Lens writer) see the same context. ChangeFlash::with_context sets one explicitly. A clear the Dio runs as one delete per row passes its context to each of those deletes.

Reads are another matter. A Dio’s list, count and window serve its cache, which the loader fills from its own task with no WriteContext in scope, so reads through a Dio don’t run the master table’s BeforeSelect per caller. A per-caller read rule belongs in the conditions of the table the Dio is built over, or in a Dio per principal.

Relations & Traversal

Vantage treats a relation as something you traverse, not something you load. Declared once on the model, a relation gives you:

  1. Set-to-set traversal — narrow “clients” down to the paying ones, traverse, and you hold “orders of paying clients” as a new set. One query when it executes; no loop over IDs.
  2. Related values inside queries — a client row carrying its order_count, an order carrying its client.name — computed by the database through correlated subqueries or native link paths.
  3. The same vocabulary on every backend — relations are declared on the model, not read from foreign-key constraints, so CSV files, REST APIs, and document stores get the same treatment as SQL.
  4. Relations that survive the stack — the declaration travels from the typed Table through the type-erased Vista into the cached Dio; where two datasources meet, the Dio stitches the join client-side.

If you come from an ORM, the shape is different enough to spell out:

typical ORMVantage
Traversal returnsloaded objects (lazy or eager)a new set — a query definition, no data loaded
N+1 queriesmanaged with eager-loading hintsdon’t arise: traversal is one subquery
Declaredderived from schema / migrationson the model; works without FK constraints
Backendsthe SQL databaseSQL, SurrealDB, MongoDB, CSV, REST, DynamoDB, …
Related fieldsJOIN and map, or embedded objectssubquery expressions, implicit references
Across two databasesout of scopecatalog traversal; augmentation joins per row in the cache

Our running example is bakery_model3 — an example model crate that ships in the Vantage repository. It defines one set of entities (bakeries, clients, orders, products) with table constructors for several datasources — SQLite, PostgreSQL, SurrealDB, MongoDB, CSV, DynamoDB — and doubles as a test fixture for the framework itself. Model-Driven Architecture walks through its anatomy; here we just borrow it as the example.

Three of its tables belong together: a Bakery has many Clients, and each Client has many Orders. In SQL terms, client.bakery_id points at a bakery and client_order.client_id points at a client:

erDiagram
    bakery ||--o{ client : "client.bakery_id"
    client ||--o{ client_order : "client_order.client_id"

Almost every real question you’d ask of this data crosses one of those links — “orders of paying clients”, “which bakery does this order belong to” — so this guide is about how Vantage models those links and how you cross them.

You met relations briefly in the Introduction, where a product catalog picked up with_many and get_ref_as; this guide covers the same machinery in depth, with a fuller model. We work SQL-first here (SQLite), with SurrealDB and other backends in notes along the way. The chapters after this one dig into traversal forms, subquery expressions, implicit references, and how relations survive into the type-erased Vista and cached Dio layers — this page sets up the vocabulary they all share.

One relation, four layers

An ORM or a data mapper typically has exactly one place where references live: on the mapped object. client.orders() loads objects, and that is the whole story. In Vantage the same declared relation is traversable at (approximately) four layers. Each layer traverses with what it has in hand and gives you back a handle native to that layer — you never drop down a level to cross a link:

The traversal method is called get_ref at every layer — what changes is what you hold when you call it, and when you’d want to:

levelwhen you reach for it
Record → TableYou hold an ActiveRecord or ActiveEntity — physical row data, loaded. client_record.get_ref("orders")? hands back a table narrowed to that client. Models typically wrap this in typed methods, so application code calls client_record.ref_orders() and gets a Table<SqliteDB, Order>.
Table → TableTrue set traversal — no data loaded on either side. Start with the table of VIP clients, traverse to their orders, aggregate: one query, and you hold the sum of all VIP clients’ orders.
Vista → VistaType-erased, for generic consumers. Record and table traversal stay inside one datasource; a Vista respects the relationships declared on the wrapped table, and a Catalog builds models by name, so a Reference can narrow a target that lives in a different datasource.
DioNot a new traversal — the caching layer over the ones above. A traversed detail set gets its own live cache, and augmentation merges a related source’s columns into the master’s cached rows, one visible row at a time.

Why is there a Dio layer at all, if the catalog already crosses datasources? Because catalog traversal navigates — it hands you the related set, and every read of it is a live round-trip. The Dio owns what navigation lacks: a cache, a viewport, and change events. It caches each traversed detail set, and it hosts augmentation — not navigation to related rows but a per-row join into the master’s rows, which needs exactly that cache and viewport. Relations and Dio works through both.

The rest of this page covers the first vocabulary — declaring the relation. The child chapters then walk the layers: tables, vistas, and dio.

What a relation is

A relation in Vantage is a declared, named link between two table definitions. It lives on the table definition — the model — not on the entity struct, and not in the database. Vantage never introspects foreign-key constraints; whether your SQLite schema actually declares FOREIGN KEY (client_id) REFERENCES client(id) is invisible to it. A relation is exactly three things:

  1. A name — the string you traverse by, like "orders".
  2. A foreign-key field — the column that carries the link.
  3. A target-table constructor — a function that builds the table on the other end.

Two properties follow from this shape. References are one-sided: a declaration gives this table a way to reach the target — nothing is declared on the target, and no back-reference is required. If you want to traverse the other direction, declare that separately (the bakery model does: orders on Client, client on Order).

And because traversal produces sets, the same pair of tables can be linked by any number of references — same target, same foreign key, different conditions. The constructor argument is what makes this natural: pass a closure that narrows the target, and the relation names a meaningful subset, not just a foreign key:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Invoice {
    pub client_id: Option<String>,
    pub status: String,
}

impl Invoice {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Invoice> {
        Table::new("invoice", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_column_of::<String>("status")
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Client {
    pub name: String,
}

pub fn client_table(db: SqliteDB) -> Table<SqliteDB, Client> {
    Table::new("client", db)
        .with_id_column("id")
        .with_column_of::<String>("name")
.with_many("paid_invoices", "client_id", |db| {
    let mut t = Invoice::table(db);
    t.add_condition(t["status"].eq("paid"));
    t
})
.with_many("due_invoices", "client_id", |db| {
    let mut t = Invoice::table(db);
    t.add_condition(t["status"].eq("due"));
    t
})
}
}

With the extension-trait convention (below), application code reads client.ref_paid_invoices() / client.ref_due_invoices() — one invoice table underneath, two named ways to reach it.

Why not read the foreign keys from the database?

Because most of the backends Vantage talks to don’t have any. CSV files, REST APIs, and MongoDB have no foreign-key concept to introspect, yet their data is just as related. Declaring the relation on the model means the same declaration works everywhere — the link between clients and orders exists whether the rows live in Postgres or in a spreadsheet export.

Two cardinalities

There are two declaration methods, and the difference between them comes down to one question: which table holds the foreign key?

  • with_one("name", "fk_field", constructor) — the FK lives on this table. Traversal yields at most one row: many-to-one. An order has one client.
  • with_many("name", "fk_field", constructor) — the FK lives on the target table. Traversal yields a set: one-to-many. A client has many orders.

Both declarations describe the same underlying link — one foreign-key column, viewed from either end:

graph LR
    order["client_order<br/>(carries client_id)"]
    client["client<br/>(has id)"]
    order -- "with_one('client')" --> client
    client -- "with_many('orders')" --> order

The two are more similar than the names suggest. Both take the same closure and use it the same way: build the target table, then pin it with a single eq-condition. The only difference is the condition’s orientation — with_one reads the foreign key out of the source row and pins the target’s id; with_many reads the source row’s id and pins the target’s foreign-key column. Same closure, same eq-condition, read from opposite ends of the link.

Internally these become HasOne and HasMany implementations of the Reference trait (vantage-table/src/references/), and the cardinality is surfaced as ReferenceKind::HasOne / ReferenceKind::HasMany for anything that needs to reason about relations generically — the vista layer will, later.

You could write it yourself

with_one, with_many, and get_ref are convenience. A traversal is just “build a table, add a condition from what I’m holding” — and nothing stops you from writing exactly that as an extension method on a loaded record, without registering any reference at all. It doesn’t even have to stay in the same datasource. Here the client lives in SQLite and the mail log is a CSV export:

#![allow(unused)]
fn main() {
use vantage_csv::operation::CsvOperation;
use vantage_csv::{AnyCsvType, Csv};
use vantage_dataset::ActiveEntity;
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::table::Table;
use vantage_types::entity;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Client {
    pub name: String,
    pub email: String,
}

#[entity(CsvType)]
#[derive(Debug, Clone, Default)]
pub struct MailLogEntry {
    pub email: String,
    pub subject: String,
}

impl MailLogEntry {
    pub fn table(csv: Csv) -> Table<Csv, MailLogEntry> {
        Table::new("mail_log", csv)
            .with_column_of::<String>("email")
            .with_column_of::<String>("subject")
    }
}

pub trait ClientMailLog {
    fn ref_mail_log(&self) -> Table<Csv, MailLogEntry>;
}

impl<'a> ClientMailLog for ActiveEntity<'a, Table<SqliteDB, Client>, Client> {
    fn ref_mail_log(&self) -> Table<Csv, MailLogEntry> {
        let mut log = MailLogEntry::table(Csv::new("exports"));
        log.add_condition(log["email"].eq(AnyCsvType::new(self.email.clone())));
        log
    }
}
}

(self.email works because ActiveEntity derefs to the entity struct — the loaded Client’s fields are right there. An untyped ActiveRecord derefs to the raw Record instead, where the same value is self.get("email").)

From the caller’s side client.ref_mail_log() is indistinguishable from a declared relation — but there is no foreign key here, no registry entry, just a CSV-backed table conditioned by the client’s email. What the declared forms add over hand-rolling is everything that needs the registry: string-addressable traversal for the generic layers (vistas, implicit references), and the foreign-key invariant on inserts. Reach for a hand-rolled method when the link doesn’t fit an FK eq-match; declare a relation when you want the machinery.

Coercion — pinning get_ref’s type

A relation is addressed by string, and a string carries no type. get_ref therefore cannot infer what entity comes back — it is generic, and the caller chooses: get_ref::<Order>("orders"). The turbofish is an assertion, not a check: the registry knows which table the relation builds, but the entity type you name is simply coerced onto the result. Name the wrong one and you find out at runtime, when rows fail to deserialize — not from the compiler.

This uncertainty is exactly why the model wraps every relation once, in an extension trait next to the table definition:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_one("client", "client_id", Client::sqlite_table)
    }
}

pub trait ClientTable {
    fn ref_orders(&self) -> Table<SqliteDB, Order>;
}

impl ClientTable for Table<SqliteDB, Client> {
    fn ref_orders(&self) -> Table<SqliteDB, Order> {
        self.get_ref_as("orders").unwrap()
    }
}
}

And the same on the other side — Order gets ref_client():

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_one("client", "client_id", Client::sqlite_table)
    }
}

pub trait OrderTable {
    fn ref_client(&self) -> Table<SqliteDB, Client>;
}

impl OrderTable for Table<SqliteDB, Order> {
    fn ref_client(&self) -> Table<SqliteDB, Client> {
        self.get_ref_as("client").unwrap()
    }
}
}

The coercion happens in one place — where the right answer is obvious — and the unwrap() is safe there too: "orders" is declared on every Client table the model produces, so a typo panics in development, not silently at runtime. This is the convention to follow throughout a model crate: every declared relation surfaces as a typed ref_<name>() method, callers write clients.ref_orders() / orders.ref_client(), and the strings and turbofish exist in exactly one place with no opportunity to name the wrong entity. The Introduction used the same pattern, and Model-Driven Architecture covers where these traits live in a model crate.

Declaring relations on the model

Here is Client from bakery_model3/src/client.rs — the entity struct and its SQLite table constructor. The real file lists six type systems in #[entity(..)], one per backend it targets; this excerpt keeps SQLite’s:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Bakery {
    pub name: String,
}

impl Bakery {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Bakery> {
        Table::new("bakery", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
    pub is_deleted: bool,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_column_of::<bool>("is_deleted")
            .with_one("client", "client_id", Client::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub email: String,
    pub contact_details: String,
    pub is_paying_client: bool,
    pub bakery_id: Option<String>,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, 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::sqlite_table)
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}
}

Notice that the struct knows nothing about relations. bakery_id is just an Option<String> field like any other; the links are declared on the table definition, where the columns and conditions already live.

And here is the other end, from order.rs:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
    pub is_deleted: bool,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_column_of::<bool>("is_deleted")
            .with_one("client", "client_id", Client::sqlite_table)
    }
}
}

The same underlying relation appears from both ends: Client declares with_many("orders", "client_id", …) and Order declares with_one("client", "client_id", …). Each side is declared independently and each is usable independently — you could declare only one of them if you never traverse the other direction. And both name the same column: "client_id" on the client_order table. For with_many that’s the FK on the target table; for with_one it’s the FK on the source — here Order happens to be the FK-carrying side both times.

The constructor argument

The third argument — Order::sqlite_table — is not a table, it’s a function. It’s the very same constructor the model already exposes; you’re just passing it by name instead of calling it. That buys two things. First, declaring a relation costs nothing up front: the target table is only constructed when the relation is actually traversed. Second, and more importantly, when the target is constructed, it’s the real model definition — its own columns, its own conditions, its own relations. If Order::sqlite_table filtered out soft-deleted rows, every traversal that reaches orders through a relation would inherit that filter. There is no second, weaker definition of “orders” that relations quietly use.

A first traversal

Traversal is set-to-set: narrow the source set, traverse, and the target set arrives already narrowed by the relationship. Here’s “orders of paying clients”:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub is_paying_client: bool,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<bool>("is_paying_client")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_one("client", "client_id", Client::sqlite_table)
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let mut paying = Client::sqlite_table(db.clone());
paying.add_condition(paying["is_paying_client"].eq(true));

// Table<SqliteDB, Order>, narrowed to orders of paying clients
let orders = paying.get_ref_as::<Order>("orders")?;
let _ = orders;
Ok(())
}

No query has run yet — orders is a Table<SqliteDB, Order> like any other, and you can add conditions, select columns, or traverse further. When it does execute, the narrowing happens via a subquery — not a JOIN, and not a round-trip to fetch client IDs first. The shape (illustrative):

SELECT ... FROM "client_order"
WHERE "client_id" IN (SELECT "id" FROM "client" WHERE "is_paying_client" = 1)

This is the payoff of set-to-set thinking: “orders of paying clients” is one narrowing plus one traversal, executed as a single query — never a loop over IDs.

In application code, the string and the turbofish disappear behind the wrapper from the coercion section above:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
    }
}

pub trait ClientTable {
    fn ref_orders(&self) -> Table<SqliteDB, Order>;
}

impl ClientTable for Table<SqliteDB, Client> {
    fn ref_orders(&self) -> Table<SqliteDB, Order> {
        self.get_ref_as("orders").unwrap()
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let paying = Client::sqlite_table(db);
let orders = paying.ref_orders();   // Table<SqliteDB, Order> — type inferred, no string
let _ = orders;
Ok(())
}

The next chapter covers the full range of traversal forms; this is just the first taste.

SurrealDB stores relations as record links — the FK column holds a typed record id (a Thing, rendered table:key) rather than a scalar. The declaration shape is identical; only the field differs. From the same bakery_model3/src/client.rs:

#![allow(unused)]
fn main() {
use vantage_surrealdb::surrealdb::SurrealDB;
use vantage_surrealdb::thing::Thing;
use vantage_surrealdb::types::AnySurrealType;
use vantage_table::table::Table;
use vantage_types::entity;

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Bakery {
    pub name: String,
}

impl Bakery {
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Bakery> {
        Table::new("bakery", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub is_deleted: bool,
}

impl Order {
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Order> {
        Table::new("order", db)
            .with_id_column("id")
            .with_column_of::<Thing>("client")
            .with_column_of::<bool>("is_deleted")
    }
}

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub email: String,
    pub contact_details: String,
    pub is_paying_client: bool,
}

impl Client {
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)
}
}
}

What changes is the column’s content, not its name — that is a database-design choice. Any column can hold a record link: you could equally keep calling it bakery_id and store a Thing in it, and the declaration would name that column instead. bakery_model3 happens to follow the common SurrealDB convention of naming the link column after the target ("bakery"), which is why its declarations differ from the SQLite ones only in that argument. Either way, the relation name is free to differ from the link field; nothing couples them.

Other backends

MongoDB, CSV, and DynamoDB declare relations exactly the same way, with scalar foreign keys — bakery_model3 has mongo_table, csv_table, and dynamo_table constructors of the same shape. Traversal itself is easy and every table supports it: it only attaches a condition to the target. The heavy lifting is honouring that condition when the related data is actually fetched — SQL pushes it down as a WHERE, while CSV filters in memory to fulfil the same Table contract. The next chapter covers what each traversal form generates per backend.

Writing into a traversed set

A traversed set is not read-only. Traverse from a loaded client, insert into the result, and the new record belongs to the set you traversed to:

use vantage_dataset::prelude::ActiveEntitySet;
use vantage_sql::prelude::*;
use vantage_table::prelude::GetRefExt;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
    pub is_deleted: bool,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_column_of::<bool>("is_deleted")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:bakery.sqlite").await.context("connect")?;
let clients = Client::sqlite_table(db);
let client = clients.get_entity("marty").await?.expect("client exists");

let orders = client.get_ref::<Order>("orders")?;
orders.insert_return_id(&Order::default()).await?;
Ok(())
}

The order’s client_id is filled in from the client — you don’t set it, and if you set a conflicting one the write is rejected. The block reads a populated bakery database, so it is compiled but not run here. How that enforcement works (set invariants, lifecycle hooks) is covered in Records: Traversal, Invariants & Hooks.

Where this guide goes

You now have the vocabulary: a relation is a named (name, fk_field, constructor) triple on the table definition, in one of two cardinalities, traversed set-to-set. The child chapters each take one thread further:

Traversing Sets and Records

The previous chapter declared the bakery model: Bakery → Clients (client.bakery_id) → Orders (client_order.client_id), with the relations "orders", "client", and "bakery" registered via with_one / with_many. Declaring a relation stores the join recipe. This chapter is about using it: turning “the orders of these clients” into an actual query.

There are four traversal forms. They differ in what you have in hand when you traverse — a set (conditions, no data), a loaded row, or a loaded record object — and, between the two set forms, in how the condition is applied to the target. Each form exists because each starting point lets the backend do something different.

Form 1 — set-to-set: get_ref_as

You have a set — a table narrowed by conditions, possibly matching many rows, none of them loaded. Table::get_ref_as::<E2>(relation) traverses from the whole set at once:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub is_paying_client: bool,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<bool>("is_paying_client")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let mut paying = Client::sqlite_table(db.clone());
paying.add_condition(paying["is_paying_client"].eq(true));

let orders = paying.get_ref_as::<Order>("orders")?;
// SELECT ... FROM "client_order"
// WHERE "client_id" IN (SELECT "id" FROM "client" WHERE "is_paying_client" = 1)
let _ = orders;
Ok(())
}

No client rows are fetched. The traversal generates an IN (subquery) condition on the target, and the subquery is whatever conditions the source set carries. Narrow paying further and the subquery narrows with it — the traversal call never changes. That composability is the point: you build the set that describes which parents you mean, and the child set inherits that description.

In application code these calls hide behind the model’s extension traits — paying.ref_orders(), orders.ref_client() — so the string and the turbofish live once, next to the table definition (Coercion — pinning get_ref’s type). This chapter spells out the underlying calls so you can see what each form generates.

How the IN condition is built is per-backend — each backend implements TableSource::related_in_condition:

  • SQL builds the subquery you see above: "client_id" IN (SELECT "id" FROM "client" WHERE …).
  • MongoDB has no subqueries, so it builds a deferred { field: { "$in": [...] } } document — the parent ids are resolved when the child query runs.
  • CSV fetches the join values in memory and builds an IN condition from them.

MongoDB: the deferred condition

MongoDB’s related_in_condition returns a deferred condition — a closure, not a document. Nothing runs when you traverse; the closure runs when the child query executes. At that point it issues a projected find on the source collection — only the join column, with the source set’s own filter applied — collects the values, and emits { field: { "$in": [...] } } for the child query. It also absorbs a representation mismatch: ids may be stored as ObjectId on one side and as the hex string on the other, so every collected value is pushed in both forms and $in matches either. The set-to-set contract survives — you still hand the target one condition — it just costs two round-trips at fetch time instead of one nested query.

CSV: an in-memory IN list

CSV has no query engine at all — its condition type is an ordinary Vantage expression that the driver evaluates per row in Rust while reading the file. related_in_condition builds target_column.in_(values), where values is itself deferred: at fetch time it lists the source table (with its conditions applied the same in-memory way), pulls the join column out of each row, and yields the list as a single value. Filtering the target is then a per-row membership check. Same declared relation, same traversal call — no database anywhere.

Building and reading conditions by column

related_in_condition has siblings on TableSource for code that builds or inspects a narrowing without a relation, such as an access rule or an audit log line:

  • eq_value_condition(field, value) and in_values_condition(field, values) build field = value and field IN (values) from native values. An empty list matches no row. REST accepts a single value only and refuses longer lists: a URL filter carries one value, and silently dropping the condition would widen the set.
  • condition_column(&condition) names the column a condition narrows to values: equality, IN (including the related and deferred forms above), comparisons, LIKE, each with a value (or, for IN, a value list or subquery) on the right. It returns None when it can’t tell, as for a raw expression or an OR, and for anything that doesn’t pin the column to values: a negation (!=, NOT IN, NOT LIKE, a null check), or a column or expression on the right (tenant_id = tenant_id, tenant_id = 7 OR 1 = 1). So a check that demands a condition on, say, tenant_id fails safe.
  • describe_condition(&condition) renders a condition for people (client_id = 7, invoice_id IN <deferred>), for log entries. Don’t parse it; use condition_column in code.

Use this form when the parent is a set — filtered, unfiltered, one row or a thousand.

Form 2 — set-to-set, for embedding: get_subquery_as

Technically set-to-set as well — same starting point, same declared relation — but the condition is applied differently. Where get_ref_as narrows the target with IN (subquery) so the related rows can be fetched on their own, Table::get_subquery_as::<E2>(relation) attaches a correlated condition — target column against source column, row by row:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let clients = Client::sqlite_table(db);
let orders = clients.get_ref_as::<Order>("orders")?;      // fetch:  WHERE client_id IN (SELECT id FROM client …)
let orders = clients.get_subquery_as::<Order>("orders")?; // embed:  WHERE client_id = client.id
let _ = orders;
Ok(())
}

A correlated table is useless to fetch standalone — its condition references the source’s rows — but it is exactly right to embed as a scalar subquery inside the source’s own SELECT: a client’s order_count, an order’s client.name. The embedding recipe (select_column, aggregates, composition) is the subject of the next chapter.

SQL and SurrealDB only

This form needs the backend to express a correlated condition (TableSource::related_correlated_condition), and only SQL backends and SurrealDB can. MongoDB, CSV, REST, and CMD have no correlated-subquery expressions to lower to — the default implementation panics rather than degrading silently. The fetching forms (1, 3, 4) remain available everywhere.

Form 3 — row-in-hand: get_ref_from_row

You already hold a loaded row — a Record<T::Value> that came back from the database. There is nothing to compute: the join value is sitting in the row. Table::get_ref_from_row::<E2>(relation, &row) reads it out and applies it as a single eq-condition on the target:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let clients = Client::sqlite_table(db);
let mut row: Record<AnySqliteType> = Record::new();
row.insert("id".to_string(), AnySqliteType::new("marty".to_string()));
let orders = clients.get_ref_from_row::<Order>("orders", &row)?;
// WHERE "client_id" = <the id read out of row> — one eq-condition
let _ = orders;
Ok(())
}

Which field gets read depends on the relation’s direction: a HasOne relation reads its stored foreign-key column from the row; a HasMany relation reads the source’s id field. No subquery, no deferred fetch — row already carries the value.

Plumbing, not user-facing API

You will rarely call this directly: it exists as the primitive beneath the blanket get_ref implementations for ActiveRecord and ActiveEntity (form 4), and the erased Vista::get_ref forwards here too. When a UI shows a clicked row’s children, this is the form doing the work — one equality filter — but the caller holds a record handle or a Vista, not a raw row.

Form 4 — record-level: GetRefExt::get_ref

The same traversal as form 3, called from a loaded handle instead of a raw row: launch.get_ref::<LaunchCrew>("launch_crew") works on an ActiveEntity (typed — its id is injected into the row first, so has-many relations resolve) and on an ActiveRecord (untyped — the raw row forwards directly). The method comes from the GetRefExt extension trait in vantage-table, with blanket implementations for both handles over any TableSource. The worked example (loading a record, traversing, inserting a child) lives in Records: Traversal, Invariants & Hooks.

The bare target: get_ref_target_erased

Sometimes you want the relation’s target table with no condition at all — Table::get_ref_target_erased(relation) builds exactly that. The target’s entity type is erased: a relation is built by a caller-supplied constructor, so the traversal site cannot name it. Where the traversal forms narrow the target to related rows, get_ref_target_erased hands you the table you’d insert a new related row into before any join value exists (this is what Vista’s nested insert uses).

When a target can’t be built

with_one and with_many take a constructor that always returns a table. Their siblings try_with_one and try_with_many take one that returns Result, plus the target’s name for metadata. When the constructor fails, every traversal form returns that error. It never falls back to a table without the target’s own conditions: if the target carried an access rule, a fallback would read rows the rule hides.

Spec references (from YAML) are built this way. A target the resolver doesn’t know, or one whose build fails (a build injector refusing it, say), makes the traversal an error. The relation still appears in metadata under its declared target name.

Contained relations

Some backends embed related data inside the row instead of linking to another table. SurrealDB’s Order carries an embedded array<object> of order lines — declared as a column (so it is selected) and as a contains-many relation whose record schema is built by a closure. From bakery_model3/src/order.rs:

#![allow(unused)]
fn main() {
use vantage_surrealdb::surrealdb::SurrealDB;
use vantage_surrealdb::thing::Thing;
use vantage_surrealdb::types::AnySurrealType;
use vantage_table::table::Table;
use vantage_types::entity;

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Product {
    pub name: String,
}

impl Product {
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub is_deleted: bool,
}

impl Order {
pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Order> {
    Table::new("order", db)
        .with_id_column("id")
        .with_column_of::<Thing>("client")
        .with_column_of::<bool>("is_deleted")
        // `lines` is an embedded `array<object>` of `{ product, quantity,
        // price }` — declared as a column so it's selected, and as a
        // contains-many relation whose record schema is built by the
        // closure (like `with_many`).
        .with_column_of::<AnySurrealType>("lines")
        .with_one("client", "client", Client::surreal_table)
        .with_contained_many(
            "lines",
            "lines",
            |db| {
                Table::new("lines", db)
                    .with_column_of::<Thing>("product")
                    .with_column_of::<i64>("quantity")
                    .with_column_of::<i64>("price")
                    // a line traverses out to the real product table
                    .with_one("product", "product", Product::surreal_table)
            },
            None,
        )
}
}
}

Contained relations are a K/V-and-document-store shape: the value already holds the related data, so “traversal” is descent into the row, not a query. Note that a contained record can still hold references back out — a line’s product field is a with_one to the real product table, so from an embedded line you traverse out with the same forms as anywhere else.

SurrealDB record ids in narrowings

Join values in SurrealDB are record ids (Thing), not scalar foreign keys. A string of the shape "table:key" handed into a narrowing — from a script surface or JSON, say — is coerced back into a record id by the backend (coerce_reference_value on TableSource; the identity function on scalar-FK backends). String ids narrow correctly; they don’t silently match nothing.

Conclusion

You can now:

  1. Traverse a filtered set with get_ref_as — conditions compose into an IN (subquery) on the target.
  2. Correlate instead of fetch with get_subquery_as — same relation, a per-row condition, built for embedding in the source’s SELECT.
  3. Traverse from a loaded row or record with get_ref_from_row / get_ref — one eq-condition, supported by every backend.
  4. Obtain a bare insert target with get_ref_target_erased — the relation’s target with no condition.
  5. Declare embedded/contained relations on document stores with with_contained_many, where traversal is descent rather than a query.

Expressions & Subqueries

The previous chapter used relations to fetch related rows — traverse from a client to their orders, get the orders back as a set. This chapter uses relations inside a query: a client row that carries its own order count, computed by the database, delivered as just another column.

The recipe has three parts:

  1. Table::with_expression(name, |t| …) — adds a computed field to the table. It’s evaluated as part of the SELECT, alongside the physical columns.
  2. Table::get_subquery_as::<E2>(relation) — builds the relation’s target table with a correlated condition. For the clients → orders relation that condition is client_order.client_id = client.id — the direction is always target.<id> = source.<fk>, supplied by the backend through TableSource::related_correlated_condition. This table is explicitly designed for embedding as a scalar subquery.
  3. select_column("field") — projects a single column from that subquery, so it can sit where a scalar value is expected.

Here’s the shape, from the select_column rustdoc, on a product table that declares a category relation:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Category {
    pub name: String,
}

impl Category {
    pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
        Table::new("category", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
}

pub fn product_table(db: SqliteDB) -> Table<SqliteDB, Product> {
    Table::new("product", db)
        .with_id_column("id")
        .with_column_of::<String>("name")
        .with_column_of::<String>("category_id")
        .with_one("category", "category_id", Category::table)
.with_expression("category", |t| {
    t.get_subquery_as::<Category>("category").unwrap()
        .select_column("name")
        .expect("Category has a 'name' column")
})
}
}

The key difference from the previous chapter: get_ref_as narrows with IN (subquery) to fetch related rows; get_subquery_as correlates to embed a related value per row. Same relation declaration, two different query shapes.

select_column returns Option<Expression<T::Value>> — None when the field isn’t a column on the target table. When the column is hardcoded and known to exist, .expect(...) is the honest choice: it panics in development, where you can fix the typo, rather than silently at runtime.

Why not a JOIN?

Vantage composes sets. A correlated scalar subquery keeps the row shape of the source table — one row per client, one extra column — with no join-key bookkeeping and no row multiplication when a client has ten orders. It also nests arbitrarily, which joins don’t do gracefully. And you’re not paying for the nesting: the database’s optimizer flattens a correlated subquery where a join would be equivalent.

A driver may still join

The correlated shape is the contract, not a mandate about execution. Lowering is a per-datasource concern, and a driver implementation is free to use a JOIN — or anything else — where that is the reliable choice for its engine, as long as the result keeps the source’s row shape: one row per source record, the related value as a column.

Aggregates over a relation

get_subquery_as returns a full Table, so the aggregate query-builders work on it: get_count_query() and get_sum_query(&col). Both wrap their output in parentheses, so they nest safely inside the outer SELECT.

Here’s the real thing — verbatim from bakery_model3/src/client.rs, a client table whose order_count is computed by the database:

#![allow(unused)]
fn main() {
use vantage_surrealdb::surrealdb::SurrealDB;
use vantage_surrealdb::thing::Thing;
use vantage_surrealdb::types::AnySurrealType;
use vantage_table::table::Table;
use vantage_types::entity;

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Bakery {
    pub name: String,
}

impl Bakery {
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Bakery> {
        Table::new("bakery", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub is_deleted: bool,
}

impl Order {
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Order> {
        Table::new("order", db)
            .with_id_column("id")
            .with_column_of::<Thing>("client")
            .with_column_of::<bool>("is_deleted")
    }
}

#[entity(SurrealType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
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 generated query has this shape (illustrative):

SELECT *, (SELECT COUNT(*) FROM order WHERE order.client = client.id) AS order_count
FROM client

The expression field shows up in ReadableValueSet results alongside the physical columns. To get it onto your entity struct, add a matching Option field — it deserializes like any other column.

Composing expressions

Expressions can reference other expressions. get_column_expr(name) resolves a name to either a real column or a computed expression, so one expression can build on another — a title composed of name and a count subquery via concat_!, for instance. All of it renders into a single query. The nesting looks redundant on paper, but SQL engines optimize it away; there is no extra round trip.

The Table chapter walks through a concrete title-composition example step by step — see Working with Tables rather than repeating it here.

Nesting arbitrary expressions — select_expression

select_column is sugar over a more general form. Table::select_expression(expr) wraps any expression as a single-column subquery over the table’s source and conditions — SELECT <expr> FROM table WHERE … — clearing fields and ordering, since a scalar subquery has exactly one output and no meaningful sort.

This is what lets one correlated subquery nest inside another: a two-hop lookup is outer.select_expression( (inner_subquery) ). It is also the machinery under the next chapter’s implicit references, which automate exactly this recipe.

Cross-backend enrichment lives at the Dio layer

This whole chapter is a SQL/SurrealDB technique — the backend-support contract is on Form 2 of the previous chapter. If you need per-row related values across backends that can’t correlate, there’s a different tool for that: augmentation, covered in Relations and Dio.

SurrealDB notes

SurrealDB’s record links give it a second way to express a related field: a native idiom path. client.name on an order traverses the link in place — no subquery at all, and often cheaper. The implicit references chapter uses exactly that lowering automatically.

Correlated subqueries still work too. The order_count example above runs on SurrealDB as written — order.client = client.id correlates through the record link.

Conclusion

At this point you should be able to:

  1. Embed a related field as a computed column — with_expression + get_subquery_as::<E2>(relation) + select_column("field").
  2. Add relation aggregates — get_count_query() and get_sum_query(&col) on the subquery table.
  3. Compose expressions — get_column_expr(name) resolves columns and expressions alike, so expressions can build on each other inside one query.
  4. Nest subqueries — select_expression(expr) wraps any expression as a scalar subquery, one inside another.

Next: Implicit References — the declarative form of everything in this chapter’s first half.

Implicit References

The previous chapter (Expressions & Subqueries) closed with a recipe: take a relation, call get_subquery_as to build a correlated subquery for one field, and register it with with_expression so it projects as a column. That recipe works, but surfacing a related row’s field is common enough to deserve a declarative form.

That form is a dotted name in Table::with_active_columns. Write "client.name" and the table traverses the declared has_one relation "client", imports the target’s name field, and projects it as a read-only column aliased under the literal dotted name — no hand-written expression. This feature is also called implicit references.

The first segment addresses the relation by its registry name — whatever you named it in with_one — not by the foreign-key column. bakery_model3 declares with_one("client", "client_id", …), so the path is client.name on SQL and SurrealDB alike; a model that names its relations after the FK columns would write client_id.name instead. Either convention works — the segment just has to match the declared name.

with_active_columns does two jobs at once:

  1. Plain names restrict projection. Only the listed columns are selected — useful on its own for narrowing wide tables. The id column is always projected regardless.
  2. Dotted names traverse relations. Each dot is one has_one hop from the current table to its target.

One hop and two hops

Using the bakery model from this guide — Orders relate to Client via client_order.client_id, and Client relates to Bakery via client.bakery_id, declared as relations "client" and "bakery" with with_one:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Bakery {
    pub name: String,
}

impl Bakery {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Bakery> {
        Table::new("bakery", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub bakery_id: Option<String>,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<String>("bakery_id")
            .with_one("bakery", "bakery_id", Bakery::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_one("client", "client_id", Client::sqlite_table)
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
// Plain names restrict projection; dotted names traverse has_one relations.
let orders = Order::sqlite_table(db).with_active_columns(&[
    "id",
    "client_id",
    "client.name",        // one hop:  client_order -> client
    "client.bakery.name", // two hops: client_order -> client -> bakery
])?;
let _ = orders;
Ok(())
}

client.name is one hop. client.bakery.name recurses through two — traversal supports an arbitrary depth of has_one hops out of the box. On a SQL backend this lowers to nested correlated scalar subqueries:

SELECT id,
  (SELECT name FROM client WHERE client.id = client_order.client_id) AS "client.name",
  (SELECT (SELECT name FROM bakery WHERE bakery.id = client.bakery_id)
     FROM client WHERE client.id = client_order.client_id)           AS "client.bakery.name"
FROM client_order

The alias is the dotted name itself, so rows come back with a flat key equal to the dotted name. Reading them needs a populated database, so this block is compiled but not run:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Bakery {
    pub name: String,
}

impl Bakery {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Bakery> {
        Table::new("bakery", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub bakery_id: Option<String>,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<String>("bakery_id")
            .with_one("bakery", "bakery_id", Bakery::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_one("client", "client_id", Client::sqlite_table)
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:bakery.sqlite").await.context("connect")?;
let orders = Order::sqlite_table(db)
    .with_active_columns(&["id", "client.name", "client.bakery.name"])?;
for row in orders.list_values().await?.values() {
    let get = |k: &str| row.get(k).map(|v| format!("{v}")).unwrap_or_default();
    println!(
        "  order {}: client.name={}, client.bakery.name={}",
        get("id"),
        get("client.name"),
        get("client.bakery.name"),
    );
}
Ok(())
}

There is no nested object to unwrap — row.get("client.bakery.name") is a plain lookup on a flat record. The full example lives in bakery_model3/examples/implicit-references.rs; run it with cargo run -p bakery_model3 --example implicit-references.

Validation at table construction

Dotted names are ordinary strings, so the compiler cannot vet them. Instead, with_active_columns returns Result and validates everything as it runs — when the table definition is constructed, typically in model or startup code, before any query is made. A mistake surfaces there, never as a silently empty column at fetch time:

  • an unknown column or unknown relation in a dotted name;
  • a has_many hop — traversal is has_one-only, because a to-many field is a set, not a value (for an aggregate over the set, a count or a sum, use the previous chapter’s get_count_query);
  • a backend that can lower neither a correlated subquery nor a native path nor a join (CSV, REST) — its TableSource::supports_traversal is false, and dotted names are refused up front. MongoDB lowers dotted names into $lookup stages, except over a pipeline: view. Traversal is also same-datasource only; enriching rows with fields from a different datasource belongs to Dio augmentation (Dio).

Why not let a bad dotted name fail at fetch time?

Because a fetch-time failure in a projection is invisible. The query still runs, the column comes back empty, and nothing tells you whether the relation was misspelled or the data is genuinely absent. Constructing the table is the moment you have full knowledge — the relation registry, the target’s columns, the backend’s capabilities — so that’s where every check runs.

Read-only semantics

An imported column is a computed projection. No backend can honestly store client.name on the client_order table — the value lives in a different row of a different table. So imported columns carry write-path enforcement:

  • They are flagged calculated in vista metadata, so downstream consumers know the column is derived (see Vistas).
  • They are stripped from full-record write payloads — insert, replace, generated-id insert. A read-modify-save round-trip never persists client.name as a real field.
  • They are rejected outright in a patch. A partial payload naming a read-only column is explicit intent, and silently dropping it would turn the patch into a successful no-op.

The key difference: strip vs reject. A full-record payload naturally contains everything you read — including imported columns — so stripping them is the correct interpretation. A patch payload contains only what you chose to send, so a read-only column there is a mistake worth an error.

Imported columns are also excluded from quicksearch and (on SurrealDB) not orderable — the dotted name is a projection alias, not a physical field.

Per-backend lowering

SQL backends lower dotted names through the generic chain: nested correlated scalar subqueries built on the same get_subquery_as/select_expression machinery from the previous chapter. A backend that already supports that recipe gets implicit references with nothing backend-specific to implement.

SurrealDB overrides TableSource::traversal_path_expr and emits a native idiom path instead — client.name — with each segment escaped separately. Escaping the joined path as one identifier would produce a literal ⟨client.name⟩ field lookup: a dead column that matches nothing. The idiom descends each hop’s link field, not the relation’s registry name — a relation declared with_one("owner", "client", …) still lowers to client.name. Multi-hop comes for free in the idiom.

Why not just use with_expression?

You still can, and sometimes you should. The key difference: reach for with_expression + get_subquery_as when you need an arbitrary expression — arithmetic, aggregates, raw escapes. Reach for a dotted column when you just want a related field. The dotted form adds what the manual recipe can’t:

  • Proper errors — the manual recipe’s checks are Option/unwrap panics (select_column returns None for a misspelled column); the dotted form returns Result, with an error naming the bad relation, column, cardinality, or backend.
  • A typed imported column — the definition is cloned from the target’s column, so type metadata travels with it.
  • Enforced read-only write semantics — a with_expression column has no write-path story at all.

Gotchas

  • Table::derive_from does not inherit implicit references. A derived table starts with no active set, and listing an imported dotted column in its column inheritance copies only a bare definition. Re-declare the dotted names on the derived table.
  • Columns added after with_active_columns are not in the active set and won’t project — declare the active set last.
  • Expression-only columns (registered via with_expression with no column definition) may be named in the active set; they stay projected.
  • Same-datasource only, by design — see Dio for the cross-datasource alternative.

Conclusion

At this point you should be able to:

  1. Restrict projection with plain names in with_active_columns (the id column always projects).
  2. Import related fields with dotted names — one hop or many, over declared has_one relations.
  3. Read them back via flat keys equal to the dotted name.
  4. Predict every construction-time error — unknown column, unknown relation, has_many hop, backend without traversal support.
  5. Explain the write-path semantics — stripped from full-record writes, rejected in a patch.
  6. Choose the right tool — dotted column for a related field, with_expression for an arbitrary expression.

The next page, Vista, YAML and Rhai, covers how the same declarations work from YAML specs, and how the calculated flag surfaces at the erased layer.

Vista, YAML and Rhai

Everything so far assumed you could write Table<SqliteDB, Order> in your source code. Generic consumers can’t. A UI grid, an admin panel, a scripting surface, a config-driven tool — none of them know your model types at compile time. What they hold is a Vista: the schema-bearing runtime handle introduced in the Vista chapter.

The question this page answers: what happens to relations when the type is erased? The answer is that they survive in three forms:

  1. Metadata — the relation names, targets, kinds and join keys are introspectable, so a consumer can discover them at runtime.
  2. Traversal — get_ref still works. Hand it a loaded row, get back a new Vista narrowed to the related rows.
  3. Capabilities — an honest contract about whether this backend serves traversal at all, so a consumer knows what to offer before trying.

The factory

A Vista is built by a vista factory: each driver’s factory implements the VistaFactory trait, covered in Vista integration. The factory’s defining ability is working by name: where typed code passes User::table(db), a generic consumer asks for "users" and gets a Vista back. How the name resolves is the factory’s business — typically it loads table specs from files — and that indirection is what lets config-driven tools, scripts, and by-name traversal address models they have no Rust types for.

Behind a name, a vista is built one of two ways:

  1. From a typed table — from_table wraps the table your model already builds. One handle type comes out, whichever backend went in:

    use vantage_csv::{AnyCsvType, Csv};
    use vantage_sql::prelude::*;
    use vantage_types::prelude::*;
    
    #[entity(SqliteType)]
    #[derive(Debug, Clone, Default)]
    pub struct Product {
        pub name: String,
    }
    
    impl Product {
        pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
            Table::new("product", db)
                .with_id_column("id")
                .with_column_of::<String>("name")
        }
    }
    
    #[entity(CsvType)]
    #[derive(Debug, Clone, Default)]
    pub struct Order {
        pub client_id: String,
    }
    
    impl Order {
        pub fn csv_table(csv: Csv) -> Table<Csv, Order> {
            Table::new("order", csv).with_column_of::<String>("client_id")
        }
    }
    
    #[tokio::main(flavor = "current_thread")]
    async fn main() -> vantage_core::Result<()> {
    let sqlite = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
    let csv = Csv::new("data");
    let vistas = vec![
        sqlite.vista_factory().from_table(Product::sqlite_table(sqlite.clone()))?,
        csv.vista_factory().from_table(Order::csv_table(csv.clone()))?,
    ];
    assert_eq!(vistas[0].name(), "product");
    Ok(())
    }
  2. From a YAML spec — no Rust model at all: columns, relations, and computed fields declared as data (Config-Driven Vistas). This path is how the declarations in the last section of this page become live, traversable relations. A spec’s source doesn’t have to be a physical table, either — a rhai: block builds it from a query (a query-sourced vista), and a base: block derives it from another spec.

Whichever route constructs it, the factory folds the table’s relations into VistaMetadata as Reference entries — name, kind, target, join keys. The erased handle carries enough to introspect the relations and to traverse them. Nothing about the relation is lost in the erasure; only the compile-time names are.

Metadata: introspecting relations

A Reference is one model for every relation shape. It is built with the constructor of its kind, so the kind and its fields always agree:

  • Reference::has_one(name, target, key) — one target row, found by a key on the parent row.
  • Reference::has_many(name, target, key) — the target rows that carry the parent’s key.
  • Reference::contains_one(name, host_column) — one record embedded as an object in a column.
  • Reference::contains_many(name, host_column) — records embedded as an array in a column.

A join’s key is a JoinKey: read the parent row’s from column and constrain the target’s to column to that value (JoinKey::to_target_id(from) constrains the target’s id column). A join may carry several keys, and every one of them must match.

Vista exposes the entries directly: references() is the ordered map of name to Reference, and reference(name) looks one up.

#![allow(unused)]
fn main() {
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, JoinKey, Reference, ReferenceKind, Vista, VistaMetadata};

let metadata = VistaMetadata::new()
    .with_column(Column::new("id", "String").with_flag("id"))
    .with_column(Column::new("country_id", "String"))
    .with_id_column("id")
    .with_reference(Reference::has_many("orders", "order", JoinKey::new("id", "client_id")))
    .with_reference(Reference::has_one("country", "country", JoinKey::to_target_id("country_id")));
let clients = Vista::new("client", MockShell::new().with_metadata(metadata));

for (name, reference) in clients.references() {
    println!("{name}: {} -> {}", reference.kind.as_str(), reference.target);
}
assert_eq!(clients.reference("orders").unwrap().kind, ReferenceKind::HasMany);
}

This is what lets a UI render relation tabs or context-menu entries without knowing the model. The loop prints “orders: has_many -> order” and “country: has_one -> country” — enough to label a tab and decide whether the target is a single row or a set — and it learned that from the handle, not from your source code. ReferenceKind::is_contained separates the embedded kinds from the joins.

Traversal: get_ref on a loaded row

The erased twin of the typed get_ref_from_row is Vista::get_ref, which takes a relation name and an Option<&Record<CborValue>>. Hand it a loaded row — Record<CborValue> is the erased record type — and get back a new Vista narrowed to the related rows. The relation is named by string because that’s all a generic consumer has; the reference’s keys supply the columns. Each key becomes one equality condition on the target, applied through Vista::add_condition, so the target’s query() records the join:

use ciborium::Value as CborValue;
use vantage_dataset::ReadableValueSet;
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, JoinKey, Reference, Vista, VistaMetadata};

fn row(pairs: &[(&str, &str)]) -> Record<CborValue> {
    pairs.iter().map(|(k, v)| (k.to_string(), CborValue::Text(v.to_string()))).collect()
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let orders = MockShell::new()
    .with_metadata(
        VistaMetadata::new()
            .with_column(Column::new("id", "String").with_flag("id"))
            .with_column(Column::new("client_id", "String"))
            .with_id_column("id"),
    )
    .with_record("o1", row(&[("id", "o1"), ("client_id", "marty")]))
    .with_record("o2", row(&[("id", "o2"), ("client_id", "marty")]))
    .with_record("o3", row(&[("id", "o3"), ("client_id", "doc")]));
let shell = MockShell::new()
    .with_metadata(
        VistaMetadata::new()
            .with_column(Column::new("id", "String").with_flag("id"))
            .with_id_column("id")
            .with_reference(Reference::has_many("orders", "order", JoinKey::new("id", "client_id"))),
    )
    .with_record("marty", row(&[("id", "marty")]))
    .with_ref_target("orders", orders);
let clients = Vista::new("client", shell);
let marty = clients.get_value("marty").await?.expect("client exists");

let orders = clients.get_ref("orders", Some(&marty))?;
assert_eq!(orders.list_values().await?.len(), 2);

let all_orders = clients.get_ref("orders", None)?;
assert_eq!(all_orders.list_values().await?.len(), 3);
Ok(())
}

Passing None gives the bare, unconditioned target. This is the insert destination for nested creates: when a UI wants to add a related row, it needs the target table without any narrowing applied. A contained relation works the same way: from a row, get_ref returns a Vista over the records embedded in the row’s host column; with None, the empty contained schema.

Both forms return Vista, so traversal chains and the consumer never leaves the erased world. A grid that drills from clients into orders into line items is calling get_ref three times, each time holding nothing more specific than a Vista and a row.

Record ids that round-tripped through JSON

At the erased layer, SurrealDB record ids travel as strings ("table:key"). The backend coerces a string-shaped record id back into a typed record id when narrowing, so a row that round-tripped through JSON or a script still traverses correctly.

The scripting surface

Rhai data scripts (the vantage-vista rhai feature) traverse with ref(relation) on a table handle, which follows the relation from every row of a set. A one-row set goes through get_ref, so it follows the same metadata and backend traversal described here. Several rows become an in condition on the bare target’s join column (which needs a relation joined on one key), a limit(n) before the ref caps the rows it follows, and an empty set gives an empty target without a query. See The Table Handle.

A relation’s foreign key may be a computed column (a lazy: column). A has-one through one works, because the parent row carries the computed value. A has-many whose key is a computed column of the child is refused by get_ref with an error naming the relation and column, since the backend can’t filter the child by a value it doesn’t store.

Capabilities: the honest contract

Not every backend serves every traversal form — the traversal chapter already showed that set-level traversal needs subqueries. At the typed layer, you knew that when you wrote the code. At the erased layer, the consumer has to ask. That’s what VistaCapabilities is for: a set of Capability values, each naming one method family the shell answers without refusing. The ones that matter for relations:

  • Capability::Traverse — the shell answers get_ref. A shell without it refuses every traversal with an Unsupported error naming the capability.
  • Capability::FilterOps — the shell takes operator conditions, in among them. A script’s multi-row ref narrows the target with an in condition, so it needs this on the target.
  • Capability::ConfineWrites — writes through a narrowed Vista stay inside the narrowed set. An insert into the result of get_ref is such a write.

Consumers branch on these before offering the corresponding affordance: a grid checks vista.capabilities().has(Capability::Traverse) before showing relation tabs, and a nested-create form checks the target’s Insert and ConfineWrites. The same set appears in the Rhai capabilities() map under each capability’s snake_case name (traverse, filter_ops), so scripts and config-driven tools can branch the same way.

Two relation features need no flag of their own. A reference with a Rhai build script carries it in Reference::build_script, and the shell runs it in place of the key match (SurrealDB, MongoDB and MySQL accept one). A dotted column either lowers when the vista is built or fails the build, so a vista that exists already serves every dotted column it declares.

Across datasources: the Catalog

Everything above stays inside one datasource — a Vista’s references forward to the wrapped table, and a single Vista deliberately knows nothing about other datasources. Traversal across datasources lives one layer up, in a Catalog: a map from model name to a loader that builds a fresh, unconditioned Vista, whichever backend backs it.

You register models by name with Catalog::with, then narrow a target the catalog builds with Reference::narrow. The reference is the same Reference the metadata holds; here it names a model in another backend:

use ciborium::Value as CborValue;
use vantage_dataset::ReadableValueSet;
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::{Catalog, Column, JoinKey, Reference, Vista, VistaMetadata, VistaResolver};

fn row(pairs: &[(&str, &str)]) -> Record<CborValue> {
    pairs.iter().map(|(k, v)| (k.to_string(), CborValue::Text(v.to_string()))).collect()
}

fn meta(columns: &[&str]) -> VistaMetadata {
    columns
        .iter()
        .fold(VistaMetadata::new(), |m, c| m.with_column(Column::new(*c, "String")))
        .with_id_column("id")
}

// Stands in for a client table in one backend.
fn client_vista() -> Vista {
    let shell = MockShell::new()
        .with_metadata(meta(&["id", "bakery_id"]))
        .with_record("marty", row(&[("id", "marty"), ("bakery_id", "hill-valley")]));
    Vista::new("client", shell)
}

// Stands in for a bakery API in another.
fn bakery_vista() -> Vista {
    let shell = MockShell::new()
        .with_metadata(meta(&["id", "name"]))
        .with_record("hill-valley", row(&[("id", "hill-valley"), ("name", "Hill Valley")]))
        .with_record("twin-pines", row(&[("id", "twin-pines"), ("name", "Twin Pines")]));
    Vista::new("bakery", shell)
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let catalog = Catalog::new()
    .with("client", |_: &str| Ok(client_vista()))
    .with("bakery", |_: &str| Ok(bakery_vista()));

let bakery = Reference::has_one("bakery", "bakery", JoinKey::new("bakery_id", "id"));

let marty = catalog.vista("client")?.get_value("marty").await?.expect("client exists");
let hers = bakery.narrow(catalog.vista(&bakery.target)?, &marty)?;
assert_eq!(hers.list_values().await?.len(), 1);
Ok(())
}

narrow applies one equality condition per key, through Vista::add_condition. How each condition is honoured — SQL WHERE, in-memory filter, REST path or query parameter — is the target driver’s concern at fetch time.

A name the catalog doesn’t hold is a not-found error, and a loader that fails keeps its own error. Because loaders build a fresh Vista on every call, the catalog composes with hot-reloading inventories. The catalog is a VistaResolver, so it goes wherever a resolver is expected — the Dio layer’s augmentation resolves its detail sources through one, which the next page picks up.

Why not register cross-datasource relations on the catalog?

Because the relation already has a home: the Reference. A second relation type on the catalog would be one more spelling of “read this column, constrain that one”, with its own constructor and its own traversal call. Keeping one model means a reference read out of a vista’s metadata and a reference written by hand for a cross-backend join narrow the same way. The catalog stays what it is good at: building a model by name.

Declaring relations in YAML and Rhai

Config-driven vistas (full chapter: Config-driven vistas) declare relations in the table spec — either as column-level sugar or a top-level map. The factory reads the spec, registers the relations, and the resulting Vista traverses them exactly like one built from_table:

name: product
columns:
  id:
    type: string
    flags: [id]
  batch:
    type: string
    references: batch        # sugar: has_one named `batch`, FK = this column
  batch.name:                # implicit reference through that relation
    type: string
references:
  tags:
    table: tag
    kind: has_many
    foreign_key: product_id

The column-level references: batch sugar names the relation after the column and uses the column as the foreign key, matched to the target’s id. That is the common case: a column named after the table it points at. A references: entry with the same name takes precedence over the sugar. The full form (table, kind, foreign_key) names the relation independently of any column — you need that when two relations point at the same table.

A join that needs more than one column pair lists them under keys:. Each pair reads the parent row’s from column and constrains the target’s to column, and the child must match every pair:

references:
  deployments:
    table: deployment
    kind: has_many
    keys:
      - { from: product_id, to: product }
      - { from: version_id, to: version }

When keys: is given it describes the whole join; foreign_key: applies only when it is absent.

When a relation needs more than key matches — extra conditions, ordering, a search — give the reference a rhai: build script in its driver block (SurrealDB, MongoDB and MySQL accept one):

references:
  due_orders:
    table: order
    kind: has_many
    foreign_key: client
    surreal:
      rhai: |
        table("order").where("client", row.id).where("status", "due")

The script runs lazily when the relation is traversed, with the parent record in scope as row, and must end on a table handle: start it with table("<name>") and chain the narrowing verbs (see Reference build scripts). Without rhai:, the relation falls back to the plain key match. The script lands in the reference’s build_script, which the shell evaluates instead of the keys.

Imported dotted columns — like batch.name above, the YAML form of implicit references — go through the same traversal import as the Rust API, with the same construction-time validation: a bad dotted column fails the spec load, not the first fetch. They arrive in metadata flagged calculated. That flag means read-only for consumers: the value comes from a traversal, not from a column you can write. This is how a UI knows not to offer editing on them. Computed columns (lazy:) carry the same flag for the same reason.

The key difference: at the typed layer you know which traversal forms are safe — you wrote the code against a backend you chose. At the erased layer, the capabilities say so. Same relations, discovered instead of known.

Conclusion

At this point you should be able to:

  1. Enumerate a Vista’s relations — references() for every Reference, reference(name) for one, its kind for the cardinality and keys for the join.
  2. Build a reference by hand — Reference::has_one, has_many, contains_one, contains_many, with JoinKey::new or JoinKey::to_target_id.
  3. Traverse from a loaded erased row — get_ref(relation, Some(&row)) returns a narrowed Vista; get_ref(relation, None) returns the bare target for nested creates.
  4. Check the capability first — Capability::Traverse before offering traversal, FilterOps and ConfineWrites on the target for multi-row script traversal and nested inserts.
  5. Traverse across datasources — register models with Catalog::with, then reference.narrow(catalog.vista(&reference.target)?, &row).
  6. Declare relations in YAML and Rhai — column-level references: sugar, the full top-level form, keys: for multi-column joins, implicit dotted columns through a relation, and rhai: build scripts for traversals that need more than a key match — all consumed by the same vista factory that wraps typed tables.
  7. Explain why calculated columns are read-only in generic UIs — their values come from traversal, not from a writable column.

Next: what happens above the Vista — combining erased handles across backends.

Relations and Dio

Everything in this guide so far has executed at or below the Vista: typed relations on the Table, traversal narrowing, subquery expressions, implicit references. This final chapter climbs one layer higher. A Dio keeps a live local copy of a data segment (a cache), fills it through a Lens, announces changes on an event bus, and serves reactive views (Scenery). You built one in the intro paths (Dio over SQL, Scenery).

Two questions matter here: what happens to the relation machinery you already know once a Dio sits on top — and what relation shape exists only at the Dio layer, because nothing below it can express one.

Same-persistence relations resolve beneath the Dio

Traversal, subquery expressions, and implicit references all happen in the Table or Vista under the Dio. The Dio caches the resulting rows like any others. If your table declares with_active_columns(&["id", "client.name"]), the client.name column is already part of each row when it enters the cache — nothing at the Dio layer knows or cares that it was traversed. From the cache’s point of view, a traversed column and a plain column are indistinguishable.

The practical consequence is the master/detail pattern in a UI: traverse at the Vista, cache at the Dio. Take a row from the cached master and call Dio::get_ref(relation, &row). It runs the erased Vista::get_ref from the Vistas chapter on the Dio’s master and builds a new Dio over the narrowed target, on the same Lens:

use ciborium::Value as CborValue;
use vantage_dataset::ReadableValueSet;
use vantage_diorama::{Cache, Lens, Loader};
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, JoinKey, Reference, Vista, VistaMetadata};

fn row(pairs: &[(&str, &str)]) -> Record<CborValue> {
    pairs.iter().map(|(k, v)| (k.to_string(), CborValue::Text(v.to_string()))).collect()
}

fn clients() -> Vista {
    let orders = MockShell::new()
        .with_metadata(
            VistaMetadata::new()
                .with_column(Column::new("id", "String").with_flag("id"))
                .with_column(Column::new("client_id", "String"))
                .with_id_column("id"),
        )
        .with_record("o1", row(&[("id", "o1"), ("client_id", "marty")]))
        .with_record("o2", row(&[("id", "o2"), ("client_id", "marty")]))
        .with_record("o3", row(&[("id", "o3"), ("client_id", "doc")]));
    let shell = MockShell::new()
        .with_metadata(
            VistaMetadata::new()
                .with_column(Column::new("id", "String").with_flag("id"))
                .with_id_column("id")
                .with_reference(Reference::has_many("orders", "order", JoinKey::new("id", "client_id"))),
        )
        .with_record("marty", row(&[("id", "marty")]))
        .with_record("doc", row(&[("id", "doc")]))
        .with_ref_target("orders", orders);
    Vista::new("client", shell)
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()?;

let clients = lens.dio(clients()).build().await?;
let marty = clients.cached().get_value("marty").await?.expect("cached");

// A Dio of its own over marty's orders, with its own cache table.
let orders = clients.get_ref("orders", &marty).await?;
assert_eq!(orders.cached().list_values().await?.len(), 2);
Ok(())
}

Each narrowed detail set gets its own cache table, keyed by the narrowed target’s query, so switching between master rows switches between already-cached detail sets rather than re-narrowing one shared cache. The detail Dio inherits the master’s live setting, but not its augmentations, which describe the master’s table. Dio::get_ref_target(relation) is the same call without a row: a Dio over every eligible target row, which is what a reference picker in an edit form lists.

When the detail lives in another datasource, build its Vista through a Catalog and narrow it with Reference::narrow, as the Vistas chapter shows, then put a Dio in front of it with lens.dio(narrowed).build().

A narrower view of the same rows is a different operation: no relation, just more conditions. dio.derive() collects them and builds a new Dio over the narrowed set:

use ciborium::Value as CborValue;
use vantage_dataset::ReadableValueSet;
use vantage_diorama::{Cache, Lens, Loader};
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, Condition, FilterOp, Vista, VistaMetadata};

fn row(pairs: &[(&str, &str)]) -> Record<CborValue> {
    pairs.iter().map(|(k, v)| (k.to_string(), CborValue::Text(v.to_string()))).collect()
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let orders = Vista::new(
    "order",
    MockShell::new()
        .with_metadata(
            VistaMetadata::new()
                .with_column(Column::new("id", "String").with_flag("id"))
                .with_column(Column::new("client_id", "String"))
                .with_id_column("id"),
        )
        .with_record("o1", row(&[("id", "o1"), ("client_id", "marty")]))
        .with_record("o2", row(&[("id", "o2"), ("client_id", "doc")])),
);
let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()?;
let dio = lens.dio(orders).build().await?;
let martys = dio
    .derive()
    .with_condition(Condition::new("client_id", FilterOp::Eq, "marty"))
    .build()
    .await?;
assert_eq!(martys.cached().list_values().await?.len(), 1);
Ok(())
}

martys keeps a cache table and an event bus of its own, but it isn’t cut off from dio. Every Dio over the order table on this Lens joins the table’s hub. What one of them settles (a confirmed flash, an apply_change, what a reconcile changed, a change its live follow received) reaches the others, and each judges it against its own conditions. Move o1 to doc through dio and it leaves martys. Insert an order for marty and it shows up in martys. A row that only left a narrowed Dio’s set is never taken as a delete: the others re-read it through their own masters.

The hub is keyed by the master’s driver and name. A layer that renames vistas, one name per session, passes the backend table to DioBuilder::hub(key) so its Dios still hear each other. An explicit key also names the cache table, so Dios keyed apart never share cached rows. A master narrowed by a rule its query() doesn’t record (a table hook’s) is built with DioBuilder::recheck_facts(), which re-reads each reported row through that master rather than judging it in memory.

recheck_facts keeps nothing apart. Dios are told apart by vista name, recorded query and hub key only, so a hidden per-session rule needs its own vista name, a vista condition, or its own hub key. Without one, two sessions share a cache table and each can list the other’s rows.

The join the layers below cannot express

A Catalog can already navigate across datasources — hand a Reference a parent row and the catalog’s target, and it returns the related set from another backend. What nothing below the Dio can do is merge: show master rows whose columns come from two sources at once. That is a join, and joins are governed by the capability contract — a SQL Vista can join its own tables, a REST Vista cannot, and two different backends can never push a join down to either engine. A Vista also has nowhere to put stitched rows: no cache, no viewport, no notion of “only visible rows pay”.

Where the backend can’t, the layer above fills the gap — that is the whole job of the Dio. Augmentation wires two Vistas into one Dio: a master that is listed, and a detail source loaded one row at a time and merged on top. The detail is resolved by name through a VistaResolver such as a Catalog, so it is persistence-agnostic — a REST master enriched by a cmd detail, or a SQL master enriched from another database.

It runs in two passes:

  1. List pass — the master is listed cheaply; each row enters the cache marked Incomplete.
  2. Detail pass — viewport-driven: for each visible incomplete row, the augmentation resolves its detail Vista, fetches the matching record, merges the chosen columns, and the row flips to Fresh.

Rows off-screen are never fetched; hydrated rows are never re-fetched. A failed detail fetch marks only that row.

Declaring an augmentation

Augmentations are a property of the Dio, fixed when it is built: DioBuilder::augment takes the list, and supplying at least one is what engages the two-pass behaviour. Each augmentation answers four questions — which detail Vista, how a master row selects its detail, how it is fetched, and which columns to lift:

use std::sync::Arc;

use vantage_diorama::{Augmentation, Cache, Detail, Fetch, Lens, MergeRule, Source};
use vantage_vista::mocks::MockShell;
use vantage_vista::{Catalog, Column, Vista, VistaMetadata};

fn meta(columns: &[&str]) -> VistaMetadata {
    columns
        .iter()
        .fold(VistaMetadata::new(), |m, c| m.with_column(Column::new(*c, "String")))
        .with_id_column("id")
}

fn tfstate_detail() -> Vista {
    Vista::new("tfstate_detail", MockShell::new().with_metadata(meta(&["id", "resources", "serial"])))
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let buckets = Vista::new("buckets", MockShell::new().with_metadata(meta(&["id", "key", "size"])));
let lens = Lens::builder().cache(Cache::memory()).build()?;
let catalog = Catalog::new().with("tfstate_detail", |_: &str| Ok(tfstate_detail()));

let dio = lens
    .dio(buckets)
    .augment(vec![Augmentation::new(
        Detail::Resolved {
            resolver: Arc::new(catalog),     // resolves the detail by name
            name: "tfstate_detail".into(),
        },
        Source::Column { from: "key".into(), to: None },
        Fetch::PerRow,
        MergeRule::lift(["resources", "serial"]),
    )])
    .build()
    .await?;
let _ = dio;
Ok(())
}
  • detail names the detail Vista: Detail::Resolved looks it up by name through a resolver; Detail::Fixed(Arc<Vista>) takes one directly, for a side table no resolver knows.
  • source picks how a master row selects its detail: Source::Id matches master.id → detail.id; Source::Column { from, to } matches master[from] → detail[to], or detail.id when to is None; Source::Build(closure) performs arbitrary narrowing from the whole row, and is per-row only.
  • fetch picks how the detail is read: Fetch::PerRow reads one record per master row; Fetch::Custom(closure) is a caller-supplied async fetch.
  • merge lists the detail columns to lift. MergeRule::lift_all() lifts all of them; on a name clash the detail wins.

Diorama has no YAML form of these types. A consumer maps its own configuration onto them: vantage-ui reads an augment: block from a table’s configuration and builds the same Augmentation:

augment:
  - table: tfstate_detail
    source: { kind: column, from: key }
    fetch:  { kind: per_row }
    merge:  [resources, serial, outputs]

Script sources

A scripted source is a Build closure the consumer writes; diorama never sees a script string. See Augmentation.

To see the two passes run, try the example — two in-memory Vistas, list pass then detail pass:

cargo run -p vantage-diorama --example augmentation

The full treatment of augmentation — merge rules, capability reasoning, the planned batched fetch and keyed caching — is in the Augmentation chapter; the tutorial form is intro step 6.

Why not model the detail as a foreign-key relation?

A relation resolves within one persistence and can be pushed down as a join. The detail here may be a different backend entirely; there is nothing to join on and no engine that could honour it. Augmentation is the cross-Vista form, stitched by the Dio.

Choosing between implicit references and augmentation

Both produce the same user-visible result: a row enriched with related fields. Which one to use depends on where the data lives and who should pay for the fetch.

Path A: implicit references (same datasource, in-query)

One query; the backend does the work. The value is present on every row, including off-screen ones, it is validated when the table is constructed, and it carries read-only column semantics. Choose this when both tables live in one datasource that supports traversal (SQL, SurrealDB, MongoDB) — see Implicit References.

Path B: augmentation (any two datasources, per-row client-side join)

Two passes; expensive work follows the user’s attention — only viewport rows pay, and only once. It works across arbitrary backends, and failed details degrade per-row, not per-page. Choose this when the sources differ, or when the detail is expensive and you want viewport-driven hydration even within one backend.

The key difference: implicit references push the join down into the engine; augmentation lifts it up into the cache. Same user-visible result — a row enriched with related fields — chosen by where the data lives and who should pay for the fetch.

Guide conclusion

This closes the Relations & Traversal guide. At this point you should be able to:

  1. Say where each relation form executes — traversal and implicit references in the engine, erased traversal through the Vista handle, augmentation in the Dio’s cache.
  2. Build master/detail — dio.get_ref(relation, &row) for a Dio over the related rows, each detail set in its own cache table; dio.get_ref_target(relation) for every eligible row.
  3. Narrow a Dio — dio.derive().with_condition(..).build().await? for a narrower view of the same rows.
  4. Declare an augmentation — Detail::Resolved or Fixed, a Source, a Fetch and a MergeRule, handed to lens.dio(master).augment(..) before build().
  5. Choose deliberately between implicit references and augmentation based on where the data lives and who should pay for the fetch.

Relations in Vantage are declared once on the model and then travel — into queries, through erasure, and up into the cache — each layer serving the form the one below can’t.

Model-Driven Architecture

Vantage is opinionated about how you structure business software. The framework prescribes a Model-Driven Architecture (MDA) where entities, relationships, and business rules live in a shared model crate — decoupled from any specific persistence, UI, or API layer.


The idea

Most Rust projects scatter database queries across handlers, services, and utilities. When the schema changes or a new backend is needed, you’re hunting through dozens of files.

Vantage inverts this. You define your entities once in a model crate, then every consumer — REST API, CLI, desktop UI, background worker — uses the same Table and DataSet interfaces. The model is the source of truth.

graph TD
    M[Model Crate] --> API[Axum API]
    M --> CLI[CLI Tool]
    M --> UI[Desktop UI]
    M --> Worker[Background Worker]
    M --> Test[Mock Tests]
    style M fill:#4a7c59,color:#fff

Anatomy of a model crate

The bakery_model3 crate in the Vantage repo demonstrates the pattern. Here’s how it’s structured:

bakery_model3/
 ├── src/
 │    ├── lib.rs          ← re-exports, DB connection helpers
 │    ├── animal.rs       ← a custom column type
 │    ├── bakery.rs       ← Bakery entity + table constructors
 │    ├── client.rs       ← Client entity + table constructors
 │    ├── order.rs        ← Order entity + table constructors
 │    └── product.rs      ← Product entity + table constructors
 └── examples/
      ├── 0-intro.rs              ← conditions and traversal over CSV
      ├── cli-vista.rs            ← multi-source CLI using Vista
      ├── contained-surreal.rs    ← embedded order lines on SurrealDB
      ├── dynamo-smoke.rs         ← the same model on DynamoDB
      └── implicit-references.rs  ← dotted columns

Each entity file follows the same pattern: struct → trait impls → table constructors.


Defining entities

An entity is a plain Rust struct. The #[entity(...)] macro generates Record conversions for each persistence’s type system:

#![allow(unused)]
fn main() {
use vantage_csv::AnyCsvType;
use vantage_mongodb::AnyMongoType;
use vantage_sql::postgres::AnyPostgresType;
use vantage_sql::sqlite::AnySqliteType;
use vantage_surrealdb::types::AnySurrealType;
use vantage_types::entity;

#[entity(CsvType, SurrealType, SqliteType, PostgresType, MongoType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub email: String,
    pub contact_details: String,
    pub is_paying_client: bool,
    pub bakery_id: Option<String>,
}
}

One struct, five type systems. The same Client works with CSV files, SurrealDB, SQLite, Postgres, and MongoDB — each using its own native Record<AnyType> representation. The macro needs each Any*Type in scope, which is why a model file imports them from the driver crates.

No id field in the struct

The entity struct does not include an id field. IDs are managed by the table via with_id_column() — keeping the entity focused on business data.


Table constructors

Each entity provides table constructors per persistence. These are plain functions that return Table<DB, Entity> with columns, relationships, and computed fields pre-configured:

#![allow(unused)]
fn main() {
use vantage_csv::{AnyCsvType, Csv};
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_surrealdb::surrealdb::SurrealDB;
use vantage_surrealdb::thing::Thing;
use vantage_surrealdb::types::AnySurrealType;
use vantage_table::table::Table;
use vantage_types::entity;

#[entity(CsvType, SurrealType, SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Bakery {
    pub name: String,
}

impl Bakery {
    pub fn csv_table(csv: Csv) -> Table<Csv, Bakery> {
        Table::new("bakery", csv).with_column_of::<String>("name")
    }
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Bakery> {
        Table::new("bakery", db).with_id_column("id").with_column_of::<String>("name")
    }
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Bakery> {
        Table::new("bakery", db).with_id_column("id").with_column_of::<String>("name")
    }
}

#[entity(CsvType, SurrealType, SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub is_deleted: bool,
}

impl Order {
    pub fn csv_table(csv: Csv) -> Table<Csv, Order> {
        Table::new("order", csv)
            .with_column_of::<String>("client_id")
            .with_column_of::<bool>("is_deleted")
    }
    pub fn surreal_table(db: SurrealDB) -> Table<SurrealDB, Order> {
        Table::new("order", db)
            .with_id_column("id")
            .with_column_of::<Thing>("client")
            .with_column_of::<bool>("is_deleted")
    }
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_column_of::<bool>("is_deleted")
    }
}

#[entity(CsvType, SurrealType, SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub email: String,
    pub is_paying_client: bool,
}

impl Client {
    pub fn csv_table(csv: Csv) -> Table<Csv, Client> {
        Table::new("client", csv)
            .with_column_of::<String>("name")
            .with_column_of::<String>("email")
            .with_column_of::<bool>("is_paying_client")
            .with_column_of::<String>("bakery_id")
            .with_one("bakery", "bakery_id", Bakery::csv_table)
            .with_many("orders", "client_id", Order::csv_table)
    }

    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::<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()
            })
    }

    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<String>("email")
            .with_column_of::<bool>("is_paying_client")
            .with_column_of::<String>("bakery_id")
            .with_one("bakery", "bakery_id", Bakery::sqlite_table)
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}
}

Notice how the shape is consistent but details differ — SurrealDB uses embedded document references ("bakery" not "bakery_id"), and in this excerpt only SurrealDB gets the computed order_count expression field. The SQL backends support it too (the real client.rs declares it on SQLite and Postgres); CSV can’t, because it has no correlated subqueries to lower it to.


Relationships

Relationships are declared on the table, not the entity. Two methods:

  • with_one("name", "fk_field", constructor) — foreign key to parent (many-to-one)
  • with_many("name", "fk_field", constructor) — parent to children (one-to-many)

Traversal is synchronous and returns a new Table with conditions applied. Reading the result needs a populated database, so this block is compiled but not run:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
    pub is_paying_client: bool,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<bool>("is_paying_client")
            .with_many("orders", "client_id", Order::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
    pub is_deleted: bool,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_column_of::<bool>("is_deleted")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:bakery.sqlite").await.context("connect")?;
let mut paying = Client::sqlite_table(db);
paying.add_condition(paying["is_paying_client"].eq(true));

// Traverse — returns Table<SqliteDB, Order> with a subquery condition
let orders = paying.get_ref_as::<Order>("orders")?;

// The generated query filters orders by paying clients automatically
for (id, order) in orders.list().await? {
    println!("{id}: client {:?}", order.client_id);
}
Ok(())
}

Computed fields

with_expression adds fields that don’t exist in the database — they’re computed via correlated subqueries:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

pub fn client_table(db: SqliteDB) -> Table<SqliteDB, Client> {
    Table::new("client", db)
        .with_id_column("id")
        .with_column_of::<String>("name")
        .with_many("orders", "client_id", Order::sqlite_table)
.with_expression("order_count", |t| {
    let orders = t.get_subquery_as::<Order>("orders").unwrap();
    orders.get_count_query()
})
// SELECT *, (SELECT COUNT(*) FROM client_order WHERE client_order.client_id = client.id)
//   AS order_count FROM client
}
}

These fields appear in ReadableValueSet results alongside physical columns.


Implicit references — dotted columns

Surfacing a related row’s field is common enough that it has a declarative form. A dotted name in with_active_columns traverses declared has_one relations and imports the target’s field as a read-only column, aliased under the literal dotted name — no hand-written expression:

use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Bakery {
    pub name: String,
}

impl Bakery {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Bakery> {
        Table::new("bakery", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Client {
    pub name: String,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<String>("bakery_id")
            .with_one("bakery", "bakery_id", Bakery::sqlite_table)
    }
}

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Order {
    pub client_id: Option<String>,
}

impl Order {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Order> {
        Table::new("client_order", db)
            .with_id_column("id")
            .with_column_of::<String>("client_id")
            .with_one("client", "client_id", Client::sqlite_table)
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let orders = Order::sqlite_table(db)
    .with_active_columns(&["id", "client.name", "client.bakery.name"])?;
// SELECT id,
//   (SELECT name FROM client WHERE client.id = client_order.client_id) AS "client.name",
//   (SELECT (SELECT name FROM bakery WHERE bakery.id = client.bakery_id)
//      FROM client WHERE client.id = client_order.client_id)           AS "client.bakery.name"
// FROM client_order
let _ = orders;
Ok(())
}

client.name is one hop; client.bakery.name recurses through two. Each backend lowers the traversal into its own query — SQL nests correlated scalar subqueries, SurrealDB emits a native idiom path (client.name, each segment escaped separately), and MongoDB adds $lookup stages. The row comes back with a flat key equal to the dotted name (row.get("client.bakery.name")).

A plain (non-dotted) entry simply restricts projection to that column — with_active_columns is also how you narrow a wide table to the columns you actually read. Everything is validated when the table is built, so mistakes surface immediately rather than at fetch time:

  • an unknown column or relation is a build-time error;
  • a has_many hop is rejected (traversal is has_one-only — a to-many field is a set, not a value);
  • a backend that can lower neither a subquery, an idiom path nor a join (e.g. CSV, REST) refuses dotted names up front; same-datasource only.

Imported columns are read-only: they are flagged calculated for consumers, stripped from full-record write payloads (insert, replace, generated-id insert) so a read-modify-save round-trip never tries to persist client.name as a real field, and rejected outright in a patch — a partial payload naming a read-only column is explicit intent, and silently dropping it would turn the patch into a successful no-op. This is the declarative counterpart to the manual with_expression + get_subquery_as recipe above — reach for with_expression when you need an arbitrary expression, and a dotted column when you just want a related field. The full treatment — traversal forms, validation, write semantics, per-backend lowering, and how relations surface on Vistas and Dio — is the Relations & Traversal guide.


Connection management

The model crate owns database connections. bakery_model3 keeps its SurrealDB handle in a OnceLock, set once by a DSN-based connection function. The same pattern for SQLite, which needs a database file, so the block is compiled but not run:

use std::sync::OnceLock;

use vantage_core::{Context, Result, error};
use vantage_sql::sqlite::SqliteDB;

static SQLITE: OnceLock<SqliteDB> = OnceLock::new();

pub async fn connect_sqlite() -> Result<()> {
    let dsn = std::env::var("SQLITE_URL").unwrap_or_else(|_| "sqlite:bakery.sqlite".into());
    let db = SqliteDB::connect(&dsn).await.context("Failed to connect to SQLite")?;
    SQLITE.set(db).map_err(|_| error!("SQLite connection already set"))
}

pub fn sqlite() -> SqliteDB {
    SQLITE.get().expect("call connect_sqlite() first").clone()
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
connect_sqlite().await?;
let _ = sqlite();
Ok(())
}

Consumers call connect_sqlite() once at startup, then use Client::sqlite_table(sqlite()) anywhere.


Using the model

Once the model crate exists, consumers are simple. They don’t know or care about SQL, SurrealQL, or BSON — they work with entities and tables. The handler and the CLI read a database, so the block is compiled but not run:

use std::sync::OnceLock;

use axum::Json;
use serde::Serialize;
use vantage_memory::{AnyMemoryType, MemoryDB};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType, MemoryType)]
#[derive(Debug, Clone, PartialEq, Default, Serialize)]
pub struct Client {
    pub name: String,
    pub is_paying_client: bool,
}

impl Client {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<bool>("is_paying_client")
    }

    pub fn memory_table(db: MemoryDB) -> Table<MemoryDB, Client> {
        Table::new("client", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<bool>("is_paying_client")
    }
}

static SQLITE: OnceLock<SqliteDB> = OnceLock::new();

fn sqlite() -> SqliteDB {
    SQLITE.get().expect("connected").clone()
}

// In an Axum handler
async fn list_clients() -> Json<Vec<Client>> {
    let mut clients = Client::sqlite_table(sqlite());
    clients.add_condition(clients["is_paying_client"].eq(true));
    Json(clients.list().await.unwrap().into_values().collect())
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let _ = list_clients;
// In a CLI tool
let table = Client::sqlite_table(sqlite());
println!("{} clients", table.get_count().await?);

// In a test — no database needed
let table = Client::memory_table(MemoryDB::new());
table.insert_return_id(&Client { name: "Marty".into(), is_paying_client: true }).await?;
assert_eq!(table.list().await?.len(), 1);
Ok(())
}

The test reaches for vantage-memory, an in-process store with a typed TableSource. The model gives it one more constructor and one more type system in #[entity(..)]; nothing else about Client changes.

Multi-persistence models

The same model crate can expose table constructors for multiple persistences. Your production code uses Postgres, your CLI reads CSV exports, your tests use mocks — all sharing the same entity definitions and business rules.


Type-erased access

For truly generic code (UI grids, admin panels, config-driven tools), wrap tables in a Vista — the schema-bearing handle that replaced the removed AnyTable in 0.5. Each driver’s factory wraps its own tables, and every result is the same type. Listing reads real data, so the block is compiled but not run:

use vantage_csv::{AnyCsvType, Csv};
use vantage_dataset::ReadableValueSet;
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::table::Table;
use vantage_types::entity;
use vantage_core::Context;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
}

impl Product {
    pub fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db).with_id_column("id").with_column_of::<String>("name")
    }
}

#[entity(CsvType)]
#[derive(Debug, Clone, Default)]
pub struct Order {
    pub client_id: String,
}

impl Order {
    pub fn csv_table(csv: Csv) -> Table<Csv, Order> {
        Table::new("order", csv).with_column_of::<String>("client_id")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let sqlite = SqliteDB::connect("sqlite:bakery.sqlite").await.context("connect")?;
let csv = Csv::new("bakery_model3/data");
let vistas = vec![
    sqlite.vista_factory().from_table(Product::sqlite_table(sqlite.clone()))?,
    csv.vista_factory().from_table(Order::csv_table(csv.clone()))?,
];

// Same code handles both — different backends, same interface
for v in &vistas {
    println!("{}: {} records", v.name(), v.list_values().await?.len());
}
Ok(())
}

See also: bakery_model3/examples/cli-vista.rs for a complete multi-source CLI built on this pattern.


The layered architecture

Putting it all together, Vantage prescribes four layers:

┌─────────────────────────────────────────────────┐
│  4. Consumers                                   │
│     Axum API · CLI · egui · Tauri · gRPC        │
├─────────────────────────────────────────────────┤
│  3. Business Logic                              │
│     Traits on Table · custom methods · rules    │
├─────────────────────────────────────────────────┤
│  2. Model Crate                                 │
│     Entities · Table constructors · Relations   │
├─────────────────────────────────────────────────┤
│  1. Persistence                                 │
│     SurrealDB · Postgres · SQLite · CSV · API   │
└─────────────────────────────────────────────────┘

Layer 1 is implemented once per database (or use an existing Vantage crate). Layer 2 is your model crate — entity definitions, relationships, computed fields. Layer 3 adds business-specific traits and methods on top of Table. Layer 4 is any number of consumers that import the model and don’t think about persistence.

Why this works

The model is the only place that knows about database structure. Business logic works against abstract Table and DataSet interfaces. Consumers work against business logic traits. Change the database — only layer 1 and 2 change. Add a new UI — only layer 4 changes. The architecture scales to hundreds of entities and dozens of developers without coupling.

Three Paths for Developers

Vantage gives you three ways to work with data. Each path builds on the previous one, and all three converge into a unified data abstraction layer.


graph TD
    EF["① Entity Framework<br/>CRUD over business objects"]
    QB["② Query Building<br/>Vendor-specific SQL power"]
    CP["③ Custom Persistences<br/>APIs, caches, reactive data"]
    EF --> DAL["Universal Data Abstraction Layer"]
    QB --> DAL
    CP --> DAL
    DAL --> E["Export: FFI · API · UI · SDK"]
    style EF fill:#4a7c59,color:#fff
    style QB fill:#2d6a8f,color:#fff
    style CP fill:#8f5a2d,color:#fff
    style DAL fill:#7c2d8f,color:#fff
    style E fill:#555,color:#fff

① Entity Framework

The most common path. Define entities, build tables, use DataSet and ActiveEntity for CRUD — your code never touches SQL or any query language. The block reads and writes a populated database, so it is compiled but not run:

use vantage_dataset::prelude::ActiveEntitySet;
use vantage_sql::postgres::AnyPostgresType;
use vantage_sql::prelude::*;
use vantage_surrealdb::types::AnySurrealType;
use vantage_types::prelude::*;

// Define once
#[entity(SurrealType, SqliteType, PostgresType)]
#[derive(Debug, Clone, Default)]
struct Product {
    name: String,
    price: i64,
    is_deleted: bool,
}

impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
            .with_column_of::<bool>("is_deleted")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:bakery.sqlite").await.context("connect")?;
let id = "flux_cupcake";

// Use everywhere
let table = Product::sqlite_table(db);
let expensive = table.clone().with_condition(table["price"].gt(200i64));
let count = expensive.get_count().await?;

let mut item = table.get_entity(id).await?.unwrap();
item.price = 350;
item.save().await?;
let _ = count;
Ok(())
}

This is the bread and butter of enterprise development — hundreds of entities, each with relationships, computed fields, and business rules, all persistence-agnostic and testable with mocks.

When to use this path

You’re building business software with well-defined entities (Client, Order, Invoice). You want clean separation between persistence and logic. You don’t need vendor-specific SQL features.


② Query Building

When you need the full power of your database — JOINs, CTEs, window functions, JSONB operators, array aggregation, recursive queries — drop into the vendor-specific query builder.

SQLite — CASE, UNION, window functions

#![allow(unused)]
fn main() {
use vantage_expressions::{Expressive, Order, Selectable};
use vantage_sql::primitives::alias::AliasExt;
use vantage_sql::sqlite::sqlite_ident as ident;
use vantage_sql::sqlite::statements::SqliteSelect;
use vantage_sql::sqlite_expr;
use vantage_sql::primitives::case::Case;

let select = SqliteSelect::new()
    .with_source("users")
    .with_field("name")
    .with_field("salary")
    .with_expression(
        Case::new()
            .when(sqlite_expr!("{} >= {}", (ident("salary")), 100000.0f64),
                  sqlite_expr!("{}", "senior"))
            .when(sqlite_expr!("{} >= {}", (ident("salary")), 60000.0f64),
                  sqlite_expr!("{}", "mid"))
            .else_(sqlite_expr!("{}", "junior"))
            .as_alias("band"),
    )
    .with_order(ident("salary"), Order::Desc);
assert!(select.preview().starts_with(r#"SELECT "name", "salary", CASE WHEN"#));
}

PostgreSQL — DISTINCT ON, LATERAL JOIN, array ops

#![allow(unused)]
fn main() {
use vantage_expressions::{Expressive, Order, Selectable};
use vantage_sql::postgres::operation::PostgresOperation;
use vantage_sql::postgres::pg_ident as ident;
use vantage_sql::postgres::statements::PostgresSelect;
use vantage_sql::postgres::statements::select::join::PostgresSelectJoin;
use vantage_sql::primitives::alias::AliasExt;
let select = PostgresSelect::new()
    .with_distinct_on(ident("user_id").dot_of("o"))
    .with_source_as("orders", "o")
    .with_expression(ident("name").dot_of("u"))
    .with_expression(ident("id").dot_of("o").with_alias("order_id"))
    .with_expression(ident("total").dot_of("o"))
    .with_join(PostgresSelectJoin::inner(
        "users", "u",
        ident("id").dot_of("u").eq(ident("user_id").dot_of("o")),
    ))
    .with_order(ident("user_id").dot_of("o"), Order::Asc)
    .with_order(ident("created_at").dot_of("o"), Order::Desc);
}

Both examples are type-safe — parameters are bound with the correct CBOR type markers, identifiers are quoted for the target dialect, and results deserialize into typed structs. See the full test suites: SQLite complex queries, PostgreSQL complex queries.

Bridge back into the entity framework

Complex queries don’t have to live in isolation. Wrap them as expression fields (with_expression) or as custom methods on your Table — they become part of the entity framework, available to every consumer of your model crate.

When to use this path

You need vendor-specific features — recursive CTEs, window functions, DISTINCT ON, JSONB operators, generate_series, array aggregation. You want full control over the query while keeping type safety and parameterised binding.


③ Custom Persistences

Vantage isn’t limited to databases. Implement the persistence traits for anything that stores or produces data:

  • REST APIs — vantage-api-client wraps any paginated JSON API as a read-only TableSource
  • Resilient transport — vantage-api-pool adds retry, a circuit breaker, rate limiting and auth refresh to API traffic
  • CSV files — vantage-csv reads structured files with in-memory conditions
  • Local caches — vantage-diorama keeps a local copy (in memory or in a redb file) of any slow backend (Postgres, SurrealDB, a REST API) for responsive UIs
  • Message queues — implement InsertableDataSet for append-only sources like Kafka topics
  • Mixed sources — read from SQL, write through a queue, cache in memory — same Table interface

A cache sits above the backend rather than replacing it. Wrap the permanent table as a Vista, put a Dio in front of it, and the Dio hands back the cached copy as another Vista. The cache is filled from a database, so the block is compiled but not run:

use vantage_dataset::ReadableValueSet;
use vantage_diorama::{Cache, Lens, Loader};
use vantage_sql::prelude::*;
use vantage_types::prelude::*;

#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product {
    name: String,
}

impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db).with_id_column("id").with_column_of::<String>("name")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:bakery.sqlite").await.context("connect")?;
// The permanent backend, type-erased
let products = db.vista_factory().from_table(Product::sqlite_table(db.clone()))?;

// A local cache in front of it
let lens = Lens::builder()
    .cache(Cache::redb("cache.redb"))
    .loader(Loader::eager(|cx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .build()?;
let cached = lens.dio(products).build().await?;

// Reads come from the cache; your UI code doesn't care which it holds
let rows = cached.cached().list_values().await?;
let _ = rows;
Ok(())
}

When to use this path

You’re integrating with external APIs, building offline-first applications, implementing reactive data layers, or combining multiple data sources behind a single interface. Your custom persistence becomes a first-class citizen in the entity framework.


Convergence

All three paths converge into the same data abstraction layer. Entity framework tables, raw query results, and custom persistence data all flow through Table, DataSet, and Vista — giving you one interface for the entire organisation’s data.

┌──────────────────────────────────────────────────────────────┐
│                  Data Abstraction Layer                       │
│                                                              │
│   Table<SurrealDB, Client>    ← entity framework             │
│   Table<PostgresDB, Report>   ← complex queries              │
│   Table<RestApi, ExternalData> ← custom persistence          │
│   Dio over any Vista          ← cached + reactive            │
│                                                              │
│   Tables implement: DataSet · ValueSet · ActiveEntitySet     │
│   All wrap into: Vista (type-erased, generic code)           │
└──────────────────────────────────────────────────────────────┘
                              │
              ┌───────────────┼───────────────┐
              ▼               ▼               ▼
          Axum API        egui/Tauri       FFI / SDK

This is where Rust’s type system pays off at scale. Every Table is checked at compile time. Every persistence has strict type boundaries. Every consumer works against abstract traits. Add a new database, API, or cache — nothing else changes. Add a new UI framework or language binding — the model stays the same.

The architecture that scales

Vantage turns your data layer into a universal abstraction. Start with one database and a handful of entities. Over time, add more persistences, more entities, more consumers. The architecture doesn’t bend — Rust’s ownership model and trait system ensure each layer stays clean, each boundary stays enforced, and each team works independently.

Config-Driven Vistas: YAML & Rhai

The Vista chapter built Vistas by wrapping typed tables — Rust code, compiled in. This page covers the other construction path: declare the Vista in YAML, load it at runtime, and seal it behind the same honest handle. Change the YAML, rebuild the Vista, and the consumer sees the new shape — no recompiling.

This is the path configuration-driven tooling takes: admin panels reading a folder of model files, AI agents writing schema on a user’s behalf, and any application (Vantage UI among them) whose data layer is user-editable data rather than user-compiled code. For everything YAML can’t express — vendor expressions, derived queries, scripted traversal — there’s an optional Rhai layer that compiles to native queries.


One page of YAML, one working Vista

Every driver’s factory implements VistaFactory, whose from_yaml parses a spec and lowers it. from_yaml is a trait method, so the trait has to be in scope; it is imported by name from vantage_vista, not through a prelude. The block reads a populated product table, so it is compiled but not run:

use vantage_dataset::ReadableValueSet;
use vantage_sql::prelude::*;
use vantage_vista::VistaFactory;

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
let yaml = r#"
name: product_view
columns:
  id:
    type: string
    flags: [id]
  name:
    type: string
    flags: [title, searchable]
  price:
    type: int
sqlite:
  table: product
"#;

let vista = db.vista_factory().from_yaml(yaml)?;

assert_eq!(vista.name(), "product_view");
assert_eq!(vista.id_column(), Some("id"));
let rows = vista.list_values().await?;   // reads the `product` table
let _ = rows;
Ok(())
}

The factory builds a typed Table under the hood — each type: becomes a real Column<T> — and wraps it exactly as from_table would. From here on, nothing downstream can tell how the Vista was made: same schema introspection, same conditions, same capability contract.

Two implicit rules worth knowing:

  • The Vista’s name is the spec’s name, not the storage name. product_view is what a UI tab or a catalog key sees; sqlite: { table: product } is where the rows live. Omit the block and the spec name doubles as the table name.
  • The id column resolves in a fixed order: explicit id_column: at the top level wins, then the first column flagged id, then a driver default ("id" for SQL, "_id" for MongoDB). The explicit key exists so a YAML author can correct a wrong flag without touching anything else.

Anatomy of a spec

A VistaSpec has a uniform core that every driver understands, plus one driver-named block at each level:

KeyWhat it is
nameThe vista’s public name (catalog key, UI label)
datasourceOptional datasource key, for inventories that manage several
id_columnExplicit id override (see resolution order above)
columnsOrdered map of column name → { type, flags, references, lazy, <driver> }
referencesNamed relations to other vistas (see below)
containedEmbedded-in-row relations (see below)
<driver>The driver’s table-level block: sqlite:, surreal:, mongo:, csv:

Column types

type: names lower to real typed columns; unknown names are a parse error, not a fallback:

YAML typeRust column
int, integer, i64, i32Column<i64>
float, double, f64, f32Column<f64>
bool, booleanColumn<bool>
string, text, str (default)Column<String>
decimal, numericColumn<Decimal>
date / time / datetimechrono naive types
timestampDateTime<Utc>

flags: is the same open vocabulary the Vista chapter introduced — id, title, searchable, orderable, hidden, mandatory — and drives the same consumer behaviour (title columns label rows, searchable feeds quicksearch, hidden drops out of default views).

Typos fail at parse time

Every driver block sets deny_unknown_fields, so sqlite: { tabel: product } is rejected when the YAML is parsed — with the offending key in the message — instead of silently falling back to a default and failing later at query time. Driver-block validation follows the same rule: an empty nested_path, a a..b path, mutually-exclusive options — all surface as parse errors naming the column.

Column mapping per driver

When the spec column name differs from the storage name, the column’s driver block maps it:

DriverColumn blockMeaning
SQLitesqlite: { column: unit_price }SELECTed under the SQL name, aliased back to the spec name
Surrealsurreal: { field: unitPrice }Read/written under the Surreal field name
MongoDBmongo: { field: unitPrice }Single-level BSON rename
MongoDBmongo: { nested_path: price.amount }Dotted path into a nested document — projected on read, sub-documents rebuilt on write, dotted form pushed down on filter
CSVcsv: { source: "Unit Price" }CSV header to read from
GraphQLgraphql: { field: unit_price }Selected as a GraphQL alias, filtered and sorted under the server name (GraphQL blocks)

MongoDB adds the column types object_id, datetime (aliases date, timestamp; a BSON DateTime) and decimal (Decimal128). Writes keep these types, and text that doesn’t parse as the column’s type is rejected. A column named with a dotted path through a reference (client.name) joins via $lookup, which compares types strictly: a string foreign key does not join an ObjectId _id.

A MongoDB table block can replace the collection read with an aggregation pipeline, or script it:

Table blockMeaning
mongo: { pipeline: [...] }Read-only view over an aggregation pipeline (MongoView)
mongo: { rhai: "..." }Builds the pipeline in Rhai from args (rhai feature)
mongo: { modify: "..." }Rhai run last against the built vista as self
mongo: { view: true }The collection is a server-side view: read-only, not watched, not imported

Change streams (Capability::Subscribe) and atomic imports (Capability::Import) need a replica set; see Live data. Streams run on a dedicated client sized by MongoDB::with_stream_pool_size (default 100).

CSV is also the one driver whose table block is mandatory — csv: { path: data/products.csv } — because without a path there is no file.


References

A relation to another vista, in the same uniform vocabulary the typed with_one/with_many uses:

name: category
columns:
  id: { type: string, flags: [id] }
  name: { type: string, flags: [title] }
references:
  products:
    table: product
    kind: has_many
    foreign_key: category_id

Each entry lowers to one Reference in the vista’s metadata. Three shapes are available:

  • Full form (above): table, kind (has_one / has_many), foreign_key. A has_one matches its foreign_key on this row to the target’s id; a has_many matches this row’s id to the target’s foreign_key. Without foreign_key:, the entry’s name is the key.

  • Column sugar — when the foreign key is the column, declare it inline. The relation takes the column’s name, and a references: entry of the same name takes precedence:

    columns:
      category_id:
        type: string
        references: category      # has_one named `category_id`, foreign_key = the column itself
    
  • Multi-key joins — keys: lists { from, to } pairs when the child is narrowed by more than one parent field (a deployment matched on both product_id and version_id). Each pair reads the parent row’s from column and constrains the target’s to column, and traversal applies every pair. When keys: is present it is the whole join, and foreign_key: is not read:

    references:
      deployments:
        table: deployment
        kind: has_many
        keys:
          - { from: product_id, to: product }
          - { from: version_id, to: version }
    

On MongoDB a reference key must be stored under its own field name. The vista fails to build when a reference’s foreign key, or the id column it matches on the other side, is renamed with field: or moved with nested_path:. A reference with a mongo: { rhai: } script (below) is exempt, as its traversal runs the script. Joined columns (client.city) can still read nested fields of the target.

The spec resolver

A reference names its target by spec name — so the factory needs a way to find the target’s current spec at traversal time. That’s the spec resolver, attached once:

use std::collections::HashMap;
use std::sync::Arc;

use ciborium::Value as CborValue;
use vantage_sql::prelude::*;
use vantage_sql::sqlite::vista::SqliteVistaSpec;
use vantage_types::Record;
use vantage_vista::{ColumnSpec, SpecResolver, VistaFactory};

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let category_yaml = r#"
name: category
columns:
  id: { type: string, flags: [id] }
  name: { type: string, flags: [title] }
references:
  products:
    table: product
    kind: has_many
    foreign_key: category_id
"#;
let mut product = SqliteVistaSpec::new("product");
product.columns.insert("id".to_string(), ColumnSpec::new().with_type("string").with_flag("id"));
product.columns.insert("category_id".to_string(), ColumnSpec::new().with_type("string"));
let specs: HashMap<String, SqliteVistaSpec> = HashMap::from([("product".to_string(), product)]);
let resolver: Arc<dyn SpecResolver<SqliteVistaSpec>> =
    Arc::new(move |name: &str| specs.get(name).cloned());
let factory = db.vista_factory().with_resolver(resolver);
let category = factory.from_yaml(category_yaml)?;

// Traversal rebuilds `product` from its live spec:
let row: Record<CborValue> = [("id".to_string(), CborValue::Text("pastry".into()))].into_iter().collect();
let products = category.get_ref("products", Some(&row))?;
assert!(products.is_narrowed());
Ok(())
}

SpecResolver<S> is the same trait for every driver, with the driver’s spec type as S, and a factory holds it as Arc<dyn SpecResolver<S>>. Any Fn(&str) -> Option<S> closure implements it (annotate its argument as &str, as above, or the closure won’t take every lifetime), and a type of your own can implement resolve directly — back it with an in-memory map, a folder of .yaml files, or a hot-reloading inventory. Because targets are re-resolved on every traversal, editing a spec changes what the next traversal builds. A name the resolver doesn’t know, or a target that fails to build, is a traversal error, never a wider table. Without a resolver at all, a traversal falls back to a column-less target and the next query fails loudly.

The build injector

Some rules belong on every table a factory builds, not on one spec: a tenant filter, an access-control condition, hooks that stamp who wrote a row. Copying them into each spec is how one gets forgotten. A build injector runs on every table the factory builds from a spec: the root, each reference target when it is traversed, each base: table under a derived vista, and tables built inside scripts. It runs after the table is built and before it becomes a vista, and it can add conditions, register hooks, or fail the build:

use std::sync::Arc;

use vantage_sql::prelude::*;
use vantage_table::table::{BuildKind, InjectCx, TableInjector};
use vantage_table::traits::table_source::TableSource;

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let tenant_only: TableInjector<SqliteDB> = Arc::new(|cx: &InjectCx<'_, SqliteDB>, table| {
    if cx.kind == BuildKind::Root && cx.key == "audit_log" {
        return Err(vantage_core::error!("audit_log is not exposed"));
    }
    let tenant = AnySqliteType::new("acme".to_string());
    let condition = table.data_source().eq_value_condition("tenant_id", tenant)?;
    table.add_condition(condition);
    Ok(())
});
let factory = db.vista_factory().with_injector(tenant_only);
let _ = factory;
Ok(())
}

InjectCx carries the spec key, the BuildKind (Root, Reference { from, relation } or Base { derived }), and build, which builds another table by key through the same factory and injector. That is how an injector expresses “orders the caller may see are those of clients the caller may see”: it builds client and narrows by its ids. A build that would re-enter a key already being built is an error naming the cycle (a -> b -> a), not a stack overflow.

On a Base build only the conditions take effect. The derived vista is a fresh table over the base’s select(), so the base’s conditions carry into it but hooks an injector registers on the base never fire. The injector runs on the derived build too (as Root or Reference); register hooks there. BuildKind and InjectCx are #[non_exhaustive], so match BuildKind with a wildcard arm.

The SQLite, Postgres, MySQL, SurrealDB, MongoDB and CSV factories take with_injector. A reference target that fails injection fails its traversal, as above. A vista whose writes go through Table, so that the injector’s hooks fire on them, advertises Capability::WritesReachHooks (writes_reach_hooks).


The Rhai layer

YAML declares structure. For anything with an expression in it, specs escalate to Rhai — a small, embeddable scripting language whose Vantage vocabulary compiles to native queries. The expression primitives are shared across backends where the concept overlaps (count, avg, coalesce, case_when, date_format …) — see SQL Primitives and SurrealDB Primitives for the full vocabularies. A script calls them through its backend’s namespace (sql::count(..), surreal::count(..), mongo::oid(..)), so they never shadow the data vocabulary’s table(name). Rhai appears in a spec in five places, each with a distinct job. The first two build native queries; the next two use the data vocabulary taught in Scripting with Rhai; the last computes a column from one row.

1. Query-sourced vistas — rhai:

Replace the physical table with a script-built SELECT:

name: expensive_products
columns:
  id: { type: string, flags: [id] }
  name: { type: string }
sqlite:
  rhai: |
    sql::select().from("product").field("id").field("name").where(sql::expr("price > 150"))

The script runs once at build time and its SELECT becomes the vista’s source. A query-sourced vista is read-only — the factory leaves Insert, Update, Delete and ConfineWrites out of its capabilities, because there’s no single table a write could honestly land in. Capability honesty is preserved automatically; consumers find out by checking, same as ever.

2. Derived vistas — base: + inherit + transform

Derive one vista from another, inheriting schema and transforming the query. The base’s select() is seeded into the script’s scope as base:

name: category_totals
id_column: category_id
columns:
  total_price: { type: int }
sqlite:
  base: product
  inherit:
    columns: [category_id]
  rhai: |
    base.clear_fields().field("category_id")
        .expression(sql::expr("SUM(price) AS total_price"))
        .group_by(sql::expr("category_id"))

This is the aggregate pattern: group the base by a key, declare the aggregate outputs as the derived vista’s own columns:, and re-key with id_column. base: resolves eagerly through the same spec resolver as references. Derived vistas are query-sourced, hence read-only.

3. Post-build tweaks — modify: (SurrealDB, MongoDB, MySQL)

A script applied to the finished vista, exposed as self, using the table handle’s verbs plus vendor expressions YAML keys can’t state (see modify: scripts):

name: active_products
columns:
  id: { type: thing, flags: [id] }
  name: { type: string, flags: [title] }
surreal:
  table: product
  modify: |
    self.with_condition(surreal::ident("is_deleted") == false)
        .sort("name", "asc")

Unlike a query source, modify: narrows a real table — so the vista stays writable. It runs last, composing with table:, rhai:, or base:. Writability follows the underlying source: a modify: on a rhai: or base: vista stays read-only, while on a plain table it stays writable. MySQL takes the same script in a mysql: block (mysql: { modify: "..." }), with the with_condition(expr) verb, which hands its expression to the shell as a native condition (from Rust: Vista::add_native_condition).

4. Scripted reference traversal — reference-level rhai: (SurrealDB, MongoDB, MySQL)

When a relation can’t be expressed as a foreign-key equality — a graph edge, a computed join — the reference carries its own build script, evaluated lazily at traversal time with the parent row in scope:

references:
  products:
    table: product
    kind: has_many
    foreign_key: category
    surreal:
      rhai: |
        table("product").where("category", row.id)

The script ends on a table handle, which becomes the narrowed target vista; foreign_key remains as metadata for consumers that introspect the relation. See Reference build scripts.

MongoDB takes the same script in the reference’s mongo: block (rhai feature):

references:
  orders:
    table: order
    kind: has_many
    foreign_key: client_id
    mongo:
      rhai: |
        table("order").where("client_id", row._id)

MySQL takes it in the reference’s mysql: block (mysql: { rhai: "..." }, rhai feature).

5. Computed columns — column-level lazy: (every driver)

A column whose value no backend stores, computed by the Vista from the row it read:

columns:
  net: { type: int }
  vat: { type: int, lazy: "row.net / 5" }

The script sees the row as row, and its last expression is the value. Columns compute in declaration order, writes drop them, and filtering, ordering or aggregating on one is an error naming the column. A has-one reference may use one as its foreign key; a has-many may not. Full details in Computed Columns.

YAML primary, Rhai targeted

The division of labour is deliberate: YAML stays the canonical, declarative format that every backend understands; Rhai is the serializable escape hatch you reach for only when a source, transform, or traversal needs an expression. A spec with no Rhai in it works on a build without the rhai feature; one that uses it fails loudly there instead of degrading.


Contained relations

Embedded objects and arrays — an order’s lines, a JSON column — declare as a contained: section and surface as editable sub-vistas:

name: order
columns:
  id: { type: string, flags: [id] }
  lines: { type: string }          # the host column — declare it so it's selected
contained:
  lines:
    host_column: lines
    kind: contains_many
    id_column: line_id             # omit for positional ids
    columns:
      product: { type: string }
      quantity: { type: int }

Reads project the embedded collection into records; writes patch it back into the host column. The mechanics — and the sharp edges — are covered in Contained Relations.


Cross-persistence: the Catalog

A single Vista is strictly single-backend. When a system spans several — categories in Postgres, products behind a REST API — a Catalog sits one layer up: it holds a loader per model name, and a relation whose target lives in a different persistence is a Reference narrowed over the target the catalog builds. Here two in-memory SQLite databases stand in for the two backends:

use ciborium::Value as CborValue;
use vantage_sql::prelude::*;
use vantage_types::Record;
use vantage_vista::{Catalog, JoinKey, Reference, VistaFactory, VistaResolver};

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let pg = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let api = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let category_yaml = "name: category\ncolumns:\n  id: { type: string, flags: [id] }\n".to_string();
let product_yaml =
    "name: product\ncolumns:\n  id: { type: string, flags: [id] }\n  category_id: { type: string }\n"
        .to_string();
let (pg_factory, api_factory) = (pg.vista_factory(), api.vista_factory());
let catalog = Catalog::new()
    .with("category", move |_: &str| pg_factory.from_yaml(&category_yaml))
    .with("product", move |_: &str| api_factory.from_yaml(&product_yaml));
let relation = Reference::has_many(
    "products",
    "product",
    JoinKey::new("id", "category_id"), // parent `id` constrains the target's `category_id`
);

let row: Record<CborValue> = [("id".to_string(), CborValue::Text("pastry".into()))].into_iter().collect();
let products = relation.narrow(catalog.vista(&relation.target)?, &row)?;  // one backend → the other
assert!(products.is_narrowed());
Ok(())
}

Loaders return a fresh, unconditioned Vista on every call, so the catalog composes with hot-reloading inventories the same way spec resolvers do. A name the catalog doesn’t hold is a not-found error; a loader that fails keeps its own error. The catalog is a VistaResolver, so it goes wherever a resolver is expected: it is also what Augmentation uses to resolve its detail sources — the augment: block in a table’s configuration is this same machinery pointed at row enrichment.


Sealed at runtime: data scripts

Once vistas are config-defined, the last step of the story is consuming them from config too. vantage-vista’s rhai feature ships run_script, a sandboxed evaluator where table(name) resolves through a catalog-style resolver. It’s the surface an AI agent or an MCP tool drives: the schema it sees, the capabilities it must respect, and the rows it reads all come from the same sealed handles this page built. The vocabulary, its limits and every other place it runs are covered in Scripting with Rhai.


Choosing a path

Both construction paths produce the same Vista — the choice is about who edits the definition:

  • Typed from_table when the model is code: business logic, compile-time safety, entity structs, with_expression closures. This is the path the introduction guide walks.
  • YAML (+ Rhai) when the model is data: inventories on disk, user- or agent-edited schema, hot-reload, no recompiling. Structure in YAML; expressions in Rhai; capabilities sealed either way.

They mix freely — a catalog can hold typed-table loaders next to YAML loaders, and a YAML spec’s reference can resolve to either. If you’re implementing this machinery for a new backend, the driver-side walkthrough is Adding a New Persistence, Step 8.

Scripting with Rhai

A Vantage application can keep much of its behaviour in configuration: YAML table specs, page definitions, action bodies, faker sims. Some of that configuration needs logic: a column derived from other columns, a filter that depends on the selected row, a write after a form is submitted, a simulated order that ships after twenty minutes. That logic is written in Rhai, a small scripting language embedded in Rust.

Rhai is part of vantage: any application that holds Vistas can run these scripts. Vantage UI is one such application and is used as an example where it helps.

This part of the book is the one place that teaches scripting across the framework. Other chapters link here instead of repeating the verbs.


Why scripts

Configuration is data. A user, an agent or a hot-reloading inventory can change it while the application runs, and nothing gets recompiled. Rust code can’t follow it there, so the logic that travels with the configuration has to be data too: text that the application compiles and runs when it loads the YAML.

Rhai fits that job:

  • It runs in the process. There is no interpreter to install and no subprocess. A script calls straight into the same Vistas the Rust code uses.
  • It can only do what the host allows. A Rhai script has no file system, network or process access of its own. It sees the functions the host registered and nothing else.
  • It can’t hang the caller. Every host caps the number of operations a script may run. A script that loops forever fails with an error.
  • It is backend-neutral. Data scripts act on Vistas, so the same script runs over SQLite, SurrealDB, a REST API or an in-memory store.

What a script can do

TaskExampleChapter
Read a set of rows, count it, follow a relationtable("client").where("vip", true).ref("orders").count()The table handle
Insert, patch, delete, copy rows between tablestable("order").patch(id, #{ status: "paid" })Writes
Compute a column from the other columns of a rowlazy: "row.net + row.vat"Computed columns
Edit one row field by field and write what changedrow.status = "paid"; row.flash();Records
Narrow a table in YAML, or build a relation’s targetself.where("vip", true)Surfaces
Build a backend’s native querysql::select().from("product").field("id")Expression dialects

Where scripts run

A script always runs inside a host: a Rhai engine with fixed operation limits, a compile cache, and the vocabularies the host chose to register. The host decides what table(name) resolves to, whether writes are allowed, and how many rows a list() may return. The same script text can be legal in one place and refused in another.

SurfaceExampleWhat the script can do
Agent data scripts (run_script)an MCP tool reading or fixing dataread, and write if the host allows it
Query preview (preview_script)an MCP tool showing the query a script would rundescribe a set, never read it
YAML modify:, reference build scriptsself.where("vip", true)describe a set
YAML lazy: columnsrow.net / 5compute one value from one row
An application’s form options: (e.g. Vantage UI)a dropdown filled from another tableread
An application’s action bodies, form on_submit, wizard workers (e.g. Vantage UI)row.status = "paid"; row.flash();read and write
Faker simsan order that ships, then disappearsread and write a memory store, plus time and random verbs

Hosts explains how a host is put together, and Surfaces walks through each row of this table with a working example.

Rhai in two minutes

Rhai reads like a mix of Rust and JavaScript. The parts these chapters use:

RhaiMeaning
let x = 5;a variable; no type annotations
#{ name: "Ada", vip: true }an object map; read with m.name or m["name"]
[1, 2, 3]an array
|c| c.namea closure, as in rows.map(|c| c.name) and rows.filter(|c| c.vip)
()“nothing”: a missing field, a row that wasn’t found
a ?? ba, or b when a is ()
`total: ${t}`string interpolation
if c { a } else { b }an expression; it has a value
try { … } catch (err) { … }, throw "message"catching and raising errors

A script’s value is its last expression, with no return and no trailing ;. Methods chain, so table("order").where("status", "due").count() is three calls on the result of the one before. The Rhai book covers the full language.

A first script

Every data script uses the same handful of words. table(name) names a table, a chain of narrowing verbs describes a set of its rows, and a terminal verb reads or writes:

let vips = table("client").where("vip", true);
let due = vips.ref("orders").where("status", "due");

#{
    vips: vips.count(),
    due: due.ids(),
}

vips and due are handles: descriptions of a set. Nothing is read until count() or ids() runs, and the handle is then resolved into a Vista and read through it. ref("orders") follows a relation from every row of the set, so due is the due orders of every VIP client. The script’s last expression is its result; here a map, which a host can turn into JSON.

The script never mentions SQL, SurrealDB or HTTP. The words act on Vistas, and every backend can be wrapped as a Vista.

The layers

Vista is the universal data handle: any backend, one type-erased interface. The data vocabulary lives in vantage-vista (behind its rhai feature) and knows nothing except Vistas. Three things sit above Vista, and each has its own scripting words:

  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐
  │ Servo        │  │ Scenery      │  │ faker sims       │
  │ form drafts  │  │ view shapes  │  │ time, random,    │
  │ (diorama)    │  │ (vantage-ui) │  │ fake_row (faker) │
  └──────┬───────┘  └──────┬───────┘  └────────┬─────────┘
         │                 │                   │
  ┌──────┴─────────────────┴───────────────────┴─────────┐
  │ Vista data vocabulary: table, where, sort, ref,      │
  │ list, get, insert, patch, record, ...                │
  └──────────────────────────────────────────────────────┘

The lower layer never refers to the upper ones. A Dio can hand a script a Vista whose writes go through its write queue, and the script can’t tell the difference. Servo and Scenery keep their own words on purpose, because they describe different things: a form draft bound to one record, and the shape of a live view. Layers covers how they relate and which words they share.

There is also a second kind of script: expressions that build a backend’s native query, such as a SQL select() or a SurrealDB condition. They aren’t data scripts and have their own chapters. Expression dialects points to them.

What this part covers

  • Hosts: Host, Vocab, Limits, the DataVocab configuration, the resolver behind table(name), and how async reads run from synchronous scripts.
  • The table handle: narrowing verbs, reads, relations, introspection, and errors.
  • Writes: insert, upsert, patch, delete and import_from, and what each returns when a row is missing or outside the set.
  • Computed columns: lazy: columns that the Vista fills on every read, and what they refuse.
  • Records: drafts of one row that stage edits and flash only what changed.
  • Surfaces: every place the vocabulary runs, with a tested example for each, plus backend extensions such as SurrealDB’s with_condition.
  • Layers: Servo, Scenery and faker sims next to the data vocabulary.
  • Faker sims: scripts that keep a memory store changing over sim time.
  • Expression dialects: query builders, templates and command scripts.
  • Tables defined by a query: a table whose source is a script-built SELECT, and why that is the only place a script builds a query.
  • Query builder functions: every function the SQL and SurrealDB query builders register.

Tested examples

Every script in this part is copied from a test. The comment above a snippet names the test, for example rhai_guide::tables::first_script in vantage-vista/tests/rhai_guide/. Run them with cargo nextest run -p vantage-vista --all-features -E 'binary(rhai_guide)'.

Words at a glance

GroupWords
Starttable(name); self in slot scripts; table() in faker sims
Narrowwhere(col, value), where(col, op, value), sort(col), sort(col, dir), search(text), limit(n), ref(relation)
Readlist(), get(id), first(), count(), ids(), columns(), references(), capabilities() (keyed by every Capability)
Writeinsert(map), upsert(id, map), patch(id, map), delete(id), import_from(source), import_from(source, mapping)
Recordrecord(), record(id); on a record: r.col, r["col"], set(map), id, is_dirty(), dirty(col), baseline(), revert(), revert(col), flash(), delete(), status(), rejection()
Computed columnrow (the record as read); the last expression is the value

where takes the operators = / == / eq, != / ne, > / gt, >= / gte, < / lt, <= / lte, in, not_in and like, plus a few aliases (<>, nin, contains). sort takes "asc" or "desc" (or "ascending" / "descending").

Hosts

A script never runs on a bare Rhai engine. It runs in a Host from vantage-rhai: one engine with fixed limits, a bounded cache of compiled scripts, and the vocabularies registered on it. This chapter is for the Rust side: how an application builds a host for the data vocabulary and what each setting decides.


Host, Vocab, Env

A host is built once and reused. Compiling through it is cached, so evaluating the same script on every row costs one parse:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use ciborium::Value as CborValue;
use vantage_memory::{MemoryCatalog, MemoryStore};
use vantage_rhai::{Block, Env, Host, Limits};
use vantage_vista::rhai::{DataVocab, Terminals, Writes};
use vantage_vista::{Column, ColumnFlag, VistaMetadata, VistaResolver};
fn shop() -> MemoryStore {
    let store = MemoryStore::new();
    for (id, status) in [("o1", "paid"), ("o2", "due"), ("o3", "paid"), ("o4", "due")] {
        let row = [("status".to_string(), CborValue::Text(status.into()))]
            .into_iter()
            .collect();
        store.table("order").insert_as(id, row).unwrap();
    }
    store
}
fn resolver(store: &MemoryStore) -> Arc<dyn VistaResolver> {
    let catalog = MemoryCatalog::new(store.clone());
    catalog.register(
        "order",
        VistaMetadata::new()
            .with_column(Column::new("id", "String").with_flag(ColumnFlag::Id))
            .with_column(Column::new("status", "String"))
            .with_id_column("id"),
    );
    Arc::new(catalog)
}
let store = shop();
let terminals = Terminals::ReadWrite {
    limit: Some(50),
    writes: Writes::Allowed,
};
let host = Host::builder(Limits::background())
    .vocab(DataVocab::new(terminals).with_resolver(resolver(&store)))
    .build();

let script = host
    .compile(&Block::from(
        r#"table("order").where("status", "due").count()"#,
    ))
    .unwrap();
let due = script.eval(&Env::new()).unwrap();
assert_eq!(due.as_int().unwrap(), 2);
}

The three pieces:

  • Vocab: anything that registers functions and types on an engine. DataVocab is the data vocabulary. ServoVocab (diorama) and SceneryVocab (vantage-ui) are others. A host can carry several. When two register the same function under the same name and argument types, the one registered last wins. The backends keep their free functions in their own namespaces (sql::, surreal::, mongo::), so none of them can replace table(name) or another data verb, whatever the order.
  • Limits: Limits::Ui caps a script at 500,000 operations, for anything the UI thread waits on. Limits::background() allows 50 million, for scripts on worker threads: agent tools, imports, faker sims. A script that runs past its limit fails instead of hanging the caller.
  • Env: the variables one evaluation sees. Slot evaluators push row and self this way. A host builds its Env per call, so the compiled script is shared and the variables are not.

DataVocab

#![allow(unused)]
fn main() {
use std::sync::Arc;
use vantage_memory::{MemoryCatalog, MemoryStore};
use vantage_vista::VistaResolver;
use vantage_vista::rhai::{DataVocab, Terminals};
let terminals = Terminals::Describe;
let resolver: Arc<dyn VistaResolver> = Arc::new(MemoryCatalog::new(MemoryStore::new()));
let vocab = DataVocab::new(terminals).with_resolver(resolver);
}

DataVocab::new(terminals) picks which verbs exist on top of the narrowing verbs. with_resolver backs table(name). Without a resolver, table isn’t registered at all, and scripts can only work with handles the host gives them (self, row).

TerminalsVerbsUsed by
Describenone: the script returns a handle and the host reads its descriptionmodify:, reference build scripts, query preview
ReadWrite { limit, writes }reads, writes, and records; under Writes::Denied every write throwsagent scripts, action bodies, form and wizard scripts, faker sims; with writes denied, form options: and action predicates

limit caps every list() and ids(). A script’s own limit(n) still applies below it. None means no cap. count() isn’t capped by the host’s limit: it counts the whole set, or at most n when the script narrowed with limit(n). Terminals::ReadWrite { limit: None, writes: Writes::Allowed } is uncapped with writes allowed.

writes is Writes::Allowed or Writes::Denied(message). A denied host still registers every write verb, but each one throws message. That keeps the error readable: a script that calls delete on a host with writes turned off gets the host’s explanation (“writes are off for agents”) instead of Rhai’s “function not found”. A Describe host, by contrast, has no count() or list(), and calling one is a “function not found” error.

The resolver

The resolver is an Arc<dyn VistaResolver>: anything with fn vista(&self, name) -> Result<Vista>, which includes a closure Fn(&str) -> Result<Vista> and a Catalog. It is called each time a handle resolves, and must return a fresh, unconditioned Vista: the handle applies its own steps on top. A name it doesn’t know is a not-found error. This is the resolver the guide’s tests use, over an in-memory store:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use vantage_memory::{MemoryCatalog, MemoryStore};
use vantage_vista::VistaResolver;
fn catalog(store: &MemoryStore) -> MemoryCatalog {
    MemoryCatalog::new(store.clone())
}
fn resolver(store: &MemoryStore) -> Arc<dyn VistaResolver> {
    let catalog = catalog(store);
    Arc::new(move |name: &str| {
        catalog.store().table(name);
        catalog.vista(name)
    })
}
let store = MemoryStore::new();
assert_eq!(resolver(&store).vista("order").unwrap().name(), "order");
}

catalog is a MemoryCatalog holding each table’s metadata. Creating the store table first means a name the shop doesn’t hold gets a new, empty table instead of a not-found error.

The resolver is where an application puts its policy, because vantage-vista only calls it. Some examples from the codebase:

  • A YAML catalog resolves through its spec resolver, so table("order") in a reference build script is the same Vista the spec declares.
  • Faker sims resolve over the sim’s memory store; the engine’s stats count the store’s writes.
  • Vantage UI’s page resolver accepts a full catalog key or its leaf name, reads from the data source rather than a cache, and wraps every Vista in a write guard. The guard refuses writes on read-only tables, coerces reference columns, gives an id-less insert a generated id, sends writes through the table’s Dio (so they reach open pages), and wraps every ref(...) target in the target table’s own guard.

None of that is visible to the script. It calls insert, and the Vista it resolved decides what an insert means.

Async reads from a synchronous script

Rhai is synchronous. Vista reads and writes are async. Every terminal verb runs its future through one function, vista’s block_on:

  • With a tokio runtime available (Handle::try_current() succeeds), it blocks on the runtime.
  • Without one, it polls the future once on the current thread. Memory Vistas finish on the first poll, so faker sims and tests run with no runtime at all.
  • A future that is still pending with no runtime (any network backend) is an error, “this data source needs a tokio runtime; run the script under spawn_blocking”. It never hangs.

Blocking on a runtime from inside an async task panics in tokio, so a host that has a runtime evaluates scripts on a blocking thread:

use std::sync::Arc;
use ciborium::Value as CborValue;
use vantage_memory::{MemoryCatalog, MemoryStore};
use vantage_rhai::{Block, Env, Host, Limits};
use vantage_vista::VistaResolver;
use vantage_vista::rhai::{DataVocab, Terminals, Writes};
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let store = MemoryStore::new();
for id in ["o1", "o2", "o3"] {
    let row = [("status".to_string(), CborValue::Text("due".into()))]
        .into_iter()
        .collect();
    store.table("order").insert_as(id, row)?;
}
let resolver: Arc<dyn VistaResolver> = Arc::new(MemoryCatalog::new(store));
let rows = tokio::task::spawn_blocking(move || {
    let terminals = Terminals::ReadWrite {
        limit: Some(2),
        writes: Writes::Denied("this host only reads".into()),
    };
    let host = Host::builder(Limits::background())
        .vocab(DataVocab::new(terminals).with_resolver(resolver))
        .build();
    host.compile(&Block::from(r#"table("order").list()"#))
        .and_then(|s| s.eval(&Env::new()))
        .map(|v| vantage_rhai::to_json(&v))
})
.await
.unwrap()
.unwrap();
assert_eq!(rows.as_array().unwrap().len(), 2);
Ok(())
}

run_script does exactly this for you. Action bodies in Vantage UI run under Limits::Ui on a background executor thread that has a runtime handle, which the bridge also accepts.

Errors

Script errors come back as Rhai errors whose text names what failed:

  • a narrowing step the backend can’t apply (an unknown relation, a column that isn’t orderable, a computed column) fails when the handle resolves, at the terminal verb, not where the step was written. The error names the step: Step can't be applied (step: Ref("invoices")): …;
  • a write the Vista’s capabilities don’t cover is an unsupported error naming the capability: “capability is not supported; method refused (capability: delete)”;
  • backend errors keep their message.

Hosts that return JSON, like run_script, flatten all of these into the error string.

The Table Handle

table(name) returns a handle: a table plus an ordered list of narrowing steps. Narrowing verbs add steps. Terminal verbs resolve the handle into a Vista, apply the steps, and read or write.


Narrowing

let paid = table("order").where("status", "paid");
let big = paid.where("total", ">", 100);
let top = paid.sort("total", "desc").first();

#{
    paid: paid.count(),
    big: big.ids(),
    top: top.id,
}

Each narrowing verb returns a new handle and leaves the one it was called on alone. big and the sorted handle both start from paid, and paid.count() still counts every paid order. You can keep a base handle in a variable and branch from it as often as you like.

VerbMeaning
where(col, value)rows whose col equals value
where(col, op, value)op is =/==/eq, !=/ne, >/gt, >=/gte, </lt, <=/lte, in, not_in or like; in and not_in take an array
sort(col), sort(col, "asc" | "desc")order by col; the column must be flagged orderable
search(text)the backend’s quicksearch
limit(n)at most n rows, the first n in sort order; n must be above 0
ref(relation)the related rows of every row in the set

Conditions add up: every where narrows further. Sorting keeps one column. A later sort replaces an earlier one, as Vista::set_order does. Several limit steps keep the smallest.

limit(n) limits the set itself, not just one read: list(), ids() and count() all see at most n rows, a ref after it follows only those n rows, and import_from copies only those n (see Following relations). A limit before a ref doesn’t carry over to the target; limit the target again if you need to. A handle with limit reads; it can’t be written through (Writes).

The column in where and sort must be one the backend stores. A computed column is refused with an error naming it.

Nothing is checked while you narrow. A step the resolved Vista can’t apply (an unknown relation, a column that isn’t orderable, search on a backend without search) fails at the terminal verb:

let invoices = table("client").ref("invoices");   // nothing resolves yet
invoices.count()                                  // throws here

The error names the step, Step can't be applied (step: Ref("invoices")), and its cause, an unknown relation naming invoices. An unknown relation is a not-found error.

Reads

let clients = table("client");
let ada = clients.get("c1");
let ghost = clients.get("c9");

#{
    name: ada.name,
    missing: ghost == (),
    first_two: clients.sort("name").limit(2).list().map(|c| c.name),
}
VerbReturns
list()an array of row maps, capped by the host’s limit and any limit(n)
get(id)the row map, or () when no row has that id
first()the first row map in sort order, or () for an empty set
count()the number of rows, as an integer, at most n after limit(n)
ids()an array of id strings, in sort order (insertion order when unsorted)

Row maps always include the id column, filled from the row’s key when the backend doesn’t return it as a field. Ids are strings. get, and every verb that takes an id, also accepts an integer and uses its decimal form.

A missing row is not an error for reads: get returns (). Compare with () as above, or use Rhai’s ??.

A capped read (the host’s limit or a limit(n)) reads the set’s leading window, fetch_window(0, n), which every backend that lists answers, so it fetches only the rows it returns. A backend that refuses the leading window makes the read throw; it never falls back to listing the whole set.

Following relations

ref(relation) works on sets, not single rows. The result is every row related to any row of the current set:

// The orders of one client...
let ada_orders = table("client").where("id", "c1").ref("orders");
// ...and the clients behind every due order.
let owing = table("order").where("status", "due").ref("client");

#{
    ada: ada_orders.ids(),
    owing: owing.sort("name").list().map(|c| c.name),
}

Relations come from the Vista’s metadata: the YAML references: block, or the typed table’s with_one / with_many. A ref step reads the rows it starts from when the handle resolves:

  • When the set has exactly one row, the step calls Vista::get_ref(relation, row), so a backend’s own traversal applies (SurrealDB record links, scripted references, cross-datasource references).
  • When it has several, the step takes the bare target and adds an in condition: on the foreign key for has-many (the target rows point at ours), on the target’s id for has-one (our rows point at the target).
  • When it has none, or none of the rows has a has-one key, the target is an empty set. Nothing is queried: reads return nothing, count() is 0, and a preview reports "query": null. The target keeps its columns, and an insert into it throws: no row can belong to an empty set.
  • A limit(n) before the ref caps the rows it follows. Without one, a ref over more than 1,000 rows is an error. Narrow first.
let biggest = table("order").sort("total", "desc").limit(2);
let nobody = table("client").where("name", "Nobody");

#{
    count: biggest.count(),
    clients: biggest.ref("client").sort("name").list().map(|c| c.name),
    none: nobody.ref("orders").count(),
}

The two biggest orders are o1 (Ada) and o3 (Ben), so this returns count: 2, clients: ["Ada", "Ben"] and none: 0.

Steps after ref narrow the target, so ref("orders").where("status", "due") is the due orders of the set. A relation keyed on a computed column works for has-one and is refused for has-many.

Introspection

Three reads describe the table instead of its rows:

let orders = table("order");

#{
    insert: orders.capabilities().insert,
    columns: orders.columns().map(|c| c.name),
    references: orders.references().map(|r| r.name + " " + r.kind),
}

This returns insert: true, the columns id, client, total, status, and the references ["client has_one"].

  • capabilities() is a map with one boolean per capability, keyed by its name. The keys come from Capability::iter(), so every capability is listed: insert, update, delete, import, subscribe, order, search, filter_ops, native_conditions, window, cursor, traverse and confine_writes. There is no count key: every Vista can count. A script that may run against different backends can check before it writes.
  • columns() is an array of #{ name, type, flags }. type is () for a column declared without a type.
  • references() is an array of #{ name, kind, contained }. kind is the YAML name: "has_one", "has_many", "contains_one" or "contains_many"; contained marks relations stored inside the row (see Contained Relations).

These resolve the handle, so they describe the table as narrowed. Capabilities rarely change with narrowing, but a query-sourced table reports no write capabilities at all.

Handles in Rust

A host that gets a handle back from a script reads it through Handle: table_name() is the starting table (None for a handle over a Vista), and resolve(resolver) builds the Vista with every step applied. Query preview works this way: the script returns a handle, and the host renders the resolved Vista’s query.

A handle can also start from a Vista instead of a name, which is how self reaches modify: scripts. The handle holds that Vista as its base and clones it on each resolve, so the same handle resolves as often as you like. Verbs on self return new handles, like any other narrowing.

Writes

On a host with Terminals::ReadWrite, a handle also writes. Every write goes through the resolved Vista, so it follows the backend’s rules and whatever policy the host’s resolver wrapped around it.


The write verbs

let orders = table("order");

let id = orders.insert(#{ client: "c2", total: 30, status: "due" });
orders.patch(id, #{ status: "paid" });

let deleted = orders.delete("o4");   // true: the row was there
let again = orders.delete("o4");     // true: the row is gone either way
let patched = orders.patch("o99", #{ status: "paid" });   // false

#{
    status: orders.get(id).status,
    deleted: deleted,
    again: again,
    patched: patched,
}
VerbReturnsWhen the row is missing
insert(map)the id; an id already in the set returns itn/a
upsert(id, map)idinserts it
patch(id, map)truereturns false
delete(id)truereturns true
import_from(source), import_from(source, mapping)#{ inserted, skipped, rejected, cancelled }n/a

patch returns false when the row is missing or outside the handle’s set (the backend reported NotFound). delete returns true whenever the row is gone afterwards, including when it was never there or is outside the set. A write that isn’t allowed (Writes::Denied, a capability the backend lacks) throws, delete included, and so does any other failure. Why: Safe writes.

patch changes only the fields in the map. upsert replaces the whole row, so fields left out of the map are gone afterwards. Computed columns in the map are dropped before the write reaches the backend.

Ids

insert looks at the map’s id column. With a value there, that id is used as given. Without one, insert mints a UUIDv7 before the first attempt, so a retry reuses it, and returns it. When the id column is flagged auto, the backend makes the id instead (insert_return_id, not retry-safe; see Safe writes). A numeric id column without auto is an error: pass an id or flag the column.

let orders = table("order");

orders.insert(#{ id: "o10", client: "c3", total: 55, status: "due" });
orders.upsert("o10", #{ client: "c3", total: 60, status: "due" });   // replaces
orders.upsert("o11", #{ client: "c3", total: 5, status: "due" });    // inserts

table("client").where("id", "c3").ref("orders").ids()

Inserting an id that already exists in the set returns it; an id held by a row outside the set throws. This insert returns "o1" and changes nothing:

table("order").insert(#{ id: "o1", client: "c2", total: 1, status: "due" })

Use upsert when a script owns stable ids and may run more than once.

Writes stay in the set

where, search and ref define the set; a write only touches rows in it, an insert fills the set’s equality conditions (including a ref’s foreign key), and a write that would leave the set throws. sort doesn’t change membership. A handle with limit(n) can’t be written through: drop the limit.

let paid = table("order").where("status", "paid");
let o2 = paid.delete("o2");                         // o2 is due: outside the set
let o9 = paid.patch("o2", #{ total: 1 });           // false: not in the set

let ada = table("client").where("id", "c1");
ada.ref("orders").insert(#{ id: "o20", total: 9, status: "due" });   // client filled

#{
    orders: table("order").ids(),
    o20: table("order").get("o20").client,
    o2: o2,
    o9: o9,
}

returns #{ orders: ["o1", "o2", "o3", "o4", "o20"], o20: "c1", o2: true, o9: false }: o2 is still there.

Capabilities

Each write goes through the Vista method of the same name, and the Vista checks the one capability that names it before the backend sees the call. A missing capability throws an unsupported error naming it, such as capability is not supported; method refused (capability: delete):

VerbNeeds
insertinsert
upsertupdate
patchupdate
deletedelete
import_fromimport, checked before the source is read

A narrowed target imports row by row through inserts, so it also needs insert and a backend that keeps writes inside the set (confine_writes).

A host can also turn writes off for every table with Writes::Denied(message). The verbs are still there, and each throws message, delete included. Vantage UI does this for MCP agents unless the “Allow MCP agents to write data” setting is on.

Importing

import_from(source) copies every row of another handle into this table. source is read through its narrowing, so it can be any set: a filtered table, the target of a ref, a table on another datasource. A limit(n) on the source copies only its first n rows:

let biggest = table("order").sort("total", "desc").limit(2);
let report = table("archive").import_from(biggest);

[report.inserted, table("archive").ids()]

returns [2, ["o1", "o3"]]. Without a mapping, each row keeps its id.

With a mapping, each imported row is built from a source row. String values in the mapping may reference source columns as ${row.<col>}:

let report = table("archive").import_from(
    table("order").where("status", "paid"),
    #{ id: "a-${row.id}", amount: "${row.total}", note: "paid by ${row.client}" }
);

#{ report: report, rows: table("archive").list() }
  • A value that is exactly one reference ("${row.total}") copies the column’s value with its type: amount stays an integer.
  • Any other string interpolates into text ("paid by ${row.client}").
  • Non-string values are literals.
  • The mapping must set the target’s id column. Two source rows mapping to the same id are an error, and so is an id whose table: prefix names a different table.

An id the target already holds is never overwritten; it counts as skipped. Rows that conflict with a narrowed target count as rejected. Into a whole table, the rows go through import_values in one call and the backend decides which ids it already holds. Into a narrowed target, each row is looked up with get first and inserted on its own if it is missing; a row outside the set is rejected and the import carries on; any other insert failure stops the import and throws. Importing the same rows twice into a memory table:

let first = table("backup").import_from(table("client"));
let second = table("backup").import_from(table("client"));

[first.inserted, second.inserted, second.skipped]

returns [3, 0, 3].

cancelled is true when the backend stopped part-way: a host’s import can be cancellable (Vantage UI shows a progress dialog with a cancel button). inserted then counts the rows written before the stop. A backend signals this by returning an error carrying the IMPORT_CANCELLED context key (the rows inserted), and optionally IMPORT_CANCELLED_SKIPPED (the rows skipped).

Writes through a Dio

A host may hand scripts Vistas whose writes go through a Dio: Dio::cached() is a Vista over the Dio’s cache whose inserts, patches and deletes are queued as flashes and written through to the master. To the script it is just a Vista. It is how action bodies in Vantage UI write: the page sees the change at once, and the master write follows.

An insert without an id is queued like any other, with its minted UUIDv7. When the id column is flagged auto, it goes straight to the master instead (there is no id to stage until the master assigns one) and then seeds the cache, so an immediate get(id) finds the row.

Computed Columns

A computed column is a column whose value no backend stores. It is a short Rhai expression over row, the record as the backend returned it, and its last expression is the column’s value. The expression lives in the Vista’s metadata, and the Vista fills the column on every read, so it works the same over SQLite, SurrealDB, a REST API, a CSV file or a memory store.

In YAML it is the lazy: key on a column. This chapter covers what a computed column does, how to declare one, what it refuses, and how relations treat it.


An example

The examples run over two memory tables built from YAML. An order stores an invoice code and a net amount. Its client key is buried in the code, and its tax and gross amounts are derived:

name: order
columns:
  id: { type: string, flags: [id] }
  code: { type: string }
  net: { type: int }
  client:
    type: string
    references: client
    lazy: |
      let parts = row.code.split("-");   // "INV-c1-0001"
      parts[1]
  vat: { type: int, lazy: "row.net / 5" }
  gross: { type: int, lazy: "row.net + row.vat" }
name: client
columns:
  id: { type: string, flags: [id] }
  name: { type: string }
  vip: { type: bool }
  badge:
    type: string
    lazy: |
      if row.vip { "★ " + row.name } else { row.name }
references:
  orders: { table: order, kind: has_many, foreign_key: client }

A script reads computed columns like any other:

let o1 = table("order").get("o1");

#{
    o1: [o1.client, o1.vat, o1.gross],
    badges: table("client").list().map(|c| c.badge),
    computed: table("order").columns()
        .filter(|c| c.flags.contains("calculated"))
        .map(|c| c.name),
}

For order INV-c1-0001 with net: 100 this returns o1: ["c1", 20, 120], the badges ["★ Ada", "Ben"], and computed: ["client", "vat", "gross"].

How the value is computed

  • Columns compute in declaration order, and each sees the values of those declared before it. gross reads row.vat, which vat has just added.
  • row is the record as the backend returned it. Nested records keep their shape (row.Message.Headers.Subject), and a missing field is (). The id column may not be in the record: memory and several other backends keep the id as the row’s key, not as a field.
  • The script is a block: let statements and helper expressions are fine, and the last expression is the value.
  • It compiles once, when the column is declared. A script that doesn’t parse fails the table build, not the first read. A script that throws on a row fails that read with “Computed column failed”, naming the column.
  • It runs under the background operation limit (50 million), so a runaway loop errors instead of hanging the read.
  • There is no table(name) in scope. A computed column sees one row. To bring in data from another table, use augmentation.

Every read path fills computed columns: list_values, get_value, fetch_window, fetch_cursor, and watched changes. Anything built on a Vista sees them without knowing they exist: data scripts, Dio caches, grids.

Declaring one

In YAML, lazy: goes on the column, next to type: and flags:. Every driver’s factory lowers it through ColumnSpec::lazy_column. The script can be inline, as above, or in a file. A loader that supports !include, as Vantage UI’s does, replaces lazy: !include ../script/client_key.rhai with the file’s text before the spec is parsed, so vantage-vista only ever sees the script.

In Rust, Column::with_expression turns a column into a computed one:

#![allow(unused)]
fn main() {
use vantage_vista::{Column, ColumnFlag};
let gross = Column::new("gross", "int")
    .with_flag(ColumnFlag::Orderable)
    .with_expression("row.net + row.vat")
    .unwrap();

assert!(gross.is_computed());
assert_eq!(gross.expression(), Some("row.net + row.vat"));
assert!(gross.has_flag(ColumnFlag::Calculated));
assert!(!gross.has_flag(ColumnFlag::Orderable));
}

with_expression needs the rhai feature and returns an error, naming the column, when the script doesn’t compile. It flags the column calculated and removes orderable and searchable. A shell then reports the column in its VistaMetadata like any other. Typed tables take spec columns through Table::add_spec_column, which registers a lazy: column as computed. That is how the SQL, SurrealDB, MongoDB, CSV, REST and GraphQL factories fold lazy: into a Vista.

The expression isn’t serialized. A Column that goes through serde comes back as a stored column that keeps only its calculated flag.

A Vista you build by hand takes a computed column the same way: put the Column::with_expression column in the VistaMetadata the shell reports, and every read fills it. A lines column over a note’s contents:

row.contents.split("\n").len() - 1

What they refuse

The backend holds no values for a computed column, so anything that would ask the backend about it is refused.

Writes drop them. Inserts, patches, upserts and imports remove computed columns from the record before it reaches the backend:

let orders = table("order");
orders.insert(#{ id: "o4", code: "INV-c2-0004", net: 50, gross: 1 });
orders.patch("o1", #{ net: 200, vat: 0 });

[orders.get("o4").gross, orders.get("o1").vat]

This returns [60, 40]: the gross: 1 and vat: 0 were dropped, and the read computed both from the new net. Vantage UI keeps computed columns out of its forms for the same reason.

Filters, orders and aggregates error, naming the column:

table("order").where("gross", ">", 100).count()   // throws
table("order").sort("gross", "desc").list()   // throws

The first fails with Step can't be applied (step: Where { col: "gross", … }): Computed column can't be used to filter, order or aggregate; the backend doesn't hold its values (column: "gross", operation: "condition"). The error is marked unsupported, like any other narrowing a backend can’t do. In Rust, Vista::add_condition and set_order refuse the same way.

To filter on a derived value, filter on the stored columns it comes from (net rather than gross), or compute it in the backend’s query: a query-sourced table or SurrealDB’s expr: column (see Other ways to compute a column).

Relations through a computed column

A has-one relation whose foreign key is a computed column of the parent works. The parent row already carries the computed value, and the target is narrowed by its own id, which it stores. order.client above is such a key. A has-many relation whose foreign key is a computed column of the child can’t work: the child table would have to be filtered by a value it doesn’t store.

let big = table("order").where("net", ">", 50);

#{
    one: table("order").where("id", "o3").ref("client").first().name,
    many: big.ref("client").sort("name").list().map(|c| c.name),
}

Both forms of ref work for has-one: from one row ("Ben"), and from several (["Ada", "Ben"]), where the parents’ computed keys become an in condition on the client id. The reverse direction throws:

table("client").where("id", "c1").ref("orders").ids()   // throws

with can't narrow the relation's target to the parent row, naming the relation orders and the column client, caused by the computed-column refusal above. Vista::get_ref narrows through the same Reference::narrow, so it refuses the same way. A nested insert that would have to write a computed foreign key (into the parent for has-one, into each child for has-many) is refused before anything is written.

Other ways to compute a column

lazy: is one of several ways to derive a column in vantage. They differ in where the value is made, and that decides what the backend can do with it:

WhereHowRunsCan filter/sortLink
SQL / SurrealDB expressionTable::with_expression(name, …), SurrealDB YAML expr:in the queryyesTable::with_expression
Rust after the readTable::with_lazy_expression(name, …)after each read, in RustnoTable::with_lazy_expression
Rust on writea BeforeSave hook that stores the valueon insert and updateyes (it’s stored)Hook
Rhai lazy:this chapterafter each read, in the Vistano—
Dio sugar:Sugaron the rows one Scenery readsno—
  • with_expression takes a closure that returns an expression in the backend’s own language, rendered into the SELECT (the intro’s computed fields use a correlated subquery). In Rust, get_column_expr(name) returns that expression, so a condition or an order can use it. SurrealDB’s expr: column is the YAML form (see Expression dialects).
  • with_lazy_expression runs an async Rust closure on each record a list or get returns. It can do I/O, which a lazy: script can’t: the intro’s Augmentation step uses it to download a file and derive columns from it. It never takes part in a query. The Vista reports its column as calculated.
  • A BeforeSave hook in the Populate phase sets a field before every insert and update (see Lifecycle hooks). The value is then stored, so the backend can filter, sort and aggregate it, but it changes only when the row is written through that table.

Pick the backend when the column must filter or sort; Rust when the logic needs the type system; lazy: when it must travel with YAML.

lazy: and sugar:

An application can also add derived columns per view with diorama’s Sugar. Vantage UI, for example, exposes it as sugar: on a grid, binder or other component. The two look similar and are used for different things.

lazy: (computed column)sugar: (Vantage UI)
Declared onthe tableone component on one page
Seen byevery reader of the Vista: pages, relations, data scripts, Dio cachesthat component only
Runsinside the Vista, on each readon its own thread, over the rows the component reads
Script shapeone expression over rowa loop over next_row() that calls emit(#{ … }), with helper functions and setup before the first row
Relationshas-one can follow itnone

Use lazy: for a value that is part of the data, such as a key, an amount or a status that relations and scripts need. Use sugar: for presentation on one page, or when the script needs setup (a lookup map, helpers) before the first row. Sugar is built on diorama’s Sugar, which merges the outputs into the rows one Scenery reads and strips them from anything written back. Its YAML is documented in Vantage UI’s skills.

Records

A record is a draft of one row. It reads like a map, stages edits, and flashes only what changed. Use it when a script edits a row field by field, or when a host hands a script “the current row” (Vantage UI’s action bodies receive row as a record).


Editing an existing row

record(id) loads the row through the handle and throws if no row has that id:

let o = table("order").record("o2");
o.status = "paid";
o["total"] = 45;

let was = o.baseline().status;
let staged = [o.dirty("status"), o.dirty("client")];
o.flash();   // patches `status` and `total`, nothing else

#{
    was: was,
    staged: staged,
    dirty: o.is_dirty(),
    stored: table("order").get("o2"),
}

A record keeps two layers:

  • the baseline: the row as last loaded or flashed (baseline() returns it as a map);
  • the changes: fields set since then.

Reading a field (o.status, o["status"]) returns the change if there is one, else the baseline value, else (). Setting a field stages a change. flash() sends a patch with the changed fields only, then folds them into the baseline. A record with no changes writes nothing and returns its id.

A new row

record() with no id starts a draft of a row that doesn’t exist yet:

let n = table("client").record();
n.set(#{ name: "Dee", vip: false });
let id = n.flash();   // an insert under a new UUIDv7 id

#{
    same_id: n.id == id,
    status: n.status(),
    stored: table("client").get(id).name,
}

The first flash() of a new record is an insert. After it, the record has an id and later flashes are patches. set(map) stages several fields at once and returns the record, so table("t").record().set(#{ … }).flash() works in one line. A key with dots ("inventory.stock") writes into a nested map, which is how a form field bound to an embedded object reaches the right place.

The id column can’t be set on a record. n.id is read-only, and setting the id column through set or an index throws:

let n = table("client").record();
n.set(#{ id: "c9", name: "Eve" })   // throws: `id` is the id column

To insert under an id you choose, use insert(#{ id: …, … }) or upsert(id, map) on the handle.

The record finds out which column is the id column on the first field set, by resolving its table, and keeps the answer, so later sets don’t resolve the table again. A table with no id column falls back to id.

Dirty state and revert

let c = table("client").record("c2");
c.name = "Benjamin";
c.vip = true;

c.revert("vip");
let still_dirty = c.is_dirty();   // `name` is still staged
c.revert();

[still_dirty, c.is_dirty(), c.name]
VerbMeaning
is_dirty()any field staged
dirty(col)col staged
revert(col)drop the staged change to col
revert()drop every staged change
baseline()the last flashed row, as a map (empty for a new record)

Status and failures

status() is "tracking" normally, "pending" while flash() or delete() runs, and "failed" when the last one failed. A failed flash throws, and the record keeps its staged changes, so the script can fix them and try again. rejection() then returns #{ message }, and returns () while the record isn’t failed.

let o = table("order").record("o3");
table("order").delete("o3");   // the row goes away underneath the draft

o.status = "refunded";
let threw = false;
try { o.flash(); } catch (err) { threw = true; }

#{
    threw: threw,
    status: o.status(),
    message: o.rejection().message,
    staged: o.dirty("status"),
}

Flashing a record whose row has gone fails with “record o3 no longer exists”. Servo drafts, which carry per-field validation errors from a Dio’s flash route, also report a fields map in their rejection (see Layers).

A new record mints its UUIDv7 on the first flash() and keeps it, so flashing again after a failure can’t create a second row (see Safe writes). A table whose id column is flagged auto leaves the id to the backend instead.

Deleting

delete() deletes the record’s row and returns true, also when the row was already gone. A delete that isn’t allowed throws, and the record is marked failed with the error as its rejection. A new record that was never flashed has nothing to delete and throws “record has no id; it was never flashed”.

Permissions

Records write through the same checks as the write verbs: the target’s capabilities (insert for a new record, update for an existing one, delete for delete()), and the host’s Writes setting. Under Writes::Denied(message), record(id) still loads and reads, and flash() or delete() throws message. Vantage UI uses this for action predicates (when:), which see row but must not change it.

Records in Rust

A host that already holds a row can give it to a script without a second fetch: RecordDraft::from_row(handle, writes, id, row). A named handle resolves through the resolver it carries, set with Handle::with_resolver. After the script runs, changes() returns what it staged and values() the row as the script last saw it. Vantage UI builds the action body’s row this way, over the Vista that Dio::cached() returns for the page’s table, so the flash goes through the Dio’s write queue.

A row that belongs to no table, such as the row an action predicate reads, still needs a handle with a resolver: the first field set asks it for the id column. A Vista over an empty MockShell (feature mocks) serves:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use ciborium::Value as CborValue;
use vantage_vista::mocks::MockShell;
use vantage_vista::rhai::{Handle, RecordDraft, Writes};
use vantage_vista::{Vista, VistaResolver};
let values = [
    ("status".to_string(), CborValue::Text("paid".into())),
    ("total".to_string(), CborValue::Integer(120.into())),
]
.into_iter()
.collect();
let no_table: Arc<dyn VistaResolver> =
    Arc::new(|_: &str| Ok(Vista::new("row", MockShell::new())));
let row = RecordDraft::from_row(
    Handle::named("row").with_resolver(no_table),
    Writes::Denied("this row is read-only".into()),
    "o1".into(),
    values,
);
assert!(row.changes().is_empty());
}

A script given this row can read it and stage fields, and every flash throws the denial:

row.status = "void";   // staged on the draft
let flashed = true;
try { row.flash(); } catch (err) { flashed = false; }

#{ status: row.status, was: row.baseline().status, flashed: flashed }

returns status: "void", was: "paid" and flashed: false. Setting row["id"] still throws, because the empty table has no id column and the draft falls back to id.

Surfaces

The data vocabulary is the same everywhere. What changes from one place to the next is the host: which tables table(name) reaches, which terminal verbs exist, and what the script is given in scope. This chapter goes through each surface with a tested example.


Overview

Rows marked “(app)” are hosts an application builds for itself; the values shown are Vantage UI’s. The rest come with vantage.

Surfacetable(name) resolvesTerminalsLimitsIn scope
run_script (agent tools)the caller’s resolverReadWrite, list() capped at 1 to 50 rowsBackground
preview_scriptthe caller’s resolverDescribeBackground
YAML modify:the backend’s spec resolverDescribeBackgroundself
reference build scriptthe backend’s spec resolverDescribeBackgroundrow
lazy: computed columnnonenone: no data vocabularyBackgroundrow
form options: (app)the app’s tablesReadWrite, writes denied, cappedform values
action predicates, when: (app)noneReadWrite, writes deniedUirow
action bodies (app)the app’s tables, writes through DioReadWriteUirow, actions
form on_submit, wizard forms (app)the app’s tables, writes through DioReadWriteBackgroundform (a Servo), wizard
wizard worker: (app)the app’s tablesReadWrite, 50 rowsBackgroundwizard, state
faker simsthe sim’s memory storeReadWritethe sim’s own budgetargs

Agent scripts: run_script

run_script evaluates a script with every read and write verb and returns its last value as JSON. It runs under spawn_blocking for you:

let due = table("order").where("status", "due");
#{ count: due.count(), rows: due.sort("total", "desc").list() }
use std::sync::Arc;
use ciborium::Value as CborValue;
use vantage_memory::{MemoryCatalog, MemoryStore};
use vantage_vista::rhai::{Writes, run_script};
use vantage_vista::{Column, ColumnFlag, VistaMetadata, VistaResolver};
const AGENT_SCRIPT: &str = r#"
let due = table("order").where("status", "due");
#{ count: due.count(), rows: due.sort("total", "desc").list() }
"#;
fn resolver(store: &MemoryStore) -> Arc<dyn VistaResolver> {
    let catalog = MemoryCatalog::new(store.clone());
    catalog.register(
        "order",
        VistaMetadata::new()
            .with_column(Column::new("id", "String").with_flag(ColumnFlag::Id))
            .with_column(Column::new("total", "int").with_flag(ColumnFlag::Orderable))
            .with_column(Column::new("status", "String"))
            .with_id_column("id"),
    );
    Arc::new(catalog)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let store = MemoryStore::new();
for (id, total, status) in [("o1", 120, "paid"), ("o2", 40, "due"), ("o4", 15, "due")] {
    let row = [
        ("total".to_string(), CborValue::Integer(total.into())),
        ("status".to_string(), CborValue::Text(status.into())),
    ]
    .into_iter()
    .collect();
    store.table("order").insert_as(id, row)?;
}
let out = run_script(
    AGENT_SCRIPT.to_string(),
    resolver(&store),
    1,
    Writes::Denied("writing data is turned off".into()),
)
.await
.unwrap();
assert_eq!(out["count"], 2);
assert_eq!(out["rows"].as_array().unwrap().len(), 1);
Ok(())
}

The limit is clamped to MIN_LIMIT..=MAX_LIMIT (1 to 50; DEFAULT_LIMIT is 5) and caps every list(), so rows above holds one row while count is 2. This is an inspection surface, not a bulk reader. Compile, runtime and backend errors all come back as the error string.

Vantage UI’s MCP run_data_script tool is this function. Its writes stay Denied unless the user turns on “Allow MCP agents to write data”, a setting separate from the one that lets agents read.

Query preview: preview_script

preview_script runs a script on a Describe host, so it can build a handle but never read rows. The script must end on the handle. The resolved Vista renders its query through Vista::preview:

table("order").where("status", "paid").sort("total", "desc")

The result is JSON with the Vista’s driver and the query it would run, plus a note naming anything the query leaves out. Here the memory store reports driver: "memory" and query: "scan order WHERE status = \"paid\" ORDER BY total DESC". One exception to “never reads”: a ref step still reads the rows it starts from, because the target’s condition depends on them. When it finds none, the preview reports "query": null with a note, because nothing would be queried.

modify: scripts

A YAML spec’s modify: script runs on the finished Vista, exposed as self. A Describe host evaluates it with eval_modify_script:

self.where("vip", true).sort("name", "desc")

The script’s value is its last statement, and it must be a table handle: that handle is resolved and becomes the table. A script that ends on anything else is an error, “The script must end on a table handle”. modify: narrows a real table, so the Vista stays writable. Reference build scripts finish the same way.

Backend extensions

A backend can add its own verbs to handles through TableShell::register_rhai_extensions, which registers its functions and constants in one call. SurrealDB adds its whole expression vocabulary, plus with_condition(expr), which narrows by a native SurrealQL expression that where can’t spell:

self.with_condition(surreal::ident("is_paying_client") == true)
   .sort("name", "asc")

and surreal::me, the current-record anchor for graph paths. MySQL adds the SQL expression vocabulary and the same with_condition(expr); MongoDB’s with_condition takes a filter written as a Rhai map, self.with_condition(#{price: #{"$gt": 100}}), with mongo::oid, mongo::date and mongo::doc for the BSON values a map can’t spell.

A backend’s free functions and constants live in its own namespace: surreal:: for SurrealDB, sql:: for every SQL database, mongo:: for MongoDB. So table(name) is always the data verb, and a backend function never shadows one. The verbs on handles, with_condition included, and the methods and operators on a backend’s own types (.from, .alias, ["col"], ==) take no prefix.

with_condition narrows a Vista in hand, not a list of steps, so it works on self only. On a handle from table(name), or after a ref step, it throws an error naming the verb. A backend implements it with Handle::with_native_condition, which hands a NativeCondition to the shell’s add_native_condition. This stand-in passes an equality as the native condition:

#![allow(unused)]
fn main() {
use vantage_rhai::rhai::{Dynamic, EvalAltResult};
use vantage_vista::rhai::{Handle, dynamic_to_cbor};
use vantage_vista::{Condition, FilterOp, NativeCondition};
fn with_condition(h: &mut Handle, col: &str, value: Dynamic) -> Result<Handle, Box<EvalAltResult>> {
    let key = format!("{col} = {value}");
    let condition = Condition::new(col, FilterOp::Eq, dynamic_to_cbor(value)?);
    h.with_native_condition("with_condition", NativeCondition::new(condition, key))
        .map_err(|e| e.to_string().into())
}
}

with_native_condition narrows a copy of the Vista and returns a new handle over it, so like every other verb it leaves the handle it was called on alone:

let all = self;
let vip = all.with_condition("vip", true);
all

still ends on every client. Verbs on self return new handles, so narrowing is a chain, whichever verbs it uses:

self.with_condition("vip", true).with_condition("name", "Cy")

leaves only Cy, and

self.with_condition("vip", true).sort("name", "desc")

leaves Cy and Ada, in that order. Chain the calls, or assign a step with let and continue from the variable. A statement that ends on a narrowing verb (where, sort, search, limit, ref, with_condition) outside the script’s last statement drops its handle, whatever the chain starts from, and is refused when the script compiles, with “this statement’s table handle is discarded; chain the calls or assign it”:

self.with_condition("vip", true);    // throws: this handle would be dropped
self.sort("name", "desc");
let all = self;
all.with_condition("vip", true);    // throws: this handle would be dropped
all

A last statement that isn’t a handle is refused too. Watch an if with no else: when its condition is false its value is (), so end it with else { self }:

let vip_only = false;
if vip_only {
    self.with_condition("vip", true)
}   // throws: a false `if` with no `else { self }` is ()

Backends without a scripting vocabulary (CSV, REST, SQLite and PostgreSQL Vistas) keep the default no-op and get the data vocabulary alone.

Reference build scripts

A reference whose join isn’t a plain foreign key can carry a rhai: build script. It runs at traversal time with the parent row as row, and must end on a handle:

table("order").where("client", row.id).where("status", "due")

In YAML:

references:
  due_orders:
    table: order
    kind: has_many
    foreign_key: client
    surreal:
      rhai: |
        table("order").where("client", row.id).where("status", "due")

eval_ref_script evaluates it.

Computed columns

A lazy: column script runs on the shared background host, with no data vocabulary at all: no table(name), no handles, only row. It computes one value from one row, every time the Vista reads that row. Computed columns covers it.

Read-only hosts

A read-only host is a Terminals::ReadWrite host with writes: Writes::Denied(message). It reads freely, and every write throws the message. Records still load, so a script can read row.status, but flashing throws:

let o = table("order").record("o1");
o.status = "void";
o.flash()   // throws: this host only reads

Table write verbs such as delete throw the same message. Vantage UI uses read-only hosts for form options: scripts, and for action predicates, which get row but no table(name).

Read-write hosts in an application

An application decides what its action scripts see; Vantage UI, for example, gives action bodies and form on_submit scripts table(name) over the app’s tables and, for row actions, row as a record:

let r = actions.cancel_order(row);
row.status = "cancelled";
row.cancellation_reason = r.reason;
row.flash();

The resolver behind table(name) sends writes through each table’s Dio, so open pages see them at once, and applies the app’s write rules (read-only tables, reference coercion, generated ids). Form and wizard scripts also see Servo drafts, which have their own vocabulary (Layers). The action and form vocabularies themselves (actions.*, notify, form) are documented in Vantage UI’s skills.

Faker sims

Sims are covered in Faker Sims: they use this vocabulary over a memory store, with table() naming the sim’s own table, plus time and random verbs.

Layers

The data vocabulary acts on Vistas and nothing else. Three parts of the framework sit above Vista and give scripts words of their own: Servo drafts in vantage-diorama, Scenery shapes in Vantage UI, and faker sims. This chapter shows how each one relates to the data vocabulary, and why they stay separate.


The rule

vantage-vista never refers to Dio, Servo or Scenery. Each upper layer either hands scripts a plain Vista or registers its own vocabulary next to DataVocab on the same host. A host for a form’s on_submit script, for example, registers both ServoVocab and DataVocab, so the script has form (a Servo) and table(name) (Vistas) side by side.

Where an upper layer means the same thing as a data verb, it uses the same word: where, sort, limit, ref, flash, is_dirty. Where it means something different, it uses its own word, even if a data verb looks close.

Dio: no vocabulary of its own

A Dio doesn’t add script words. Dio::cached() returns a Vista whose reads come from the Dio’s cache and whose writes are queued as flashes and written through to the master. A host that resolves table(name) to Dio Vistas gives scripts cached reads and optimistic writes, and the scripts are the same ones that would run against the bare backend. Vantage UI’s action bodies work this way.

Servo: a form draft

A Servo is a draft of one record bound to a Dio. It tracks a baseline, staged edits, a status and per-field rejections from the Dio’s flash route. Forms and wizards in Vantage UI hand one to their scripts as form. ServoVocab (vantage_diorama::rhai) registers its words:

servo.set("id", "tag:AB12-CD34");
servo.set("status", "unregistered");
if !servo.is_dirty() { throw "draft should be dirty"; }
if !servo.dirty("status") { throw "field should be dirty"; }
servo.flash()

A Servo and a data-vocabulary record look alike, and some words match:

record (table(t).record(id))Servo
read a fieldr.col, r["col"]servo.get("col")
stage a fieldr.col = v, r.set(map)servo.set("col", v)
idr.idservo.id()
id of a new rowa UUIDv7 minted on the first flashIdStrategy: Uuid (the default) mints one when the Servo opens
the whole draft(field reads)servo.record()
baseliner.baseline()servo.baseline()
dirtyr.is_dirty(), r.dirty(col)servo.is_dirty(), servo.dirty(col)
revertr.revert(col), r.revert()servo.revert(col), servo.revert_all()
fields that differ from the baselineservo.error()
writer.flash(): a patch or insert, nowservo.flash(): a flash through the Dio, waits for it to settle
statusr.status(), r.rejection() (#{ message })servo.status(), servo.rejection() (#{ message, fields }, per-field errors)

They stay separate because they are different objects. A record is a script’s own short-lived draft over any Vista, and is gone when the script ends. A Servo lives in the Dio and outlasts the script. The form’s widgets are bound to it, its flash goes through the Dio’s write queue and route, and a route can reject single fields. Folding the two into one type would make the record depend on the Dio, or hide the Servo’s lifecycle behind data-vocabulary words.

servo.flash() needs a tokio runtime context and blocks on the flash, so Servo scripts run under spawn_blocking, like any data script with a network backend.

Which one to use

Use a record for a script’s own one-off edit over any Vista; it writes when the script says so and is gone after. Use a Servo when a person edits: it lives in the Dio, outlasts the script, takes per-field rejections, and its flash goes through the flash route.

Unless the id is left to the backend (an auto id column, IdStrategy::Auto), both reuse their id when a flash is retried, so neither can create a row twice (see Safe writes).

Scenery: the shape of a view

A Scenery is a live, reactive view over a Dio: a TableScenery keeps an ordered window of rows current as the Dio’s cache changes, a ValueScenery keeps one aggregate current, and a RecordScenery one row (see Scenery — Reactive Views). An application binds a widget to one.

vantage-diorama has no Scenery script words. Vantage UI adds some: a component’s table: binding can be a plain table key or a short script, scenery(name) followed by narrowing words, that describes which Scenery to open. The script builds a description, and the page opens the Scenery when it mounts:

scenery("top").sort("visitors", "desc").limit(10)

A child view through a relation, for the row selected in a grid:

scenery("launches").where("id", "42").ref("payloads")

In a page this reads launches_grid.selected_id instead of "42", and the page remounts the binding when the selection changes.

Shared words, with the same meaning as on a table handle:

  • where(col, value): equality only. Values carry as text, so a page number needs no to_string().
  • sort(col, dir): the direction is required, "asc" or "desc".
  • limit(n): at most n rows.
  • ref(relation): the related rows. On a Scenery it must follow exactly one where on the parent’s id and nothing else, because the binding follows one parent row.

Scenery-only words:

  • tail(n): follow the last n rows as they arrive, for append-only feeds.
  • arg(name, value) and args(#{ … }): parameters passed to the table’s own rhai: query script as its args map, which reshape the query itself rather than filtering its result (see Arguments).

Scenery has no reads or writes: it describes a view, and the page’s Dio opens it. The vocabulary lives in Vantage UI (SceneryVocab in crates/app/src/infra/scenery_script.rs), runs under Limits::Ui, and records every page value the script reads as a dependency of the binding.

Faker sims

Sims use the data vocabulary over a memory store plus time and random words. See Faker Sims.

Faker Sims

vantage-faker fills memory tables with generated rows. With its sim feature it also runs Rhai scripts as simulations over the same memory store: each sim is a script with its own clock, and a SimDef spawns copies of it (a burst at start, then a rate per sim minute, up to a maximum alive). Sims write through the data vocabulary from this part of the book, so a Dio, a grid or an API watching the store sees the rows arrive, change and leave.


A sim script

A faker sim is a Rhai script that runs from top to bottom on its own thread, with its own sim clock. Its local variables are its state, and sleep pauses it. The data vocabulary is there in full, over the engine’s memory store, and table() with no name is the sim’s own table:

if table().where("status", "Placed").count() >= 40 {
    done();
}

let id = table().insert(#{ customer: fake("name"), total: rand_float(5.0, 80.0), status: "Placed" });
sleep(minutes(rand_int(5, 20)));

table().patch(id, #{ status: "Shipped" });
table("order_event").insert(#{ order: id, note: "shipped" });
sleep(hours(2));

table().delete(id);

Sim words

On top of the data vocabulary, sims get:

  • data: fake_row() on a handle (table().fake_row(), table("t").fake_row()) returns a map with a generated value for each column the table declares through SimEngineBuilder::columns, without the id column. Sequential generators (walk, evenly spread date) advance one step per call, shared by every sim of the def.
  • time: seconds(n), minutes(n), hours(n), days(n), sleep(d), wait_until(t), now(), now_secs(), wall_now(), wall_in(d), elapsed(), clock(), done().
  • random: pick, pick_weighted, rand_int, rand_float, chance(p), pattern("BA####"), sentence(min, max), fake(kind), date_between(from, to).
  • spawn: spawn_sim(name, args?), sim_id(), sim_name(), and the spawner’s args.
  • geo: great_circle, bearing, interpolate.

The store underneath

A sim’s tables are memory Vistas, so reads and writes finish on the first poll and sims run without tokio (see the bridge). Writes broadcast the store’s change events to any watching Dio. During a warm start, each written table stays quiet and sends one Reset at the end. A missing table is a script error: the store’s tables are created by the dataset, not by the sim.

The vantage-faker README covers defs, spawners, clocks, the operations budget and the built-in sims.

Expression Dialects

Not every script in a Vantage configuration is a data script. Some build a backend’s native query, some compute one value from one row, and some are text with a few holes in it. They run on the same vantage-rhai hosts with the same limits, but their vocabularies are separate from the data vocabulary, and from each other. This chapter tells them apart and points to where each is documented.


Data scripts and expression scripts

A data script works on sets of rows through Vistas: table, where, list, insert. It doesn’t know which backend it talks to.

An expression script builds a query in one backend’s own terms: a SQL SELECT, a SurrealQL condition, a graph path. It runs once, when a Vista is built, and its result becomes part of that Vista’s query. It reads no rows itself.

The two meet in one place: a backend’s extension verbs on a data handle, such as SurrealDB’s self.with_condition(expr), take an expression built with the backend’s vocabulary (see Backend extensions).

SQL query builders

A YAML spec for a SQL table can replace its source with a script-built SELECT. The script uses vantage-sql’s builder (select, from, field, where, group_by, expr) and the shared primitives (count, avg, coalesce, case_when, date_format, …). The free functions among them live in the sql:: namespace, one for every SQL database; the methods don’t take it:

name: expensive_products
columns:
  id:
    type: string
    flags: [id]
  name:
    type: string
sqlite:
  rhai: |
    sql::select().from("product").field("id").field("name").where(sql::expr("price > 15"))

A query-sourced Vista is read-only. A derived Vista (base: plus rhai:) gets the base’s select() as base and transforms it. Tables Defined by a Query covers both, and Query Builder Functions lists every function the builders register. The Rust primitives are in SQL Primitives.

where here is the SQL builder’s method and takes an expression. On a data handle, where takes a column and a value. The name is the same because both narrow. A modify: script has both vocabularies on one host, and each where dispatches on its receiver: a select or a table handle. The builder’s free functions are namespaced so that none of them can shadow a data verb such as table(name).

SurrealDB expressions

vantage-surrealdb registers the same builder shape for SurrealQL, the same primitive names where the concept exists in both, and SurrealDB’s own terms where it doesn’t (graph paths, SPLIT, me), in the surreal:: namespace: surreal::graph(surreal::me, "placed", "order"). It backs rhai: sources, base: transforms, and the expressions handed to with_condition. See SurrealDB Primitives.

The SQL and SurrealDB builders are two copies of a similar grammar. Merging them is a known follow-up and not part of the data vocabulary.

A SurrealDB column spec may also carry expr:, a server-side computed column: the script is evaluated once with this vocabulary, and the backend projects the result as (<expr>) AS <column> on every read. Unlike a lazy: column it is part of the query, so it can follow record links and call SurrealDB functions. Other backends reject or ignore it.

lazy: columns

A column’s lazy: script is a third kind: it computes one value from one row, in the Vista, after the backend has returned the row. It uses neither the data vocabulary nor a query builder, only row. Computed columns covers it.

Templates

Many YAML strings are templates: text with ${ … } holes. vantage-rhai’s Template slot evaluates each hole as a Rhai expression against the host’s scope and joins the results. A template that is exactly one hole (surrounded by whitespace at most) keeps the hole’s value with its type, so "${ row.total }" stays a number and a map stays a map.

Two look-alikes are not Rhai:

  • import_from’s mapping (see Writes) only understands ${row.<col>} column references. Other holes stay in the text as written.
  • Vantage UI’s action texts (title:, description:, terminal.args) substitute ${row.x} and ${form.x} paths, with no expressions.

Command scripts

vantage-cmd tables read by running a Rhai script that calls run(...) for a subprocess and parse_json / parse_jsonl on its output, then returns an array of row maps. The script sees the read’s conditions, columns, limit and offset as variables. It is a third dialect, for shaping command output into rows, and is documented in the vantage-cmd crate.

Tables Defined by a Query

A table usually reads one physical table or collection. A YAML spec for SQLite, PostgreSQL, MySQL or SurrealDB can instead take its rows from a SELECT that a Rhai script builds. The script uses the backend’s query builder (Query Builder Functions), not the data vocabulary, and it runs when the table is built, never when a data script runs.


A query as the source

rhai: in the driver block (sqlite:, postgres:, mysql: or surreal:) replaces table:. The script’s last expression must be a select(), built with the backend’s namespaced functions (sql:: for the SQL databases, surreal:: for SurrealDB):

name: expensive_products
columns:
  id:
    type: string
    flags: [id]
  name:
    type: string
sqlite:
  rhai: |
    sql::select().from("product").field("id").field("name").where(sql::expr("price > 15"))
  • The script runs once, when the Vista is built. A script that fails or doesn’t end on a select() fails the build.
  • The SELECT becomes a subquery in the table’s FROM, aliased under the spec’s name: SELECT "id", "name" FROM (SELECT … FROM "product" WHERE price > 15) AS "expensive_products".
  • columns: declares which of the query’s output columns the table has, as for any table.
  • Reads, conditions, sort, search and paging apply on top, as on any table. A data script can narrow table("expensive_products") with where and sort like any other table.
  • Scalars the script passes to the builder (sql::expr("price > {}", [15]), a comparison with a number) are bound as parameters.

Deriving from another table

base: names another spec. It is resolved when the table is built, its select() is put in the script’s scope as base, and inherit: copies the columns and relations to keep. The script transforms the base query instead of building one:

name: client_totals
id_column: client_id
columns:
  total_due: { type: int }
sqlite:
  base: orders
  inherit:
    columns: [client_id]
  rhai: |
    base.clear_fields().field("client_id").expression(sql::expr("SUM(total) AS total_due")).group_by(sql::expr("client_id"))

This is the aggregate pattern: drop the base’s fields, group by a key, declare the aggregate as the table’s own column, and re-key the table with id_column. clear_fields() comes first because PostgreSQL and MySQL reject non-grouped columns that SQLite tolerates. Without rhai:, base: uses the base query as it is.

An inherited relation still works: following it from the derived table narrows the target by the derived query, as in client_id IN (SELECT "id" FROM (…) AS "debtors").

Read-only

A table with rhai: or base: in its driver block is read-only: the factory turns off the insert, update and delete capabilities, because there is no single table a write could land in. Callers find out the usual way, by checking capabilities. In Rhai, insert, upsert, patch, delete and a record’s flash() throw (see Capabilities).

To keep a table writable while narrowing it with a native expression, use SurrealDB’s modify: instead, which changes a real table (see modify: scripts).

Arguments

A query-sourced table can take arguments. The host sets them on the driver block when it opens the table; YAML can’t set them. The script sees them as args, a map of strings, where "" means “not set”. They reach rhai: sources and base: transforms, not modify: scripts.

let q = surreal::select().from("order");
if "bakery" in args && args.bakery != "" {
    q = q.where(surreal::expr("bakery.name = {}", [args.bakery]));
}
q

With bakery set to "Breg" the query gains the condition, with the name bound as a parameter. Without it the script skips the condition instead of failing. Arguments change the query itself, so they can filter rows before a GROUP BY, which a condition on the finished table can’t.

Vantage UI, for example, sets them from a Scenery binding’s arg(name, value) (see Layers).

Why scripts can’t run their own queries

Query building is allowed in one place: a table’s source, written by whoever writes the YAML. (SurrealDB’s modify: scripts, expr: columns and reference scripts are configuration too, and use the same builder.) A data script, such as an action body or an agent script, can only narrow tables with where, search and ref. It can’t send SQL, can’t template a query, and actions can’t run custom queries.

That keeps every read inside the tables an application declared, every write inside the set rules of Safe writes, and every value a data script passes a parameter, never text spliced into a query. Allowing actions to run queries may come later, behind its own permission.

Config-Driven Vistas shows where these scripts sit among the other YAML keys.

Query Builder Functions

This page lists every function the SQL and SurrealDB query builders register for Rhai. They are available where a script builds a native query: a query-sourced table’s rhai: and base: scripts, and on SurrealDB and MySQL also modify: scripts and reference build scripts (SurrealDB expr: columns too). Data scripts don’t get them (see Why scripts can’t run their own queries). The data vocabulary is listed in Words at a glance.

SQLite, PostgreSQL and MySQL share one registration (vantage_sql::register_engine!), so the “SQL” column holds for all three; where the output differs per database, the row says so. SurrealDB registers its own copy (vantage_surrealdb::rhai_engine::register_surreal_onto) with the same names where the concept exists in both. The Rust side of each primitive is in SQL Primitives and SurrealDB Primitives.

Free functions are namespaced. Every function in the tables below that isn’t called on a receiver lives in its backend’s namespace: sql:: for all three SQL databases, surreal:: for SurrealDB. A script writes sql::select().from(sql::ident("users")) or surreal::count(surreal::graph(surreal::me, "placed", "order")); the tables leave the prefix out. Methods (.from, .where, .alias, .when, .map, …), indexers and operators are called on their receiver as written. The namespace keeps the builders from shadowing the data vocabulary’s table, where and first on a host that has both.

In the signatures, x is an expression or an identifier, sel a select(), and "…" a string. Where a function takes a plain value as well, it says so.


Values in a query

Two rules hold in both builders:

  • A number, bool or string passed as an argument (to expr’s array, to a comparison, to coalesce and thing on SurrealDB) becomes a bound parameter, never query text.
  • A string passed as a name (ident, from, field, alias) or as a template (expr, cast’s type) is written into the query. These come from the configuration’s author, not from data.

Constructors

FunctionSQLSurrealDBBuilds
select()yesyesan empty SELECT
ident("name")yesyesa quoted identifier
expr("text")yesyesraw query text
expr("a = {}", [args])yesyestext with {} holes; each argument is an expression, an identifier, or a value bound as a parameter
fx("name", [x, …])yesyesa function call name(x, …); arguments must be expressions or identifiers
case_when()yesyesan empty conditional (see Conditionals)
window()yes—an empty window spec (see Window functions)
thing("table", "id")—yesa record id, type::record(…) with both parts bound as parameters
param("name")—yesa parameter, $name
parent(), parent("field")—yes$parent, $parent.field
time_now()—yestime::now()
me (a variable: surreal::me)—yesthe current record, the anchor of a graph path

Identifiers and expressions

FunctionSQLSurrealDBBuilds
id.dot_of("s")yesyesdiffers: SQL puts s in front (sql::ident("name").dot_of("u") is "u"."name"); SurrealDB puts it after (surreal::ident("t").dot_of("f") is t.f)
id["col"]yesyesSQL: the column col qualified by the identifier’s alias or name; SurrealDB: the path id.col
x["field"], x[n]—yesa field of an expression, or element n of an array
id.alias("a")yesyesSQL: the identifier with an alias, rendered "users" AS "a"; SurrealDB: the expression id AS a
x.alias("a")yesyesx AS a, with a quoted as an identifier
x.clone()yesyesa copy

Select methods

Each method returns the select, so they chain.

MethodSQLSurrealDBAdds
from("table"), from(id)yesyesa source
from(x)—yesan expression as the source, such as a graph path or a record id
from_as("table", "alias")yes—an aliased source
from_as(sel, "alias")yes—a derived table, FROM (SELECT …) AS "alias"
field("name")yesyesa column
clear_fields()yesyesdrops every field so far; call it before grouping a base
expression(x)yesyesan expression as a field; name it with .alias("a")
where(x)yesyesa condition; several are joined with AND
group_by(x)yesyesa GROUP BY term
order_by(x, "asc"|"desc")yesyesan ORDER BY term; any other direction is an error
having(x)yes—a HAVING condition
distinct()yesyesDISTINCT
limit(n, skip)yesyesLIMIT and offset; SurrealDB writes LIMIT n START skip and leaves out START 0
inner_join("table", "alias", on)yes—INNER JOIN
left_join("table", "alias", on)yes—LEFT JOIN
group_all()—yesGROUP ALL: the whole result as one aggregate row
split("field"), split(id)—yesSPLIT: one row per element of an array field
only()—yesSELECT ONLY: a single record
value()—yesSELECT VALUE: plain values instead of objects
sel.subquery()—yesthe select as an expression, (SELECT …)

Aggregates

FunctionSQLSurrealDB
count(x)COUNT(x)count(x)
count()—count()
count_distinct(x)COUNT(DISTINCT x)count(array::distinct(x))
sum(x)SUM(x)math::sum(x)
avg(x)AVG(x)math::mean(x)
min(x), max(x)MIN(x), MAX(x)math::min(x), math::max(x)
group_concat(x, distinct)SQLite GROUP_CONCAT(x, ','), PostgreSQL STRING_AGG(x, ','), MySQL GROUP_CONCAT(x SEPARATOR ','); distinct is a bool—
median(x), stddev(x)—math::median(x), math::stddev(x)
array_group(x)—array::group(x)

Scalar functions

FunctionSQLSurrealDB
coalesce(a, b)COALESCE(a, b); a and b must be expressions or identifiersa ?? b; values allowed
nullif(a, b)NULLIF(a, b); expressions or identifiersIF a = b THEN NONE ELSE a END; values allowed
cast(x, "type")CAST(x AS type)type::<type>(x)
round(x, n)ROUND(x, n)math::fixed(x, n)
round(x)—math::round(x)
date_format(x, "%Y-%m")SQLite STRFTIME, MySQL DATE_FORMAT, PostgreSQL TO_CHAR with the tokens translated (%Y %m %d %H %M %S)time::format(x, "fmt")
time_group(x, "unit")—time::group(x, unit); the unit is bound
lower(x)—string::lowercase(x)
words(x)—string::words(x)
string_len(x)—string::len(x)
similarity(x, "term")—string::similarity::jaro_winkler(x, term); the term is bound
first(x)—array::first(x)
len(x)—array::len(x)
object_entries(x), object_values(x)—object::entries(x), object::values(x)
type_float(x), type_int(x)—type::float(x), type::int(x)

Arithmetic and comparison

Function or operatorSQLSurrealDBBuilds
add(a, b), sub(a, b), mul(a, b), div(a, b)yesyes(a + b) and so on; expressions or identifiers only
a + b, a - b, a * b, a / b—yesthe same, where one side is an expression and the other an expression or a number
a == b, a != b, a < b, a > b, a <= b, a >= byesyesa condition; == is written =. Either side may be an expression, an identifier or a value; SurrealDB also takes an array […] or a map #{…} literal

Conditionals

case_when() starts a conditional, .when(cond, then) adds a branch, .else_(value) sets the fallback, and .expr() turns it into an expression:

MethodSQLSurrealDB
.when(cond, then)cond an expression or identifier, then an expressioneither may be a value
.else_(value)an expression or identifierany value
.expr()CASE WHEN … THEN … ELSE … ENDIF … THEN … ELSE … END

Window functions

SQL only. window() starts a window spec; each method returns it.

MethodAdds
.partition_by(x)a PARTITION BY term
.order_by(x, "asc"|"desc")an ORDER BY term
.rows("from", "to")ROWS BETWEEN from AND to, such as "UNBOUNDED PRECEDING", "CURRENT ROW"
.range("from", "to")RANGE BETWEEN from AND to
.apply(x)the expression x OVER (…), such as sql::window().partition_by(…).apply(sql::sum(…))

Graph paths

SurrealDB only.

FunctionBuilds
graph(me, "edge", "table", …)an outward path ->edge->table… from the anchor; 2 to 7 arguments
graph("table", "edge", …, me)an inward path table<-edge<-…; the anchor’s position sets the direction
graph(…, graph(…), …)a nested path; the anchor may be a sub-path, which changes direction per hop
recurse(path, min, max)@.{min..max}(path)
id.arrow("edge")id->edge
id.back("edge")id<-edge
id.arrow_field("edge", "field")id->edge.field

Exactly one argument of graph is the anchor (surreal::me or a sub-path); it must be first or last.

Array closures

SurrealDB only. These take native Rhai closures and build SurrealQL from them; the closure’s parameters become engine-named placeholders ($value, $acc).

MethodBuilds
x.map(|v| body)x.map(|$value| body)
x.filter(|v| body)x.filter(|$value| body)
x.fold(init, |acc, v| body)x.fold(init, |$acc, $value| body)

On a table handle

SurrealDB and MySQL add one verb to the data vocabulary’s handles: self.with_condition(x) narrows the table by a native expression, such as self.with_condition(surreal::ident("is_paying_client") == true). It is a method, so it takes no namespace, and it works on self only (see Backend extensions). MongoDB has the same verb, but it takes a filter written as a Rhai map rather than a query-builder expression.

Live Data: Push, Poll & Freshness

A cache only stays correct if it finds out when the underlying data changes. There are two ways it can: it asks (poll), or the database tells it (push). A Dio asks for push with one call on its builder, DioBuilder::live(): the Dio checks whether the master Vista advertises Capability::Subscribe, follows its change stream if so, and quietly does nothing if not.

That last part is the bit worth knowing before you pick a backend. A live() Dio that builds without error does not mean you have a live feed; it means nothing went wrong. This page says exactly what each shipped driver delivers today, what — if anything — you have to do to get it, and how the Lens keeps a cache fresh when push isn’t there.


Three freshness models

Every backend answers two questions, and the pair decides what you get:

  1. Can it tell you something changed at all? A file on disk cannot. A database server can.
  2. When it does, does it hand you the row, or only a signal?
ModelWhat arrivesWhat the Dio does
pollnothing — you re-read on a timerre-lists the whole set
coarse push“something changed”, no payloadre-reads the set to find out what
fine pushthe action and the affected recordapplies that one row to the cache

Fine push is the only model where a change costs you no query. Coarse push still beats polling: you re-read because something happened, not on the off-chance.

There is a fourth position, currently held by one backend. Fine push still leaves a question open — does this row belong in my filtered set? — because a driver that watches the whole table will hand you rows your conditions exclude. Filtered fine push closes it: the conditions go into the subscription itself and the database maintains the result incrementally, so a row that changes out of the set arrives as a delete. Membership stops being something anyone has to reconcile.

What each backend delivers today

BackendMechanismModelSetup required
SpacetimeDBsubscription over WebSocket (BSATN)filtered fine pushnone
SurrealDBLIVE SELECT over WebSocketfine pushnone
PostgreSQLLISTEN/NOTIFY on {table}_changedcoarse pusha trigger and an explicit opt-in
SQLite—poll—
MySQL—poll—
MongoDBchange streamsfine pusha replica set or sharded cluster
CSV files—poll—
REST / GraphQL—poll—
AWS—poll—
Kubernetes—poll—
CLI tools—poll—
Append logs— (write-only sink)n/a—

MySQL is poll-only: it has no LISTEN/NOTIFY equivalent, and pushing changes would need a binlog replication client.

Only four drivers implement TableShell::watch at all. A standalone MongoDB server has no change streams and polls. MongoVistaFactory::with_change_streams(true) is for overriding a failed (Unknown) topology probe, say behind a proxy that refuses hello; it cannot give a standalone server change streams, and forcing it on there makes the watch fail on the server. Everything in the poll rows inherits the trait default, which refuses watch and leaves Capability::Subscribe out of the shell’s capabilities — so live() on those does nothing and the Lens’s refresher carries the load.

Poll is a supported answer, not a failure

A polled backend is not broken, and you do not need different application code for it. The Lens keeps its refresher, the scenery is content-aware, and an unchanged re-read bumps no generation and repaints nothing. The cost is latency and a query you didn’t strictly need — not correctness.

Polling: the Lens’s refresher

A Lens re-reads its Dios through a Refresher. Its function gets a LoaderCx and does the re-read; LoaderCx::reconcile makes the cache hold exactly the snapshot it is given. Two settings decide when it runs:

  • Refresher::every(interval) runs it on a timer, interval after each Dio is built and then every interval.
  • Refresher::max_age(ttl) bounds how stale a cache may be when it is opened. A Dio whose last successful refresh is older than ttl, or unrecorded, gets a background refresh when it is built and whenever a table or record scenery opens on it. The refresh time is stored in the cache, so it holds across restarts.

They combine freely. max_age alone runs no timer: a cache that was refreshed five minutes ago and opened again is left alone until it is older than its bound. every alone keeps refreshing whether anyone opens the cache or not. Both together is the usual setting for a polled backend behind a persistent cache: a refresh on open after a long absence, then a steady timer while the application runs.

use std::time::Duration;

use ciborium::Value as CborValue;
use vantage_dataset::ReadableValueSet;
use vantage_diorama::{Cache, DioEvent, Lens, Loader, Refresher};
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, Vista, VistaMetadata};

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let mut lobby = Record::new();
lobby.insert("name".to_string(), CborValue::Text("Lobby".into()));
let rooms = Vista::new(
    "rooms",
    MockShell::new()
        .with_metadata(
            VistaMetadata::new()
                .with_column(Column::new("id", "String").with_flag("id"))
                .with_column(Column::new("name", "String"))
                .with_id_column("id"),
        )
        .with_record("r1", lobby),
);
let lens = Lens::builder()
    .cache(Cache::memory())
    .loader(Loader::eager(|cx| async move {
        let rows = cx.master().list_values().await?;
        cx.reconcile(rows).await
    }))
    .refresher(
        Refresher::new(|cx| async move {
            let rows = cx.master().list_values().await?;
            cx.reconcile(rows).await
        })
        .max_age(Duration::from_secs(15 * 60))
        .every(Duration::from_secs(60)),
    )
    .build()?;

// Follows the master's change stream when it advertises Subscribe;
// over a polled master the refresher above keeps the cache fresh instead.
let dio = lens.dio(rooms).live().build().await?;

let mut events = dio.subscribe_events();
tokio::spawn(async move {
    while let Ok(event) = events.recv().await {
        if let DioEvent::RefreshFailed { error, .. } = event {
            eprintln!("rooms may be stale: {error}");
        }
    }
});
assert_eq!(dio.cached().list_values().await?.len(), 1);
Ok(())
}

Without a refresher a Lens has no timer and no staleness check; the cache holds what the loader and the writes put there.

Why not refresh on every open?

Because for a persistent cache, “on every open” is “on every screen change”. A grid that the user leaves and comes back to would re-list its whole set each time, which on a slow REST source is the latency the cache was there to hide. max_age keeps the refresh where it pays: after an absence long enough that the data has plausibly moved.

Changes you feed in yourself

A source with its own change feed that no driver wraps — a webhook, a message queue, a file watcher — feeds the Dio directly. Two calls cover it:

  • Dio::apply_change(ChangeEvent) applies one change: Inserted and Updated upsert the row, Deleted removes it, and Invalidated re-reads the whole set. Each publishes the matching DioEvent, so open sceneries repaint.
  • Dio::reconcile(snapshot) makes the cache hold exactly a full snapshot: rows absent from it are deleted, except one with a write in flight, and one DatasetChanged is published.
use ciborium::Value as CborValue;
use vantage_dataset::ReadableValueSet;
use vantage_diorama::{Cache, ChangeEvent, Lens};
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, Vista, VistaMetadata};

fn room(name: &str) -> Record<CborValue> {
    let mut r = Record::new();
    r.insert("name".to_string(), CborValue::Text(name.to_string()));
    r
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let rooms = Vista::new(
    "rooms",
    MockShell::new().with_metadata(
        VistaMetadata::new()
            .with_column(Column::new("id", "String").with_flag("id"))
            .with_column(Column::new("name", "String"))
            .with_id_column("id"),
    ),
);
let lens = Lens::builder().cache(Cache::memory()).build()?;
let dio = lens.dio(rooms).build().await?;

dio.apply_change(ChangeEvent::Inserted { id: "r1".into(), new: Some(room("Lobby")) }).await?;
dio.apply_change(ChangeEvent::Updated { id: "r1".into(), new: Some(room("Atrium")) }).await?;

let snapshot = [("r2".to_string(), room("Kitchen"))].into_iter().collect();
dio.reconcile(snapshot).await?;

let cached = dio.cached().list_values().await?;
assert!(cached.len() == 1 && cached.contains_key("r2"));
Ok(())
}

A None row on Inserted or Updated publishes the event without touching the cache: the notify-only shape a coarse feed produces. A refresher’s or loader’s own re-read goes through LoaderCx::reconcile instead, which also leaves alone any row whose write settled while the read was in flight.

SpacetimeDB — filtered fine push, no setup

SpacetimeDB is a database that is also the application server: a WASM module holding the schema and the reducers runs inside it, and the client’s normal way to read anything is to subscribe. Push is not a feature bolted on beside queries; it is the product.

The driver sends the Vista’s own conditions as the subscription query, so the server evaluates them and only matching rows are ever transmitted. Because it maintains that result incrementally, a row that changes so it no longer matches arrives as a delete — you are told the row left, not merely that something happened. That is why this is the only row in the table where set membership needs no reconciliation from anyone: not a re-read per notification as SurrealDB does, and not a whole-set reload as Postgres does.

Nothing needs installing or enabling. There is no trigger, no extension, no opt-in flag — which is why its shells simply advertise Subscribe rather than something you have to declare.

This backend gives you less everywhere else

The trade is real and worth stating. SpacetimeDB’s SQL has no ORDER BY, OFFSET, GROUP BY, IN or LIKE, and the driver does not emulate them — a grid over a SpacetimeDB table shows rows in the server’s natural order and its sort headers are inert. Aggregates for a chart have to be materialised by the module into a rollup table or computed in a view. You are choosing a backend that pushes beautifully and queries narrowly.

SurrealDB — fine push, no setup

LIVE SELECT delivers a typed per-row notification: the action (CREATE / UPDATE / DELETE) and the affected record. The driver re-reads each notified id through the vista’s own conditions, so a row that no longer belongs to a filtered set arrives as a delete rather than silently lingering. A real table is watchable the moment you build it; only query-sourced vistas (rhai: / base:) are not.

Live queries ride the WebSocket connection

LIVE SELECT needs a transport the server can push frames down, which means WebSocket. That is not a trap you can fall into: SurrealConnection::connect accepts only ws://, wss:// and cbor:// and rejects anything else outright, so every SurrealDB connection you can actually open is one that can carry live queries.

PostgreSQL — coarse push, and why you must ask for it

Postgres has no built-in change feed. It has LISTEN/NOTIFY, a general-purpose pub/sub channel, and a trigger you install yourself decides what gets announced. The driver listens on {table}_changed and yields a payload-less invalidation for every notification.

Because you install that trigger, only you know whether it exists — and LISTEN on a channel nobody ever feeds succeeds, then blocks forever. A Vista that assumed it could push merely because the table is writable would advertise a feed that may never arrive, which a consumer cannot tell apart from a table where nothing happens. So it is opt-in. The block needs a running Postgres, so it is compiled but not run:

use vantage_sql::postgres::{AnyPostgresType, PostgresDB};
use vantage_table::table::Table;
use vantage_types::entity;
use vantage_core::Context;

#[entity(PostgresType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
    pub name: String,
}

impl Product {
    pub fn table(db: PostgresDB) -> Table<PostgresDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = PostgresDB::connect("postgres://localhost/bakery").await.context("connect")?;
let master = db
    .vista_factory()
    .with_notify(true)   // yes, I installed the trigger
    .from_table(Product::table(db.clone()))?;
let _ = master;
Ok(())
}

The trigger itself, and the full walkthrough, are in Real-Time Push with LISTEN/NOTIFY. The declaration applies to every table a factory builds, so if only some of your tables carry triggers, use separate factories. It also carries across reference traversal — a relation target inherits the parent’s opt-in rather than silently reverting to unwatchable.

What a consumer must assume

Push is best-effort, and the contract is deliberately loose so that a driver can tighten its scope later without breaking anyone. Callers always hand over the full Vista; the driver delivers what it can. Four things hold everywhere:

  • The stream may be coarser than your Vista. Watching the whole table and letting the consumer discard the rest is a legitimate implementation.
  • A pushed row may fall outside your conditions, precisely because of the above. Either the driver reconciles or you must. A live Dio holds each change against its own query, and applies a row that falls outside it as a delete.
  • An invalidation means “re-read everything.” It carries no id and implies nothing about how much changed.
  • The stream ending is normal. Connections drop; consumers resubscribe with backoff and reconcile the gap. A live() Dio already does this: it re-subscribes, re-reads the set, and publishes DioEvent::RefreshFailed for each re-read that fails until one succeeds. The sceneries keep the rows they show meanwhile.

The consequence worth internalising: Capability::Subscribe advertises an attempt to push, not a guarantee of delivery. No shipped backend is lossless, so anything that must not miss a change keeps a reconcile path — a refresher with every, a refresh on reconnect — rather than trusting push alone.

Adding push to a driver

If you are implementing a backend, the driver-side contract — what you must promise when you implement watch, and when you may honestly advertise Subscribe — is step 8 of Adding a New Persistence. Start without the capability; turning it on the day the feed works is a healthier state than shipping a flag that lies.

Checklist

To keep a Dio fresh:

  1. Know your backend’s model — the table above; push needs Capability::Subscribe on the master.
  2. Ask for push — lens.dio(master).live().build(). Over a polled master it does nothing.
  3. Give the Lens a refresher — Refresher::new(..) with max_age for staleness on open, every for a timer, or both.
  4. Feed changes no driver wraps — dio.apply_change(..) per change, dio.reconcile(..) for a full snapshot.
  5. Watch for DioEvent::RefreshFailed — the cache may be out of step until a retry succeeds.

Optimising Live Diorama Data

An application reports its faults. It does not report its inefficiencies. A failed request raises an error, but a duplicate fetch stays unnoticed. Typical problems are:

  • refetch a static page in its entirety every 5 seconds
  • fetch all columns of a related table, just to fill a select field in a form
  • sort 120,000 rows in the client, and ignore the sort capability of the source

Inefficiencies show as extra latency, high CPU usage, or network traffic. This chapter gives you the mechanisms to detect them and to avoid them.

Why live data needs a cache

A user interface repaints many times each second. It cannot wait for the network on each frame, so the rows must already be in memory. When the user scrolls, the framework fetches rows ahead of the viewport to keep the movement smooth.

Diorama keeps those rows in a redb file on disk, one file for each datasource. The rows therefore survive a move to another page, and they survive a restart.

A user interface is not the only consumer of a cache. Vantage is also designed for facade APIs and for live caches at the edge.

The goal is the same in each case: answer most requests without a request to the master source, and keep the local copy in agreement with that source. A correct cache answers immediately, and then pushes each change to the viewport when it arrives.

UI viewport Facade API Edge cache Diorama cache redb, on disk Master source slow · remote declare viewport relevant live updates few fetches, batched whole-table changes

What slows the app down?

InefficiencyWhat happensThe damage
Repeated fetchThe application asks for a window that it already asked for.One request for each timer tick or repaint
Redundant rowsRows arrive that the cache already holds.Bandwidth, with no change on screen
Off-screen updatesThe source announces a change to a row that the user cannot see, and the view repaints.CPU usage for data that nobody looks at
Undeclared columnsEach row carries fields that no table lists.Bandwidth. A grid of five columns can cost megabytes for each screen.
Work in the clientThe client sorts, searches, or counts locally.CPU usage, and an answer that covers the loaded rows only
A full copy of a large tableThe source cannot serve windows, so the client reads all the rows at open.Memory, and a wait before the first screen
A total that no fetch can fillThe source states more rows than it serves.Requests for the missing rows, for as long as the page stays open

There is no best caching strategy. Each use case needs a different approach. Vantage lets you design and implement your own strategy, but you must first understand how the cache behaves and how to monitor it.

Vantage UI works well with its default behaviour. This chapter starts from that default, and then adjusts it.


Overview

SectionWhat you doWhat it answers
1. The setupBuild the optimising project and open it in Vantage UIHow do I put a source that I control under a real client?
2. Reading the Debug StreamMake the source slow, empty the cache, and switch on debug for one datasourceWhat does my application ask for?
3. Where the Work HappensRemove capabilities and find where the work movesWhich work moved into my process, and what did it cost?
4. Fetching What You DropFind repeated fetches, redundant rows, and columns that no view showsHow many of these bytes did anybody need?
5. Faults: Errors, Outages & LiesInject failures, outages, and dishonest totalsDoes the application continue, or does it stop?
6. Locking It InMake assertions from your measurements, over a run with a fixed seedWill I know when this becomes worse?

Start here: The setup

The setup

An enterprise source holds millions of records, and there can be hundreds of changes each second. The change channels are coarse: one channel for each table. But no user observes millions of records. A user looks at 20 or 30 rows, and possibly at some aggregated values.

A Vantage client makes the distinction. Vantage UI connects to the datasource and then narrows the data to a viewport, which is the set of rows on screen. A live cache at the edge follows the same principle when it drives a frontend application: it holds a fragment of the table, and it forwards the changes that touch that fragment. The cache is the same cache in both cases, so what you learn here against Vantage UI transfers to the other components.

To measure a cache strategy you need three things:

  • a source whose behaviour you control: fast, slow, unreliable, or fast-changing
  • a consumer that you can navigate by hand and check by eye
  • full transparency into the cache mechanics, through a real-time log

This chapter uses a Vantage Faker datasource, Vantage UI, and the full cache log.

Create a project

Vantage UI is a free download from vantage-ui.com. It installs as a normal macOS application.

Start it normally, create a new project, and choose Fake Data in the wizard. Then find the project folder — this chapter calls it optimising — and examine the structure inside:

optimising/
├── datasource/people.yaml     where the rows come from
├── table/people.yaml          the columns, and which datasource supplies them
├── page/people.yaml           what the screen shows
└── menu/left.yaml             how to open the page

A project is a folder of YAML, and each folder holds one kind of declaration. Change the four files to match the listings below.

The datasource is a faker source. It generates rows instead of fetching them, and you declare how it behaves. This one holds 200,000 personal records: names, surnames, emails, and some additional data.

# datasource/people.yaml
type: faker
effect: static
count: 200000
seed: 103
shape:
  capabilities: [count, fetch_window, order, search]
  latency:
    window: 10ms..40ms
    get: 5ms..20ms

The capabilities list

capabilities is the contract of the source. A real driver declares what it can do, and faker declares what you tell it to. The list above mimics a SQL source:

  • count — the source reports how many rows the table holds. The grid then knows the size of the scrollbar before it has any row.
  • fetch_window — the source serves a range, such as rows 500 to 600. Without it, the only operation is “read everything”.
  • order — the source sorts. A sort then applies to all the rows, and not to the loaded rows.
  • search — the source filters, with the same condition.

The source refuses each operation that is not in the list, and the work then moves to the client. Section 3 removes them one at a time.

Vista — the Universal Data Handle describes the full set of capability flags and how a driver declares them. The names here are Vantage UI’s. In Rust, fetch_window is Capability::Window, and count has no capability at all, because every Vista can count.

Once the datasource exists, declare the tables on it. Each table names its columns and the datasource that supplies them:

# table/people.yaml
datasource: people
title: People
columns:
  - { name: id,      type: string,  flags: [id] }
  - { name: name,    type: string,  flags: [title, searchable] }
  - { name: surname, type: string,  flags: [title, searchable] }
  - { name: email,   type: string }
  - { name: city,    type: string }
  - { name: balance, type: decimal }

Each column name is significant. name generates human names, surname generates surnames, and email generates email addresses. Faker uses the column name first and the declared type second, so a name that faker does not recognise gets a value from its type. Every value is fake, and faker generates all of them at start-up from the seed.

Use realistic names. Much of this chapter measures size, and a table of row_1 and row_2 values makes each of those measurements wrong.

Use one faker datasource for each table

A table file follows the Vantage YAML specification, which is the same for every datasource type. It declares columns, types, and flags, and nothing more. A datasource file has no such restriction, so each faker knob is there: count, seed, effect, and the full shape: block.

One faker datasource therefore describes one set of rows. Give each table its own datasource. This chapter builds four: people, stubborn, flaky, and wide, one for each behaviour that it measures.

The page shows a binder. A binder appears as a full CRUD screen: it lists the rows, it edits them when the source supports editing, it has a quick-search box and column ordering, and a click on a row opens a summary of that row. Behind the screen it keeps its viewport equal to the rows that you see.

Vantage UI also has a kind: grid component. A grid has no search box, so this chapter uses a binder.

# page/people.yaml
title: People
body:
  - kind: binder
    name: people
    observe: people
    params:
      columns:
        name:    { width: 160 }
        surname: { width: 160 }
        email:   { width: 220 }
        city:    { width: 180 }
        balance: { width: 120 }

The menu makes the page available:

# menu/left.yaml
title: Optimising
items:
  - section: Optimising
    children:
      - page: people
        label: People
        icon: Users

Run it

Each later section starts Vantage UI again from the command line, and not from the Finder. The binary in the bundle accepts arguments, and the debug output goes to stdout. Make an alias:

alias vantage-ui='/Applications/Vantage.app/Contents/MacOS/vantage-ui'
vantage-ui ./optimising --page=people

The binder lists the rows, and a click on a row fills the details panel below it. The row count says 200000, because the source counted the set. Scrolling is smooth, and no row stays empty for long enough to see.

The first argument is the project folder. --page=<key> opens that page instead of the first entry in the menu. This matters: a page that you did not intend to measure also opens a Dio, and its requests go into the same statistics.

Keep the project simple

Vantage UI builds one Dio for each observed table, when a page that observes it opens. The other YAML files in the folder cost nothing until you open them.

Two conditions make a measurement unclear:

  1. One page with components that observe different tables. Each component has its own Dio and sends its own requests.
  2. Tabs that stay open behind the page that you measure. Each open tab keeps its Dios active.

Use one table, one binder, and one variable. Close the pages that you do not measure.

Using faker in Rust

This chapter uses Vantage UI, because Vantage UI supplies the viewport. The faker source does not need Vantage UI. vantage-faker is a normal crate: a DatasetGen fills a vantage-memory store, and the shape: block above is a BackendShape that a ShapedShell enforces over the memory table. This code builds the same datasource:

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
use std::time::Duration;

use vantage_dataset::prelude::ReadableValueSet;
use vantage_diorama::{Cache, Lens, Loader};
use vantage_faker::{BackendShape, DatasetGen, FakerColumn, Latency, LatencyModel, ShapedShell, TableGen};
use vantage_memory::{MemoryCatalog, MemoryStore, MemoryTableShell};
use vantage_vista::{Capability, Column, ColumnFlag, Vista, VistaMetadata};

// The rows: 200,000 people from seed 103. Faker fills the `id` column itself.
let store = MemoryStore::new();
let people = TableGen::new("people")
    .column(FakerColumn::new("name", "string"))
    .column(FakerColumn::new("surname", "string"))
    .column(FakerColumn::new("email", "string"))
    .column(FakerColumn::new("city", "string"))
    .column(FakerColumn::new("balance", "decimal"))
    .count(200_000);
DatasetGen::new(Some(103)).table(people).generate(&store)?;

// The table file: columns and flags.
let column = |name: &str, ty: &str| Column::new(name, ty).with_flag(ColumnFlag::Orderable);
let metadata = VistaMetadata::new()
    .with_column(Column::new("id", "string").with_flag(ColumnFlag::Id))
    .with_column(column("name", "string").with_flag(ColumnFlag::Title).with_flag(ColumnFlag::Searchable))
    .with_column(column("surname", "string").with_flag(ColumnFlag::Title).with_flag(ColumnFlag::Searchable))
    .with_column(column("email", "string"))
    .with_column(column("city", "string"))
    .with_column(column("balance", "decimal"))
    .with_id_column("id");

// The `shape:` block. `count` has no capability in Rust: every Vista counts.
let shape = BackendShape {
    capabilities: [Capability::Window, Capability::Order, Capability::Search]
        .into_iter()
        .collect(),
    latency: LatencyModel {
        window: Some(Latency::between(Duration::from_millis(10), Duration::from_millis(40))),
        get: Some(Latency::between(Duration::from_millis(5), Duration::from_millis(20))),
        ..LatencyModel::default()
    },
    seed: Some(103),
    ..BackendShape::default()
};
let shell = MemoryTableShell::new(store.table("people"), metadata, MemoryCatalog::new(store.clone()));
let vista = Vista::new("people", shell).map_shell(|inner| ShapedShell::new(inner, shape));

// From here, this is the normal reactive stack. Nothing is faker-specific.
// Page a master that serves windows; copy one that doesn't.
let loader = Loader::paged(|cx, range, query, sink| async move {
    let fetched = cx.fetch(range.clone(), &query).await?;
    if !fetched.refused.is_empty() {
        // A window can't be sorted or searched after the fact: fail the load.
        return Err(vantage_core::error!("the source refused part of the view's query"));
    }
    let page = fetched.page;
    if let Some(total) = page.total {
        sink.set_total(total.max(0) as usize);
    }
    for (i, (id, row)) in page.rows.into_iter().enumerate() {
        sink.push(range.start + i, id, row).await?;
    }
    Ok(())
})
.or_eager(|cx| async move {
    let rows = cx.master().list_values().await?;
    cx.reconcile(rows).await
});
let lens = Lens::builder().cache(Cache::memory()).loader(loader).build()?;
let dio = lens.dio(vista).build().await?;
let _ = dio;
Ok(())
}

Use this code when your own code is the consumer: an integration test that calls set_viewport, a CLI, or a scenery in a terminal. ShapedShell only takes capabilities away: it advertises the ones the shape lists and the memory table has, and refuses the rest.

Switch on the debug stream

Delete the cache and start Vantage UI again. Each measurement in this chapter starts from an empty cache, because a full cache serves the first screen and asks the source for nothing.

rm -rf ~/Library/Caches/Vantage/optimising-*
vantage-ui ./optimising --page=people

Without the debug flag, the output describes the start-up and nothing else:

INFO vantage_ui: starting root="…/optimising" has_project=true
INFO catalog: loaded datasource key="people"
INFO catalog: loaded table key="people"
INFO catalog: loaded framework page key="people"
INFO vantage_ui: Dio cache dir path="~/Library/Caches/Vantage/optimising-c79233058eb9f98f"
INFO vantage_ui: configured faker datasource datasource=people effect=Some("static")
INFO vantage_ui: entity open, by phase key=people/people ms=2891
                 breakdown=vista=2798ms cache=82ms dio=2ms scenery=7ms

This tells you what loaded. The last line is the only measurement: 2.8 seconds inside the Vista, which is faker generating 200,000 rows at start-up. Nothing here reports a request, a cache hit, or a row.

Now add one line to the datasource:

# datasource/people.yaml
type: faker
effect: static
debug: true          # ← add this
count: 200000

Delete the cache and start it again:

rm -rf ~/Library/Caches/Vantage/optimising-*
vantage-ui ./optimising --page=people

A second stream now appears beside the log:

11:53:45.591  people  dio       created over "people" — cache table "people_people", nothing held yet
11:53:45.592  people  source    can count, window, order, search · pages lazily, 100 rows at a time
11:53:45.592  people  scenery   opened cold — 0 rows seeded from cache
11:53:45.593  people  census    +1 table scenery — now 1 table, 0 record, 0 servo · rss 310MB
11:53:45.645  people  viewport  rows 0..200 — 2 scroll events coalesced into one
11:53:45.645  people  dio       fetch #1 asks for rows 0..200 — 200 missing, 0 already held
11:53:45.715  people  cache     +200 new, 0 updated — now holding 200 of 200,000 (0.1%)
11:53:45.715  people  scenery   opening → partial (chunk load succeeded)
11:53:45.716  people  dio       fetch #1 got 200 rows in 70ms
11:53:45.716  people  payload   6 columns received · no view declared what it needs, so all were
                                fetched · 23KB · id,name,surname,email,city,balance
11:53:45.717  people  total     200,000 rows — stated by the source in the same response
11:53:45.821  people  cache     all 22 rows of 0..22 served locally — no fetch

That is one page opening, from an empty cache. The binder asked for 200 rows, one request supplied them, and the last line is the repaint that followed: 22 rows on screen, no request.

The flag applies to one datasource. It is not a global setting. Your other datasources stay silent, so the question is always “what does this table do?”.

In Rust the same switch is on the Lens: LensBuilder::debug_datasource(name), behind vantage-diorama’s diagnostics feature. Every Dio that Lens builds writes the stream to the vantage_diorama::debug tracing target, stamped with name.

What is next

The next section makes the source slow, so that each of these lines separates in time. It then reads the stream line by line.

Reading the Debug Stream

The first thing that you learn from the stream is which line agrees with which moment on screen. Keep the window and the terminal beside each other. Sort a column, type in the quick-search box, and scroll, and watch which lines each action produces.

That is much easier when each request needs three seconds than when it needs 40 milliseconds.

Make the source slow

A source that answers in 40ms hides its behaviour. The rows are simply present, and a load is one flicker between two frames.

Change one value in the datasource. Each fetch then becomes an event with a start and an end.

# datasource/people.yaml
shape:
  capabilities: [count, fetch_window, order, search]
  latency:
    window: 3s          # each batch of rows costs three seconds
    get: 500ms..1s      # each single record costs half a second or more

The two classes pay for different requests:

Latency classThe request that pays itWhat causes it on screen
windowA range of rows, such as rows 500 to 600The grid scrolls into rows that the cache does not hold
getOne record, by its idA click on a row, which opens the details panel

window is the class that this chapter measures. A grid asks for ranges, and each unnecessary range is a fetch that you can count. Give get a value as well, so that a click on a row is also visible in the stream.

Nothing else changes: the same table, the same page, and the same 200,000 rows. But each decision of the framework now takes clock time that you can see.

Open the page and watch it load:

  1. The window opens immediately, and the page then needs a few seconds to mount. That delay is the fixture, not the framework: count: 200000 makes faker generate 200,000 rows at each start. Decrease count if you do not want it.
  2. Skeleton rows appear. A skeleton row has a position, but no contents. The grid gave a position to each of the 200,000 rows, because the source declared count, so the scrollbar has the correct size before any row arrives.
  3. The first batch arrives three seconds later, and those rows paint together.
  4. Scroll past them. The same sequence occurs again.

Now scroll back to the top. Nothing happens. There is no delay and there are no skeleton rows. That is the cache, and it is your first measurement: to return to a position that you visited costs nothing.

Cold runs and warm runs

There is one cache file for each datasource, under ~/Library/Caches/Vantage/<project>-<hash>/. Delete the folder for a cold run:

rm -rf ~/Library/Caches/Vantage/optimising-*

A cold run and a warm run are two different experiments. A cold run answers “what does the first open cost?”. A warm run starts from a full cache and answers “what does the application do with rows that it already holds?”. Too many refreshes appear in the warm run.

The two sets of numbers are not comparable. Record which run you did, each time.

Understanding the log

The debug stream shares the terminal with the ordinary log of the application:

12:23:58.222  people  dio  created over "people" — cache table "people_people", 422 rows already held from a previous session
2026-07-31T11:23:58.242796Z  INFO build_page_for_key{page_key="people" …}:build_entity{key="people"
  datasource=Some("people")}: vantage_ui: entity open, by phase key=people/people ms=2791
  breakdown=vista=2722ms cache=49ms dio=0ms scenery=18ms
12:23:58.341  people  dio  fetch #1 asks for rows 0..22 — 0 missing, 22 already held
12:23:58.420  people  dio  fetch #1 got 22 rows in 79ms

The short lines are the debug stream. Each one has four parts, always in this sequence:

PartExampleWhat it is
time12:23:58.341Local time, to the millisecond
tablepeopleThe datasource with debug switched on. With one datasource for each table, this is also the table.
origindioThe part of the framework that wrote the line
messagefetch #1 asks for rows 0..22 …One sentence, in units that a person reads: 3.0s, 24KB, 200,000, 0.1%

Each request has an identifier, such as fetch #1. The identifier connects a request to its result. There is one sequence for each Dio, and it starts at 1.

The sequence of lines in one load is not a contract

The lines from one load do not always appear in the same sequence. The framework writes the cache line and the state line inside the operation that the result line completes. Both can therefore come before the result line.

Use fetch #N to connect the lines. Do not use their positions.

The origin is what you search for. There are fourteen origins:

OriginWhat the line reports
dioThe framework created a Dio. Or a fetch asks for a range, got rows, or failed.
sourceWhat the source can do and cannot do, and which load method follows. One line, at open.
sceneryA view opened with a full or an empty cache. Or the load state changed.
censusA consumer attached or detached. Consumers are table sceneries, record sceneries, and servos, which watch one value. The line gives the current counts and the memory.
viewportThe range that a consumer declared. Coalesced means that several scroll events became one range.
cacheThe framework wrote rows. Or it served a viewport locally, with no fetch.
payloadThe columns that arrived, the columns that a view wants, and the size in bytes.
totalThe total count changed, and what supplied the new value.
sortA sort changed. Says whether the source does the work.
searchA search changed. Says whether the source does the work.
filterThe runtime filters changed. Says whether the source does the work.
deriveAn aggregate recomputed. The first line of a mount says initial: the computation that makes the schema of the derived table. After it, the aggregate waits for the source to change.
hydrateA second pass fetched the remaining columns of rows that are on screen. Section 4 explains the two passes.
summaryThe session summary, printed when Vantage UI quits.

Read one origin at a time

Write the session to a file, then read one origin:

vantage-ui ./optimising --page=people 2>&1 | tee /tmp/optimising.log
grep ' dio ' /tmp/optimising.log
12:23:58.222  people  dio  created over "people" — cache table "people_people", 422 rows already held from a previous session
12:23:58.341  people  dio  fetch #1 asks for rows 0..22 — 0 missing, 22 already held
12:23:58.420  people  dio  fetch #1 got 22 rows in 79ms
12:24:11.295  people  dio  fetch #2 asks for rows 316..339 — 23 missing, 0 already held · sorted by city Desc
12:24:16.842  people  dio  fetch #2 got 23 rows in 5.5s  ⚠ slow

Five lines report a whole session:

  • The cache was warm: 422 rows from an earlier run. A cold start says nothing held yet here.
  • fetch #1 asked for 22 rows that the cache already held. A warm start still asks the source for the window on screen, to check that the held rows are current.
  • Between 12:23:58.420 and 12:24:11.295 there is no line. Thirteen seconds of use cost no request.
  • fetch #2 carries sorted by city Desc. The sort went to the source, and the source applied it to all 200,000 rows, and not to the rows that the cache held.
  • That sorted request cost 5.5s. ⚠ slow marks each request of one second or more.

Read the other origins in the same way:

grep ' cache '          /tmp/optimising.log   # what the cache wrote, and what it served
grep 'served locally'   /tmp/optimising.log   # the viewports that cost nothing
grep -E 'asks for|got ' /tmp/optimising.log   # the network, and nothing else

Scroll while you watch tail -f on the file. Each three-second delay must have one asks for line and one got line. Each immediate repaint must have a served locally line, or no line.

A delay with no request is a fault in the UI. A request with no delay is a request that you did not need.

A full cold open

This is the output of the optimising project, with high latency and an empty cache, when the People page opens. It is a capture, not an example.

21:20:15.397  people  dio       created over "people" — cache table "people_people", nothing held yet
21:20:15.399  people  source    can count, window, order, search · pages lazily, 100 rows at a time
21:20:15.401  people  scenery   opened cold — 0 rows seeded from cache
21:20:15.401  people  census    +1 table scenery — now 1 table, 0 record, 0 servo · rss 313MB
21:20:15.455  people  viewport  rows 0..200 — 2 scroll events coalesced into one
21:20:15.455  people  dio       fetch #1 asks for rows 0..200 — 200 missing, 0 already held
21:20:18.493  people  cache     +200 new, 0 updated — now holding 200 of 200,000 (0.1%)
21:20:18.494  people  scenery   opening → partial (chunk load succeeded)
21:20:18.494  people  dio       fetch #1 got 200 rows in 3.0s  ⚠ slow
21:20:18.494  people  payload   6 columns received · no view declared what it needs, so all were
                                fetched · 23KB · id,name,surname,email,city,balance
21:20:18.494  people  total     200,000 rows — stated by the source in the same response
21:20:18.551  people  cache     all 13 rows of 0..13 served locally — no fetch

The framework creates the Dio before any view can use it, and its cache table is empty. That is the cold start. The source line gives the contract that each following line obeys.

The binder declares a viewport of 200 rows, and not the 13 rows that you can see. The loader reads ahead of the screen, so that the next scroll costs nothing. Section 4 measures this.

The source line says 100 rows for each page, and the first fetch asks for 200. The first fetch covers the opening viewport, which the binder sets to 200 rows. Later fetches use the page size of 100.

Then the delay: from .455 to 18.493, three seconds. Four facts then arrive together:

  • the cache holds the rows — 200 of 200,000, which is 0.1%
  • the view is no longer in the loading state
  • the fetch reports its cost
  • the total is known, because the source supplied it in the same response

The last line is the result: the grid painted 13 rows and made no fetch.

Now scroll. Most of the output is that same line:

21:20:20.401  people  viewport  rows 29..43 — 5 scroll events coalesced into one
21:20:20.401  people  cache     all 14 rows of 29..43 served locally — no fetch

The summary at the end

When you quit, the framework prints the summary of the session:

21:20:33.374  — diorama session summary —
21:20:33.374  people     summary  3 fetches, 0 repeats · 400 rows, 0 redundant · 9.1s waiting, 3.0s slowest
21:20:33.374             summary  1 dio, 1 table scenery, 0 record, 0 servo still alive
21:20:33.374             summary  session 18.0s · cpu 15.0s · peak rss 352MB

Two numbers in the first line are important. The other sections keep both at zero.

  • repeats is the count of ranges that the application fetched more than one time.
  • redundant is the count of rows that arrived when the cache already held them. A warm revalidation gives a small number. A number equal to the count of rows that arrived means that the application fetched a full page for nothing.

The second line finds leaks. Close a page, and the counts must decrease. If the count of sceneries increases while you move between pages, something keeps views alive after their page closed.


What we covered

ItemWhat it does
latency.window: 3sSeparates the events, so that you can attribute each line to a moment
~/Library/Caches/Vantage/<project>-<hash>/The location of the cache. Delete it for a cold run.
debug: trueSwitches the stream on for one datasource. The others stay silent. In Rust: LensBuilder::debug_datasource(name)
vantage_diorama::debugThe one target, at info. It appears without RUST_LOG.
time · datasource · tag · clauseThe four parts of each line. fetch #N connects a request to its result.
served locally — no fetchThe line that reports a viewport that cost nothing
repeats / redundantThe two waste counters in the session summary

What is next

The next section removes the capabilities of the source, one at a time, and finds where the work goes. A sort that the source cannot do does not stop. It moves to a more expensive place, and it uses only the rows that the application loaded.

Where the Work Happens: Capabilities & Latency

Until now the source was capable. When the source can do everything, the layers above it do nothing that you can measure.

This section removes the capabilities. Work does not disappear when a source cannot do it. The work moves.

One line controls everything

Each session starts with this line:

people   source   can count, window, order, search · pages lazily, 100 rows at a time

That line is the full contract. The capabilities: list in the datasource produces it, and each later decision follows from it. Change that line, and you change the behaviour of the application. You change no other file.

A source that can do nothing

Remove all capabilities. One operation remains, because a source always supports list.

# datasource/stubborn.yaml
type: faker
effect: static
debug: true
count: 3000
seed: 109
shape:
  capabilities: []          # no count, no order, no search, no windows
  latency:
    list: 2500ms..3500ms    # and the one operation that it has is slow

Open the page. The stream is very short:

22:02:51.204  stubborn   dio      created over "stubborn" — cache table "stubborn_stubborn", nothing held yet
22:02:51.205  stubborn   source   can do nothing but list · cannot count, order, search · copied whole into the cache
22:02:51.205  stubborn   scenery  opened cold — 0 rows seeded from cache
22:02:51.206  stubborn   census   +1 table scenery — now 1 table, 0 record, 0 servo · rss 129MB
22:02:54.265  stubborn   scenery  opening → complete (reactor reseed)
22:02:54.337  stubborn   cache    all 22 rows of 0..22 served locally — no fetch

There are no fetch lines. The people source sends one request for each screen of rows. This source receives one read only — the three seconds between the census line and the opening → complete line. Each later scroll comes from the cache, because the cache holds all the rows.

Diorama has two load methods, and the source line names the one in use.

MethodLog stringWhen the framework uses it
pagedpages lazilyThe source can serve [offset, limit) windows
eagercopied whole into the cacheThe source cannot serve windows

In Vantage UI you do not select the method. It follows from what the source can do. In Rust the choice is the Lens’s loader: Loader::paged fetches windows, Loader::eager copies the set, and Loader::paged(..).or_eager(..) decides per Dio, paging a master that advertises Window and copying one that doesn’t. That is the rule this section measures.

pagedeager
Readsone window for each screen, on demandone full read at open
Memoryproportional to the rows that you looked atproportional to the whole table
Scrollingcan cost a requestcosts nothing
A large tablestays cheapis a large read

Note the line opening → complete, and not → partial. A paged view stays partial for most of its life: it holds 200 of 200,000 rows, and it knows this. An eager view goes directly to complete, because after the copy there is nothing more to get.

Eager is correct for a small table, not for a large one

This source holds 3,000 rows. The same shape with 200,000 rows would read all of them at open, and the user would wait.

If your source cannot serve windows and your table is large, do not look for a Diorama setting. Add windows to the source, or observe fewer rows.

The cost of a sort, and its location

Sort the windowed table. The stream tells you where the work goes:

people   sort     by name descending — pushed to the source

The next fetch then includes the sort:

people   dio      fetch #3 asks for rows 521..621 (viewport 521..534) — 100 missing, 0 already held · sorted by name Desc
people   dio      fetch #3 got 100 rows in 7.3s  ⚠ slow

The framework did not sort the rows that it holds. It sent the sort to the source, because the source can order. When you hold 300 rows of 200,000, only the answer of the source is correct.

The request also became more expensive: 7.3s for 100 rows, against 3.0s for 200 rows. The source must now order 200,000 rows for each window.

The same action against the source that can do nothing gives a different line:

stubborn   sort   by name descending — the source cannot order; sorting the 3,000 rows held locally

No fetch follows. The cache holds all the rows, so the sort is a local operation. It is immediate, and it is correct because the cache holds the complete set.

The dangerous combination is a source that cannot order and a table that is too large to copy. The framework would then have to sort the rows that it loaded, and “sorted” would mean “the first page of a sample”.

Diorama does not do this. A paged view whose source orders the rows discards its cached row positions instead of moving them, and it fetches the visible window again:

people   scenery  row positions dropped — the source re-orders, so cached rows must be re-placed

The cost is one more fetch. The result is correct.

Search, and the control that the UI hides

Search obeys the same rule:

people   search   "smith" — pushed to the source

A source that cannot search gives the opposite line, and the framework filters the rows that it holds. You see that line from the stubborn source, which holds all its rows.

The UI adds one condition. A grid over a source that cannot search shows no search box. The UI hides the control instead of showing a control with a smaller scope than the user expects.

A grid has no search control

A grid component shows no search box, and this does not depend on the source. Use kind: binder for the full table controls.

A grid over a source that can search therefore offers less than the source can do.

Latency multiplies the cost

Capabilities control where the work happens. Latency controls what the work costs.

fast sourceslow source
can serve windowsscrolling is almost free; waste is invisibleeach unnecessary window is a visible delay
cannot serve windowsone large read, and it completes quicklyone large read, and the user waits for it

The count of requests is the same in both conditions. Measure the count, not the feeling.


What we covered

ItemWhat it does
capabilities: []Removes all operations except list. This gives the eager method.
the source lineNames what the source can do, and which load method follows
paged and eagerOne window for each screen, against one read at open
the sort lineSays whether the source sorts, or the framework sorts the rows that it holds
the search lineThe same, for filters. kind: grid hides the control.
row positions droppedA paged view that refetches instead of showing a sample as the set

What is next

Capabilities explain the requests that the application had to send. The next section finds the requests that it did not have to send, and adds a source that sends 19MB to fill a grid of five columns.

Fetching What You Drop

This section examines the requests that the application did not have to send. These are more difficult to find. A wasteful application and an efficient application look the same on screen.

There are three types of waste, from the easiest to find to the most difficult:

  1. Repeats. The application fetched the same window more than one time.
  2. Redundant rows. Rows arrived when the cache already held them.
  3. Columns that no view shows. Data crossed the network and then went in the bin.

The first two have counters. The third has one line.

The two counters that must be zero

Quit Vantage UI. The session summary prints:

people     summary  3 fetches, 0 repeats · 400 rows, 0 redundant · 9.1s waiting, 3.0s slowest
           summary  1 dio, 1 table scenery, 0 record, 0 servo still alive
           summary  session 18.0s · cpu 15.0s · peak rss 352MB

repeats counts the windows that the application requested more than one time. The loader is built to avoid repeats, so a number above zero is a defect. There are three causes:

  • the cache is not operating
  • a total declares rows that the source will not serve
  • a viewport moves between two positions

redundant counts the rows that arrived when the cache already held them. A cold run must give zero. A warm run gives a small number, and that is the cost of revalidation. If the redundant count equals the received count, the application fetched a full page for nothing.

Read the counters with the session, not alone

3 fetches has no meaning until you know that you scrolled through 400 rows of a table of 200,000.

Use the ratios: fetches for each screen of rows, and redundant rows for each received row. Record what you did together with what it cost.

Watch the cache do its work

22:42:19.028  people   viewport  rows 77..90 — 6 scroll events coalesced into one
22:42:19.029  people   cache     all 13 rows of 77..90 served locally — no fetch
22:42:19.516  people   viewport  rows 125..138 — 3 scroll events coalesced into one
22:42:19.516  people   cache     all 13 rows of 125..138 served locally — no fetch
22:42:19.858  people   viewport  rows 257..270 — 11 scroll events coalesced into one
22:42:19.859  people   dio       fetch #2 asks for rows 257..357 (viewport 257..270) — 100 missing

Two efficiencies are visible here.

The framework combines scroll events. Twenty scroll events produced three viewports. The loader waits for the movement to stop, and then declares one viewport for the final position. The intermediate positions never cause a fetch.

The loader reads ahead of the screen. The viewport of fetch #2 was 257..270, which is 14 rows. The request was for 257..357, which is 100 rows. The loader fetches extra rows in the direction of movement, so that the next screen costs nothing. This is why the two ranges differ.

Read-ahead also explains the fetch count in the summary above: 3 fetches covered 400 rows, which is more screens than three.

The waste with no counter

Now add a source with large rows. The grid shows five short columns. Each row carries 200 more fields that nobody declared.

# datasource/wide.yaml
type: faker
effect: static
debug: true
count: 500
seed: 111
shape:
  capabilities: [count, fetch_window, order]
  latency:
    window: 50ms..150ms
  extra_fields:
    count: 200       # 200 extra fields in each row
    size: 500        # each extra field holds 500 bytes

Open it and read one line:

22:06:34.148  wide  dio      fetch #1 got 200 rows in 2.3s  ⚠ slow
22:06:34.148  wide  payload  206 columns received · no view declared what it needs, so all were
                             fetched · 19.6MB · id,name,surname,email,city,balance,extra_0001,
                             extra_0002 +198 more

The application moved 19.6MB to fill a grid of five columns. The five columns are about 19KB of that total.

Nothing failed. No counter changed. repeats is zero, and redundant is zero. Each measurement in the session summary says that the application is correct.

This fault hides better than the others, because each other signal says that the application is correct. Only the payload line finds it.

“No view declared what it needs”

Diorama has a mechanism for this. A scenery can declare its demand, which is the list of columns that it shows. The Dio then combines the demands of the open views. When a view declares its demand, the line reports against that demand:

wide  payload  5 of 206 columns wanted · 201 fetched and dropped · 19.6MB

This grid declares nothing. The combined demand is therefore “all columns”. In Rust a view declares its demand when it opens, with TableSceneryBuilder::columns.

The framework cannot tell the difference between a view that wants all 206 columns and a view that never declared its columns. It assumes the first, and it fetches all the columns.

Demand controls the second pass, not the width of the first

Diorama can load a row in two passes. The first pass fetches the window. The second pass — the hydrate pass — fetches the augmented columns of the rows on screen.

Demand controls the second pass. A view that does not show an augmented column does not pay to hydrate it.

Demand does not yet decrease the columns in one window fetch. The 19.6MB above is therefore not a configuration error that you can correct in YAML. Use the payload line as a measurement, and use the size of the number as the reason to make the source return fewer columns.

What to do about a large payload

These actions are listed from the most effective to the least effective.

  • Decrease the columns at the source. Use a REST endpoint with a fields= parameter, a SQL view that selects the columns that you show, or a GraphQL query that names its columns. The cheapest byte is the byte that nobody sends.
  • Move the large columns into an augment. An augment: block on the table makes the large fields the second pass, which demand controls. See Augmentation for the block and its options.
  • Observe fewer tables. A page that observes two tables opens two Dios and pays for both.

A checklist for one page

Run the page cold, then warm. Read four numbers.

NumberWhereCorrect value
fetchessummaryfewer than one for each screen, because the loader reads ahead
repeatssummaryzero, always
redundantsummaryzero when cold; small when warm
payload for each fetchpayloadproportional to the columns that you show

If the first three numbers are correct and the fourth is not, you have the invisible fault.


What we covered

ItemWhat it does
repeatsWindows that the application fetched more than one time. Must be zero.
redundantRows that arrived when the cache held them. Zero when cold, small when warm.
coalescingScroll events that became one viewport, because the loader waits for the movement to stop
read-aheadThe extra rows that the loader fetches in the direction of movement
payloadThe columns that arrived, the columns that a view wants, and the cost in bytes
extra_fieldsThe faker setting that adds fields to each row
demand · hydrateThe columns that a view declares, and the second pass that they control

What is next

Each source until now answered its requests. The next section uses a source that fails one request in five, stops for ten seconds each minute, and reports a row count that is wrong.

Faults: Errors, Outages & Lies

Each source until now answered, and its answers were true. Production sources are not always honest. A source can fail one request while the user scrolls. It can stop for ten seconds. It can also report a row count that it will not serve.

Here the cache stops being an optimisation. It becomes the component that keeps your application usable.

A source with faults

# datasource/flaky.yaml
type: faker
effect: static
debug: true
count: 1000
seed: 107
shape:
  capabilities: [count, fetch_window, order]
  latency:
    window: 100ms..300ms
  faults:
    error_rate: 0.2      # one request in five fails
    offline: 10s/60s     # <down>/<period>: down for 10s in each 60s
    total_lie: -13       # reports 13 fewer rows than it will serve

There are three independent faults, and each one repeats because the shape has a seed.

What a failure looks like

22:08:05.813  flaky  dio  fetch #1 asks for rows 0..200 — 200 missing, 0 already held
22:08:05.978  flaky  dio  fetch #1 failed after 164ms — shaped source request failed (injected fault)

The request identifier connects the two lines. The duration tells you how long you waited for the failure. The message is the error from the source.

What is absent is more important. There is no state line, no cache line, and no total line.

A failed fetch changes nothing. The rows that the view already had are still present, still in their correct positions, and still on screen. A failed refresh costs freshness, not data.

The viewport causes the retry

Nothing retries on a timer, and this is deliberate. The rows are still absent. The next time that a view declares a viewport that includes those rows, the loader asks again. A scroll, a repaint, or a window that gets focus each cause this.

flaky  dio  fetch #2 asks for rows 0..200 — 200 missing, 0 already held
flaky  dio  fetch #2 got 200 rows in 210ms
flaky  cache +200 new, 0 updated — now holding 200 of 987 (20.3%)
flaky  total 987 rows — stated by the source in the same response

The retry is a result of demand. It is not a policy. It therefore needs no backoff configuration, and it cannot flood the source: if no view looks at a range, the loader sends no request for it.

The one failure that does flood the source

There is an exception, and the optimising project produced it.

A fetch can fail immediately and always — a rejected query, and not an unreliable connection. Each viewport movement then sends a request, each request fails immediately, and the application uses all available CPU.

During the writing of this guide, a faker datasource advertised order while the source refused every ordered query. Vantage UI appeared to stop responding.

The lesson is not about retry policy. A capability that a source advertises but cannot do is worse than a capability that it never claims, because nothing limits the rate of the failure.

The dishonest total

Read the two last lines above again. The table has 1,000 rows. The source says 987.

The grid uses the total from the source, because the source is the authority on how many rows it has. One condition overrides this rule, and the next paragraph gives it.

A total that is too small is the safe direction. The grid makes its scrollbar 13 rows short, and the user cannot reach the last rows.

A total that is too large is the dangerous direction. If a fetch asks for a range with missing rows, and it fills none of them, then the set ends at the first missing row, whatever the total says. The framework caps the total and reports the cap with the message “capped: the source promises more than it serves”.

Without this rule, the grid shows rows that no fetch can fill. A missing row causes a fetch. Therefore the application asks the source again, one request each few seconds, for as long as the page stays open.

Outages

offline: 10s/60s stops the source on a schedule. In the stream, an outage looks like a sequence of failures. There is no separate offline state to handle, no reconnect procedure, and no separate path in your code.

  • Cached rows continue on screen, and they do not change.
  • Ranges that the application has not fetched stay empty. The loader asks for them again when a view looks at them.
  • When the source returns, the next viewport succeeds and fills the empty rows.

The difficult condition is the first open with an empty cache during an outage. There is nothing to show and nothing to fetch. This is one more reason to measure the cold run and the warm run separately.

What to check under faults

QuestionWhere to look
Did a failure delete data?No cache line after the failed line, and rows still on screen
Did the retry behave correctly?One asks for line for each viewport movement, and not a sequence of them
Is the total correct?The total line, and whether the framework ever caps it
Did recovery need a restart?A got line after the failures, with no restart

An application that handles faults well produces a stream that looks normal: some failures, some retries when a view looks at the rows, and rows that never disappear.


What we covered

ItemWhat it does
faults.error_rateMakes a fraction of the requests fail
faults.offlineStops the source on a schedule, in the form <down>/<period>
faults.total_lieChanges the number that the grid uses to set its size
failed after …The request that did not complete, with its cost and the error from the source
capped: promises more than it servesThe defence against a total that no fetch can fill

What is next

The last section makes assertions from what you read. A number that becomes worse then makes a test fail, instead of nobody noticing.

Locking It In: Cache Regression Tests

The numbers that you measured are true of the code as it is today. This section answers a different question: what happens in six months, when somebody adds a refresh timer or makes a query wider, and nobody repeats the experiment?

Three measurements automate. The rest is judgement.

PropertyAssertionWhy it becomes worse without a signal
Requests for each openexactly N fetches for a known scrolla second component that observes the table, a refresh at open, or a second Dio
No repeatsrepeats == 0a total that is too large, or a viewport that moves between two positions
No redundant rowsredundant == 0 on a cold runa refresh that makes too much data invalid

Put everything else in a report that you read. Do not put it in a test. Latency, memory, and payload size change for correct reasons, such as a larger fixture or a slower machine. A test that fails on those numbers gets disabled in one month.

Assert on counts. Never assert on times.

3 fetches is a property of your code. 9.1 seconds is a property of the machine that ran the test. Put the first in CI. Put the second in a log that you read when something feels wrong.

What a test needs from the source

Assertions on exact counts need a source that you control. A real source gives different numbers on each run, so you cannot assert on them. You can still point the optimising project at a real source and compare the two reports by hand, as section 1 describes.

type: faker
effect: static
debug: true
count: 1000                   # a small set, so the fetch count is small
seed: 103                     # the same rows, in the same sequence, on each run
shape:
  capabilities: [count, fetch_window, order]
  latency:
    window: 20ms              # one value, not a range — no variation in CI

There are three changes from the interactive project.

  • count decreases from 200,000 to 1,000, so that a short scroll covers a known part of the set.
  • seed was always present, and now it is necessary. A test that asserts on row 0 needs row 0 to be the same row on each machine. A seed fixes the sequence of generated values, so the same seed with the same count gives the same rows. A different count gives a different set.
  • latency becomes one value instead of a range. Variable latency is realistic in a measurement session. In a test it causes intermittent failures and gives no benefit.

The structure of a test

  1. Start from a known cache state. Delete the cache file of the datasource for a cold run. For a warm run, run Vantage UI one time to fill the cache, then measure the second start.
  2. Do a known interaction. Open one page, declare one viewport, or scroll a fixed number of rows. Use --page=<key>, so that no other page adds requests to your count.
  3. Wait for the requests to stop. Do not wait for a fixed time. Poll the fetch count until it does not change for 500ms. A fixed sleep is slower than necessary, and it also fails on a busy CI machine.
  4. Assert on the session summary. Read the counters, compare them, and fail with a clear message.

How to read the numbers

# each fetch of the session
grep 'asks for' session.log | wc -l

# the summary, printed when Vantage UI quits
grep -A3 'session summary' session.log

In a Rust harness, you read the same numbers directly. The counters live in stats, behind vantage-diorama’s diagnostics feature:

#![allow(unused)]
fn main() {
use vantage_diorama::stats;

// Limit the measurement to this interaction, not to the whole process.
stats::reset_fetch_stats();

// ... drive the page ...

let stats = stats::fetch_stats();
// `table` is the name of the Dio, which is the master table of the datasource.
let people = stats.iter().find(|s| s.table == "people").expect("the table was read");
assert_eq!(people.repeats, 0, "the application fetched a window more than one time");
assert_eq!(people.rows_redundant, 0, "rows arrived that the cache already held");
}

The block is compiled but not run: // ... drive the page ... stands for your own interaction, and without one there is no people entry to find.

reset_fetch_stats makes the measurement local to one test. The counters are global to the process and they accumulate. A test that does not reset them measures each test before it.

To assert on the count of fetches, first measure the count for your interaction, then write that number into the test. The count depends on the read-ahead that section 4 describes, so you cannot calculate it from the number of screens.

The assertion that finds faults

If you write one assertion only, write this one:

#![allow(unused)]
fn main() {
use vantage_diorama::stats;
let stats = stats::fetch_stats();
let people = stats.iter().find(|s| s.table == "people").expect("the table was read");
assert_eq!(people.repeats, 0);
}

It is cheap, it does not fail intermittently, and it finds every repeated fetch.

It does not find a large payload. Section 4 shows a fault with 19.6MB of traffic, zero repeats, and zero redundant rows. Assert on the payload separately, or read the payload line.

Views that stay open

The second line of the session summary counts the views that are still alive. In Rust, ask the Dio itself. Dio::diagnostics is a snapshot of one Dio: its cache table, the rows it holds, and one entry for each open table view, with shared_by saying how many views read the same rows:

use vantage_diorama::{Cache, Lens};
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, ColumnFlag, Vista, VistaMetadata};
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let metadata = VistaMetadata::new()
    .with_column(Column::new("id", "string").with_flag(ColumnFlag::Id))
    .with_id_column("id");
let people = Vista::new("people", MockShell::new().with_metadata(metadata));
let lens = Lens::builder().cache(Cache::memory()).build()?;
let dio = lens.dio(people).build().await?;
let grid = dio.table_scenery().open().await?;

let diagnostics = dio.diagnostics().await?;
assert_eq!(diagnostics.sceneries.len(), 1, "one grid, one open view");
drop(grid);
Ok(())
}

Take the snapshot after the page closes, too. A view that outlives its page is the leak that the summary line reports. Reading the snapshot changes nothing, so a test can take as many as it needs.

The result

The result is not a faster application. The framework always did this work. The result is an application that you can measure.


What we covered

ItemWhat it does
seed: with one latency valueMakes a run repeatable, so that you can assert on it
--page=<key>Opens one page, so you count the requests of that page only
stats::reset_fetch_stats()Limits the counters to one interaction
stats::fetch_stats()Gives the counters in Rust, with no log to parse
dio.diagnostics().await?Lists the open views of one Dio, so a test can find a view that leaked
repeats == 0The one assertion to write, if you write one

Where to go next

If you want to measure your own source, point the optimising project at it. The method works with any datasource, and the faker runs stay useful as the control.

If you write a driver, Adding a New Persistence is where you select the capabilities to advertise. This chapter is where you find out whether your driver does what it advertised.

Adding a New Persistence

So you want to connect Vantage to a new database? This guide walks through the process in nine incremental steps — each one unlocks more framework features. You don’t have to implement all nine; stop whenever your persistence has enough capability for your use case.


Overview

StepWhat you buildWhat it unlocksCan skip?
1. Type Systemvantage_type_system! macro, AnyType, Record conversionsType-safe values, struct ↔ record mappingRequired
2. ExpressionsVendor macro, ExprDataSourceExecute raw queries, cross-database defer()Skip for simple read-only sources (CSV)
3. OperatorsVendor operation trait, typed .eq()/.gt()/.in_()Ergonomic typed conditions instead of raw expression macrosSkip if you don’t expose typed columns
4. Query BuilderSelectable, SelectableDataSourceComposable SELECT with conditions, ordering, limitsSkip if your persistence has no query language
5. Table & CRUDTableSource, entity tables, aggregates, writesTable<DB, Entity>, full CRUD, ReadableDataSet, WritableDataSetRequired for table support
6. Relationshipswith_one, with_many, correlated subqueriesReference traversal, expression fieldsSkip if you don’t need cross-table queries
7. Multi-Backendmodel crate, vista_factory().from_table(), CLI exampleType-erased tables, generic UI/API codeSkip if you only use one persistence
8. Vista<Driver>VistaFactory, <Driver>TableShell, YAML extrasYAML-defined data handles consumed by UI / scripting / agentsSkip if you don’t need Vista support
9. Contained Relationsembedded object/array columns as sub-tablesJSON/document sub-records surfaced as editable child setsSkip if you have no embedded collections

Step 1: Type System

Every database has its own idea of what types exist. The vantage_type_system! macro generates a type trait, variant enum, and type-erased AnyType wrapper that prevents silent casting between incompatible types.

You’ll implement the type trait for each Rust type your database supports, set up Record conversions (free via serde for JSON-based backends, or via #[entity] macro for custom value types), and add TryFrom<AnyType> for scalar extraction.

Read Step 1 →


Step 2: Expressions

With types in place, build a vendor macro (sqlite_expr!, surreal_expr!) that produces Expression<AnyType> with typed parameters. Implement ExprDataSource to execute expressions against your database — handling parameter binding, deferred cross-database resolution, and result parsing.

Skip this step if your persistence evaluates conditions in-memory (like CSV) — you can implement TableSource directly without an expression engine.

Read Step 2 →


Step 3: Operators

Build vendor-specific operation traits so typed columns get ergonomic .eq()/.gt()/.in_() methods that produce your backend’s native condition type. Blanket-implemented over all Expressive<T> so users don’t have to write raw expression macros for every condition.

Skip this step if you don’t expose typed columns to end users.

Read Step 3 →


Step 4: Query Builder

Build a SELECT struct implementing the Selectable trait — fields, conditions, ordering, limits, aggregates. Wire it up through SelectableDataSource so the rest of Vantage can create and execute queries through a standard interface.

Skip this step if your persistence doesn’t have a query language. MongoDB, for instance, skips Selectable and uses native BSON pipelines instead.

Read Step 4 →


Step 5: Table & CRUD

Implement TableSource to give Vantage full table abstraction — columns, conditions, ordering, pagination, entity CRUD, and aggregates. This is where Table<DB, Entity> comes alive and auto-implements ReadableDataSet, WritableDataSet, and ActiveEntitySet.

Start with todo!() for every method and implement them incrementally, driven by tests.

Read Step 5 →


Step 6: Relationships

Declare with_one and with_many relationships on tables and traverse them with get_ref_as. Implement column_table_values_expr for subquery-based traversal and optionally related_correlated_condition for correlated subqueries (expression fields like computed counts).

Skip this step if your persistence is flat (no foreign keys or cross-collection references).

Read Step 6 →


Step 7: Multi-Backend Applications

Define your entities and table constructors once in a model crate, then erase the backend type with db.vista_factory().from_table(table) so generic UI, CLI, and API code works identically across SurrealDB, SQLite, CSV, MongoDB, or your new persistence. (Type erasure used to live in the now-removed AnyTable; it moved up to Vista in 0.5.)

Read Step 7 →


Step 8: Vista

Wrap your typed Table<Driver, E> as a Vista — the universal, schema-bearing handle that UI, scripting, and agents consume through a CBOR / String boundary. You write a TableShell that executes reads, narrowing and writes, and a factory that builds it from a typed table or a YAML spec. The shell advertises only the capabilities it honours, and the vista contract test checks each one.

Skip this step if your driver is only consumed from typed Rust code.

Read Step 8 →


Step 9: Contained Relations

Surface an embedded object or array column (a JSON document, a Mongo sub-document) as an editable child sub-table. Declare it with with_contained_one / with_contained_many; reads project the embedded collection as records and writes patch it back into the host column. Your shell’s get_ref routes these relations to the shared Table::get_contained_ref, so native document backends and JSON-blob columns share the same path.

Skip this step if your persistence has no embedded collections.

Read Step 9 →

Step 1: Define Your Type System

Every database has its own idea of what types exist. SQLite has 5 storage classes (NULL, INTEGER, REAL, TEXT, BLOB). Postgres has dozens. SurrealDB has its own set with Things and Geometry types.

The vantage type system gives you two things:

  1. Type markers — so you can tell the difference between “this is an integer” and “this is text” even when both are stored as serde_json::Value under the hood.
  2. Safe extraction — try_get::<i64>() on a text value returns None instead of silently coercing. This prevents the kind of bugs where a string “42” gets treated as a number somewhere downstream.

Why not just use serde_json::Value directly?

You can! And for simple cases it works fine. The problem shows up when values move between contexts. A JSON number 42 could be an integer, a float, or even a boolean (SQLite stores bools as 0/1). Without type markers, you lose that distinction and get silent data corruption.

Setting it up

Use the vantage_type_system! macro. It generates a trait, an enum of variants, and a type-erased AnyType wrapper:

#![allow(unused)]
fn main() {
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &serde_json::Value) -> Option<Self> {
        use serde_json::Value;
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
}

This gives you SqliteType (trait), SqliteTypeVariants (enum), and AnySqliteType (the type-erased wrapper that remembers which variant a value belongs to).

The value_type is whatever your database driver naturally speaks. For SQL databases that’s usually serde_json::Value. SurrealDB uses ciborium::Value (CBOR). You could use any type — even String if your storage is that simple.

Then implement the trait for each Rust type. Here’s bool — SQLite stores it as 0/1:

#![allow(unused)]
fn main() {
use serde_json::Value;
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for bool {
    type Target = SqliteTypeIntegerMarker;  // bool lives in the Integer family

    fn to_json(&self) -> serde_json::Value {
        Value::Number(if *self { 1.into() } else { 0.into() })
    }

    fn from_json(value: serde_json::Value) -> Option<Self> {
        match value {
            Value::Number(n) => n.as_i64().map(|i| i != 0),
            Value::Bool(b) => Some(b),  // accept native bools too
            _ => None,
        }
    }
}
assert_eq!(AnySqliteType::new(true).try_get::<bool>(), Some(true));
}

And here’s how the type safety works in practice:

#![allow(unused)]
fn main() {
use serde_json::Value;
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
let val = AnySqliteType::new(42i64);
assert_eq!(val.try_get::<i64>(), Some(42));    // same variant → works
assert_eq!(val.try_get::<String>(), None);      // Integer ≠ Text → rejected

let val = AnySqliteType::new("hello".to_string());
assert_eq!(val.try_get::<String>(), Some("hello".to_string()));
assert_eq!(val.try_get::<i64>(), None);          // Text ≠ Integer → rejected
}

You also need From conversions so values can be created conveniently:

#![allow(unused)]
fn main() {
use serde_json::Value;
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
impl SqliteType for bool {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(i64::from(*self)) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64().map(|i| i != 0) }
}
impl From<i64> for AnySqliteType {
    fn from(value: i64) -> Self { AnySqliteType::new(value) }
}
impl From<&str> for AnySqliteType {
    fn from(value: &str) -> Self { AnySqliteType::new(value.to_string()) }
}
impl From<bool> for AnySqliteType {
    fn from(value: bool) -> Self { AnySqliteType::new(value) }
}
let val: AnySqliteType = 42i64.into();
let val: AnySqliteType = "hello".into();
let val: AnySqliteType = true.into();
assert_eq!(val.try_get::<bool>(), Some(true));
}

Records and struct conversion

A Record<V> is an ordered key-value map (field name → value). It’s how vantage represents a single row of data regardless of the backend. The question is: how do your structs become Records and vice versa?

There are two paths depending on your value_type.

Path A: serde_json::Value (the easy path)

If your type system uses serde_json::Value as the value type (like SQLite does), you get struct conversion for free. Vantage has blanket implementations of IntoRecord and TryFromRecord for any type that implements serde’s Serialize/Deserialize:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use vantage_types::{IntoRecord, Record, TryFromRecord, TryIntoRecord};
#[derive(Serialize, Deserialize)]
struct Product {
    name: String,
    price: i64,
    is_deleted: bool,
}

let product = Product { name: "Cupcake".into(), price: 120, is_deleted: false };

// Struct → Record<serde_json::Value> — automatic via serde
let record: Record<serde_json::Value> = product.try_into_record().unwrap();

// Record<serde_json::Value> → Struct — automatic via serde
let restored: Product = Product::from_record(record).unwrap();
}

This works because serde already knows how to turn structs into JSON objects and back. No extra code needed on your part.

Path B: Custom value type (the #[entity] path)

If your type system uses something other than serde_json::Value — like SurrealDB’s ciborium::Value — then serde’s blanket impls don’t apply. You need the #[entity] proc macro to generate the conversion code:

#![allow(unused)]
fn main() {
use vantage_surrealdb::AnySurrealType;
use vantage_types::entity;
#[entity(SurrealType)]
#[derive(Debug, Clone)]
struct Product {
    name: String,
    price: i64,
    is_deleted: bool,
}
}

The #[entity(SurrealType)] macro looks at each field, and generates:

  • IntoRecord<AnySurrealType> — calls AnySurrealType::new(self.name) for each field
  • TryFromRecord<AnySurrealType> — calls record["name"].try_get::<String>() for each field

This is where the type markers from Step 1 become critical. When you read a record back from the database, each value is an AnySurrealType with a variant tag. The try_get::<String>() call checks that the variant is Text before extracting. If someone stored an integer in a field that should be a string, you get an error instead of garbage.

You can even target multiple type systems at once:

#![allow(unused)]
fn main() {
use vantage_csv::AnyCsvType;
use vantage_surrealdb::AnySurrealType;
use vantage_types::entity;
#[entity(SurrealType, CsvType)]
struct Product {
    name: String,
    price: i64,
}
}

This generates conversions for both Record<AnySurrealType> and Record<AnyCsvType>, so the same struct works across different backends.

Testing Record conversions

Test Record<AnySqliteType> in both modes — typed (write path) and untyped (read path).

Typed records simulate what you’d build when inserting data. Values have variant tags, and try_get enforces them:

#![allow(unused)]
fn main() {
use serde_json::Value;
use vantage_types::{Record, vantage_type_system};
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
#[test]
fn test_typed_record() {
    let mut record: Record<AnySqliteType> = Record::new();
    record.insert("name".into(), AnySqliteType::new("Cupcake".to_string()));
    record.insert("price".into(), AnySqliteType::new(120i64));

    assert_eq!(record["name"].try_get::<String>(), Some("Cupcake".to_string()));
    assert_eq!(record["name"].try_get::<i64>(), None);  // Text ≠ Integer → blocked
    assert_eq!(record["price"].try_get::<i64>(), Some(120));
    assert_eq!(record["price"].try_get::<String>(), None);  // Integer ≠ Text → blocked
}
}

Untyped records simulate what comes back from the database. Values have type_variant: None, so try_get is permissive — it just attempts the conversion:

#![allow(unused)]
fn main() {
use serde_json::{Value, json};
use vantage_types::{Record, vantage_type_system};
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for f64 {
    type Target = SqliteTypeRealMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_f64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
impl AnySqliteType {
    fn untyped(value: Value) -> Self {
        Self { value, type_variant: None }
    }
}
#[test]
fn test_untyped_record() {
    let mut record: Record<AnySqliteType> = Record::new();
    record.insert("name".into(), AnySqliteType::untyped(json!("Cupcake")));
    record.insert("price".into(), AnySqliteType::untyped(json!(120)));

    assert_eq!(record["name"].try_get::<String>(), Some("Cupcake".to_string()));
    assert_eq!(record["name"].try_get::<i64>(), None);  // fails because "Cupcake" isn't a number
    assert_eq!(record["price"].try_get::<i64>(), Some(120));
    assert_eq!(record["price"].try_get::<f64>(), Some(120.0));  // permissive — json 120 can be f64
}
}

The key difference: a typed AnySqliteType::new(42i64) blocks try_get::<f64>() because Integer ≠ Real. An untyped AnySqliteType::untyped(json!(42)) allows it because there’s no variant to check — it just asks “can JSON number 42 be read as f64?”

Also test Option<T> fields, null handling, and missing fields.

TryFrom<AnyType> for common types

You also need TryFrom&lt;AnyType&gt; implementations for scalar types and Records. These are used later by AssociatedExpression::get() in Step 2, but they belong here because they’re part of the type system:

// Scalars — extract single value from single-row results
impl TryFrom<AnySqliteType> for i64 { ... }
impl TryFrom<AnySqliteType> for String { ... }
// etc.

// Records — extract first row from result array
impl TryFrom<AnySqliteType> for Record<AnySqliteType> { ... }
impl TryFrom<AnySqliteType> for Record<serde_json::Value> { ... }

For scalars, if the result is a single-row, single-column array like [{"COUNT(*)": 3}], extract the value automatically. For Records, extract the first row and wrap each field as an untyped AnyType.

Step 1 conclusion

At this point you should have:

  1. Type impls in src/<backend>/types/ — the vantage_type_system! macro call, trait implementations for each Rust type, From conversions on AnyType, variant detection in TypeVariants::from_*(), and TryFrom&lt;AnyType&gt; for scalars and Records.

  2. Tests in tests/<backend>/1_types_round_trip.rs covering:

    • In-memory AnyType round-trips for each supported type
    • Type mismatch rejections (wrong variant → None)
    • Struct ↔ Record conversions (including Option fields and error cases)
    • Values read from the actual database converting correctly

How type markers flow through the system

The AnyType wrapper has an Option<Variant> field — Some(Integer) means “I know this is an integer”, None means “I don’t know, just try whatever conversion you need.”

Writing (you → database): Values created with AnySqliteType::new(42i64) get type_variant: Some(Integer). The bind layer uses the variant to pick the right sqlx bind call — Integer binds as i64, Text as &str, Real as f64. No guessing.

Reading (database → you): Values coming back from the database are created with AnySqliteType::untyped(json_value) which sets type_variant: None. This means try_get::<i64>() won’t be blocked by a variant mismatch — it just attempts the conversion. The type checking happens later when you deserialize into a struct.

#![allow(unused)]
fn main() {
use serde_json::{Value, json};
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for f64 {
    type Target = SqliteTypeRealMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_f64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
impl SqliteType for bool {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(i64::from(*self)) }
    fn from_json(value: Value) -> Option<Self> {
        match value {
            Value::Number(n) => n.as_i64().map(|i| i != 0),
            Value::Bool(b) => Some(b),
            _ => None,
        }
    }
}
impl AnySqliteType {
    fn untyped(value: Value) -> Self {
        Self { value, type_variant: None }
    }
}
// Writing — typed, variant enforced
let val = AnySqliteType::new(true);            // type_variant: Some(Integer)
val.try_get::<bool>();   // Some(true) — variant matches
val.try_get::<i64>();    // Some(1) — bool lives in the Integer family
val.try_get::<String>(); // None — Integer ≠ Text, blocked

// Reading — untyped, permissive
let val = AnySqliteType::untyped(json!(1));    // type_variant: None
val.try_get::<i64>();    // Some(1) — no variant check, json 1 parses as i64
val.try_get::<bool>();   // Some(true) — no variant check, json 1 parses as bool (≠0)
val.try_get::<f64>();    // Some(1.0) — no variant check, json 1 parses as f64
val.try_get::<String>(); // None — json 1 can't parse as String
}

Both directions use Record<AnySqliteType>, but the values behave differently:

Writing:  Struct → Record<AnySqliteType> (typed) → bind_sqlite_value() → sqlx
Reading:  sqlx → Record<AnySqliteType> (untyped) → try_get / serde → Struct

Step 2: Make Expressions Work

With the type system in place, you can now use Expression<AnySqliteType> to build and execute queries. This step has two deliverables: a convenience macro and the ExprDataSource trait implementation.

The vendor macro

Define a macro that produces Expression<YourAnyType>. SurrealDB has surreal_expr!, we create sqlite_expr!:

#![allow(unused)]
fn main() {
use vantage_sql::sqlite_expr;
let expr = sqlite_expr!("SELECT * FROM product WHERE price > {}", 100i64);
}

Under the hood, 100i64 gets wrapped as AnySqliteType::new(100i64) with variant Integer. When this expression hits the database, the bind layer knows to call query.bind(100i64) — not query.bind("100") or query.bind(100.0).

The macro handles three kinds of parameters:

  • 42i64 → scalar with type marker
  • (sub_expr) → nested expression (composed into the template)
  • {deferred} → lazy evaluation (resolved at execution time)

Identifier quoting

SQL identifiers (table names, column names) need quoting to handle reserved words, spaces, and special characters. Different databases use different quote styles — PostgreSQL and SQLite use double quotes ("name"), MySQL uses backticks (`name`), SurrealDB uses something else entirely.

Vantage centralises this in the Identifier struct (vantage-sql/src/primitives/identifier.rs). Identifier is quote-agnostic — it stores the name parts and optional alias, but the actual quoting happens in the Expressive<T> implementation for each backend type:

impl Expressive<AnyMysqlType> for Identifier {
    fn expr(&self) -> Expression<AnyMysqlType> {
        Expression::new(self.render_with('`'), vec![])  // `name`
    }
}

When you add a new SQL backend, add an Expressive<YourAnyType> impl with your quote character. The compiler picks the right impl based on the expression type context.

In practice you use the ident() shorthand and pass it into the vendor macro with parentheses — the (...) syntax calls .expr() automatically, so quoting is handled by the type context:

#![allow(unused)]
fn main() {
use vantage_sql::{mysql_expr, postgres_expr};
use vantage_sql::primitives::identifier::{Identifier, ident};

// The (ident(...)) syntax invokes Expressive — quoting is automatic
let expr = mysql_expr!("SELECT {} FROM {} WHERE {} = {}",
    (ident("name")), (ident("product")), (ident("price")), 100i64);
// → SELECT `name` FROM `product` WHERE `price` = 100

let expr = postgres_expr!("SELECT {} FROM {} WHERE {} = {}",
    (ident("name")), (ident("product")), (ident("price")), 100i64);
// → SELECT "name" FROM "product" WHERE "price" = 100
}

For qualified identifiers (table.column) and aliases:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::identifier::{Identifier, ident};
use vantage_sql::{mysql_expr, sqlite_expr};
let expr = sqlite_expr!("SELECT {}", (ident("name").dot_of("u")));
// → SELECT "u"."name"

let expr = mysql_expr!("SELECT {}", (ident("name").with_alias("n")));
// → SELECT `name` AS `n`
}

Test identifier quoting in tests/<backend>/2_identifier.rs — cover basic names, reserved words, spaces, hyphens, unicode, and names that start with numbers. These are all legal inside quoted identifiers in both PostgreSQL and MySQL.

ExprDataSource

Implement DataSource (marker) and ExprDataSource<AnySqliteType> on your DB struct. The execute method takes an expression, flattens nested sub-expressions, converts {} placeholders to your driver’s syntax (?N for SQLite, $N for Postgres), binds parameters using type markers, and returns results.

Results come back as AnySqliteType with type_variant: None — the database doesn’t preserve our markers, so results are permissive (see Step 1). For SQLite that’s especially natural since it doesn’t distinguish boolean from integer on the wire.

If the persistence layer you’re implementing does preserve type information in responses (like SurrealDB with CBOR tags), set the correct type_variant when constructing result values in your execute() implementation. That way try_get enforces type boundaries on both sides of the round-trip.

Validating with INSERT expressions

The best way to test this is INSERT + SELECT round-trips. A single insert exercises all the pieces — macro, parameter binding, type markers, and result parsing:

use vantage_expressions::ExprDataSource;
use vantage_sql::sqlite::SqliteDB;
use vantage_sql::sqlite_expr;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.unwrap();
db.execute(&sqlite_expr!(
    "CREATE TABLE product (id TEXT PRIMARY KEY, name TEXT, price INTEGER, is_deleted BOOLEAN)"
))
.await?;
let insert = sqlite_expr!(
    "INSERT INTO product (id, name, price, is_deleted) VALUES ({}, {}, {}, {})",
    "cupcake", "Flux Cupcake", 120i64, false
);
db.execute(&insert).await?;

let select = sqlite_expr!("SELECT * FROM product WHERE id = {}", "cupcake");
let result = db.execute(&select).await?;
Ok(())
}

Nested expressions let you build multi-row inserts from composable parts:

use vantage_expressions::{ExprDataSource, Expression, ExpressiveEnum};
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.unwrap();
db.execute(&sqlite_expr!(
    "CREATE TABLE product (id TEXT PRIMARY KEY, name TEXT, price INTEGER, is_deleted BOOLEAN)"
))
.await?;
let row1 = sqlite_expr!("({}, {}, {}, {})", "tart", "Time Tart", 220i64, false);
let row2 = sqlite_expr!("({}, {}, {}, {})", "pie", "Sea Pie", 299i64, true);

// Expression::from_vec joins sub-expressions with a delimiter
let rows = Expression::from_vec(vec![row1, row2], ", ");

// Nest into the INSERT — flattener resolves everything into a single query
let insert = Expression::<AnySqliteType>::new(
    "INSERT INTO product (id, name, price, is_deleted) VALUES {}",
    vec![ExpressiveEnum::Nested(rows)],
);
db.execute(&insert).await?;
let count = db.execute(&sqlite_expr!("SELECT COUNT(*) FROM product")).await?;
assert_eq!(count.unwrap_scalar().try_get::<i64>(), Some(2));
Ok(())
}

The ExpressionFlattener collapses all nesting into one flat template with positional parameters — each one still carrying its type marker for correct binding.

Deferring: cross-database value resolution

Sometimes a query on one database needs a value from another database. That’s what defer() is for — it wraps a query as a closure that executes later, when the outer query runs.

This is not a subquery. The deferred query runs first, produces a concrete value, and that value gets bound as a regular parameter in the outer query.

use vantage_expressions::{ExprDataSource, Expression, ExpressiveEnum};
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
async fn setup() -> (SqliteDB, SqliteDB) {
    let config_db = SqliteDB::connect("sqlite::memory:").await.unwrap();
    for sql in [
        "CREATE TABLE config (key TEXT PRIMARY KEY, value INTEGER)",
        "INSERT INTO config VALUES ('min_price', 150)",
    ] {
        config_db.execute(&sqlite_expr!(sql)).await.unwrap();
    }
    let shop_db = SqliteDB::connect("sqlite::memory:").await.unwrap();
    for sql in [
        "CREATE TABLE product (name TEXT, price INTEGER)",
        "INSERT INTO product VALUES ('Cheap', 50), ('Tart', 220), ('Pie', 299)",
    ] {
        shop_db.execute(&sqlite_expr!(sql)).await.unwrap();
    }
    (config_db, shop_db)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let (config_db, shop_db) = setup().await;

// This doesn't execute yet — it's a closure
let threshold_query = sqlite_expr!("SELECT value FROM config WHERE key = {}", "min_price");
let deferred_threshold = config_db.defer(threshold_query);

// Use the deferred value as a parameter in a different database
let shop_query = Expression::<AnySqliteType>::new(
    "SELECT name FROM product WHERE price >= {} ORDER BY price",
    vec![ExpressiveEnum::Deferred(deferred_threshold)],
);

// When shop_db.execute() runs:
// 1. Resolves the deferred → calls config_db, gets 150
// 2. Replaces the Deferred param with Scalar(150)
// 3. Flattens and binds: SELECT name FROM product WHERE price >= ?1
let result = shop_db.execute(&shop_query).await?;
Ok(())
}

Your execute() implementation needs to resolve deferred parameters before flattening. Walk the parameter list, call .call().await on any Deferred, and leave Scalar and Nested untouched.

The resolved value comes back as an untyped AnySqliteType (no variant marker), so it gets bound via JSON-inference. For SQLite this is fine — the loose type system handles it. For stricter databases, you may want defer() to preserve type information from the source query’s result.

Reading query results

So far we’ve been calling db.execute(&expr).await which returns AnySqliteType. For a SELECT query, that value wraps a JSON array of row objects. To work with individual rows, you convert into Records:

use serde_json::Value as JsonValue;
use vantage_expressions::ExprDataSource;
use vantage_sql::sqlite::SqliteDB;
use vantage_sql::sqlite_expr;
use vantage_types::Record;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.unwrap();
for sql in [
    "CREATE TABLE product (id TEXT PRIMARY KEY, name TEXT, price INTEGER)",
    "INSERT INTO product VALUES ('a', 'Cheap', 50), ('b', 'Tart', 220), ('c', 'Pie', 299)",
] {
    db.execute(&sqlite_expr!(sql)).await?;
}
let result = db.execute(&sqlite_expr!("SELECT * FROM product")).await?;

// Result is AnySqliteType wrapping [{"id":"a","name":"Cheap","price":50}, ...]
// Convert to records manually:
let rows: Vec<ciborium::Value> = match result.into_value() {
    ciborium::Value::Array(arr) => arr,
    _ => panic!("expected rows"),
};
let record: Record<ciborium::Value> = rows[0].clone().into();
Ok(())
}

That works but it’s verbose. The TryFrom<AnyType> impls from Step 1 make this cleaner through AssociatedExpression. When you call db.associate::<R>(expr), you get an expression that knows its return type — .get() executes and converts in one step:

use serde_json::Value as JsonValue;
use vantage_expressions::ExprDataSource;
use vantage_sql::sqlite::SqliteDB;
use vantage_sql::sqlite_expr;
use vantage_types::Record;
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.unwrap();
for sql in [
    "CREATE TABLE product (id TEXT PRIMARY KEY, name TEXT, price INTEGER)",
    "INSERT INTO product VALUES ('a', 'Cheap', 50), ('b', 'Tart', 220), ('c', 'Pie', 299)",
] {
    db.execute(&sqlite_expr!(sql)).await?;
}
// Scalar — extracts single value from single-row result
let count = db.associate::<i64>(sqlite_expr!("SELECT COUNT(*) FROM product"));
assert_eq!(count.get().await?, 3);

// Record — extracts first row
let record: Record<JsonValue> = db
    .associate(sqlite_expr!("SELECT * FROM product WHERE id = {}", "c"))
    .get().await?;
assert_eq!(record["name"], "Pie");
Ok(())
}

From a Record, you can deserialize into a struct. For the #[entity] path:

use vantage_expressions::ExprDataSource;
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
use vantage_types::{Record, TryFromRecord, entity};
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.unwrap();
for sql in [
    "CREATE TABLE product (id TEXT PRIMARY KEY, name TEXT, price INTEGER)",
    "INSERT INTO product VALUES ('a', 'Cheap', 50), ('b', 'Tart', 220), ('c', 'Pie', 299)",
] {
    db.execute(&sqlite_expr!(sql)).await?;
}
#[entity(SqliteType)]
struct Product { id: String, name: String, price: i64 }

let record: Record<AnySqliteType> = db
    .associate(sqlite_expr!("SELECT * FROM product WHERE id = {}", "c"))
    .get().await?;
let product = Product::from_record(record)?;
assert_eq!(product.price, 299);
Ok(())
}

Or for the serde path with Record<JsonValue>:

use serde::Deserialize;
use serde_json::Value as JsonValue;
use vantage_expressions::ExprDataSource;
use vantage_sql::sqlite::SqliteDB;
use vantage_sql::sqlite_expr;
use vantage_types::{Record, TryFromRecord};
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite::memory:").await?;
for sql in [
    "CREATE TABLE product (id TEXT PRIMARY KEY, name TEXT, price INTEGER)",
    "INSERT INTO product VALUES ('a', 'Cheap', 50), ('b', 'Tart', 220), ('c', 'Pie', 299)",
] {
    db.execute(&sqlite_expr!(sql)).await?;
}
#[derive(Deserialize)]
struct Product { id: String, name: String, price: i64 }

let record: Record<JsonValue> = db
    .associate(sqlite_expr!("SELECT * FROM product WHERE id = {}", "c"))
    .get().await?;
let product: Product = Product::from_record(record)?;
assert_eq!(product.name, "Pie");
Ok(())
}

Testing the failure modes (missing fields, NULL into required field, wrong types) can help spot issues in your implementation.

Step 2 conclusion

At this point you should have:

  1. A vendor macro (sqlite_expr!, surreal_expr!, etc.) that produces Expression<AnyType> with typed parameters.

  2. Trait impls in src/<backend>/impls/ — DataSource (marker) and ExprDataSource<AnyType> with execute() and defer().

  3. Tests in tests/<backend>/2_*.rs covering:

    • INSERT with typed parameters, read back and verify
    • Multi-row INSERT using nested expressions and from_vec
    • Type marker verification (bool binds as bool, not as string “true”)
    • Cross-database deferred value resolution
    • AssociatedExpression with scalar, Record, and entity results
    • Identifier quoting: basic names, reserved words, spaces, hyphens, unicode

Step 3: Implement Operators

Expressions let you build raw queries, but users shouldn’t have to write sqlite_expr!("{} > {}", (ident("price")), 100i64) every time they want a condition. Operators give typed columns ergonomic methods like .eq(), .gt(), .in_() that produce your backend’s native condition type.

This step covers how to implement a vendor-specific operation trait for your persistence.

Vendor-specific operation traits

Each persistence defines its own operation trait that returns the backend’s condition type directly. The trait is blanket-implemented for all Expressive<T> where T: Into<AnyBackendType>, so typed columns get the methods for free.

For SQL backends, a macro generates the trait:

// In vantage-sql/src/sqlite/operation.rs
define_sql_operation!(
    SqliteOperation,
    SqliteCondition,
    crate::sqlite::types::AnySqliteType
);

This produces:

  • A trait SqliteOperation<T> with .eq(), .gt(), .lt(), .ne(), .gte(), .lte(), .in_(), .in_list(), .cast() — all returning SqliteCondition
  • A blanket impl for all Expressive<T> where T: Into<AnySqliteType>
  • An Expressive<AnySqliteType> impl for SqliteCondition, enabling chaining

How it works internally

Each method builds an Expression<T> from the two operands, then converts it to the backend’s condition type via From<Expression<T>>:

fn gt(&self, value: impl Expressive<T>) -> SqliteCondition {
    let expr: Expression<T> = Expression::new("{} > {}", vec![
        ExpressiveEnum::Nested(self.expr()),
        ExpressiveEnum::Nested(value.expr()),
    ]);
    SqliteCondition::from(expr)  // maps T → AnySqliteType via Into
}

The From<Expression<F>> for SqliteCondition impl (from Step 1’s define_sql_condition! macro) handles the type mapping — it calls ExpressionMap::map() to convert all F scalars into AnySqliteType.

Chaining across type boundaries

Because SqliteCondition implements Expressive<AnySqliteType>, the blanket gives it SqliteOperation<AnySqliteType>. This enables:

#![allow(unused)]
fn main() {
use vantage_sql::sqlite::operation::SqliteOperation;
use vantage_table::column::core::Column;
let price = Column::<i64>::new("price");
price.gt(10).eq(false)
// => SqliteCondition wrapping: (price > 10) = 0
;
}

The first operation (.gt(10)) enforces type safety — 10 must be Expressive<i64>. The second operation (.eq(false)) operates on SqliteCondition where bool: Expressive<AnySqliteType>, so any backend-compatible type is accepted.

Implementing for a non-SQL backend

For backends that don’t use expression trees for conditions (like MongoDB), you implement the operation trait manually instead of using the macro. MongoDB produces BSON documents:

pub trait MongoOperation<T>: Expressive<T> {
    fn eq(&self, value: impl Into<AnyMongoType>) -> MongoCondition {
        let field = self.expr().template.clone();
        let bson_val = AnyMongoType::from(value).to_bson();
        MongoCondition::Doc(doc! { field: { "$eq": bson_val } })
    }

    fn gt(&self, value: impl Into<AnyMongoType>) -> MongoCondition {
        let field = self.expr().template.clone();
        let bson_val = AnyMongoType::from(value).to_bson();
        MongoCondition::Doc(doc! { field: { "$gt": bson_val } })
    }
    // ...
}

impl<T, S: Expressive<T>> MongoOperation<T> for S {}

Key differences from SQL:

  • Values use Into<AnyMongoType> not Expressive<T> — MongoDB doesn’t compose expression trees, it builds BSON documents from scalar values.
  • Field name extraction — self.expr().template gives the column name for simple columns. Complex expressions produce the template string as the field path.
  • Chaining — MongoCondition implements Expressive<AnyMongoType> for the blanket, but boolean chaining (.eq(false) = negate) is handled via dedicated methods like .eq_bool(false) since MongoDB negation uses $not wrappers.

Avoiding method name conflicts

When multiple backend features are enabled, types like Identifier and &str implement Expressive<T> for multiple backends. This causes ambiguity if the operation trait is generic.

Each backend’s operation trait lives in its own module (e.g. sqlite::operation::SqliteOperation). Users import only the trait they need:

mod sqlite {
    pub mod operation {
        pub use vantage_sql::sqlite::operation::SqliteOperation;
    }
}
// In your prelude:
pub use crate::sqlite::operation::SqliteOperation;
fn main() {}

Condition type requirements

Your condition type must satisfy TableSource::Condition bounds — Clone + Send + Sync + 'static. It also needs:

  • From<Expression<F>> for any F: Into<AnyType> — so typed column operations convert cleanly
  • From<Identifier> — so ident("field") works with with_condition()
  • Expressive<AnyType> — so the condition can be chained with further operations
  • Any backend-specific conversions (e.g. From<Document> for MongoDB)

For SQL backends, the define_sql_condition! macro generates all of these.

Step 2b checklist

  1. Define your operation trait — either via define_sql_operation! (SQL) or manually (document-oriented backends).

  2. Blanket-implement it for all Expressive<T> where T converts into your AnyType.

  3. Implement Expressive<AnyType> for your condition type — enables chaining.

  4. Export from your prelude — so users get the operation trait automatically.

  5. Tests covering:

    • Typed column operations: Column::<i64>::new("price").gt(150) → condition
    • Boolean column: Column::<bool>::new("active").eq(false) → condition
    • Chaining: price.gt(10).eq(false) compiles and produces correct output
    • Cross-type rejection: price.gt(false) does NOT compile
    • Same-type column comparison: price.eq(price.clone()) works
    • Condition usable with table.add_condition() and select.with_condition()

Step 4: Statement Builders and SelectableDataSource

In practice, nobody writes raw expressions for every query. This step adds the Selectable trait implementation for your SELECT builder and wires it up through SelectableDataSource so the rest of vantage can create and execute queries through a standard interface.

Implement Selectable for your SELECT builder

The Selectable<T> trait is the standard interface for building SELECT queries across all vantage backends. Your SELECT struct needs to implement it. The trait has two kinds of methods:

Mutating methods you must implement — set_source, add_field, add_where_condition, add_order_by, add_group_by, set_limit, set_distinct, the clear_* methods, the has_* methods, as_count, and as_sum.

Builder methods you get for free — with_source, with_field, with_condition, with_order, with_expression, with_limit. These are default implementations that call the mutating methods and return self.

This means your builder code is just the struct definition and new():

pub struct SqliteSelect {
    pub fields: Vec<Expr>,
    pub from: Vec<Expr>,
    pub where_conditions: Vec<Expr>,
    pub order_by: Vec<(Expr, bool)>,
    pub group_by: Vec<Expr>,
    pub distinct: bool,
    pub limit: Option<i64>,
    pub skip: Option<i64>,
}

impl SqliteSelect {
    pub fn new() -> Self { /* initialize empty */ }
}

The Selectable impl goes in its own file (e.g., statements/select/impls/selectable.rs) and the builder methods come from the trait:

#![allow(unused)]
fn main() {
use vantage_expressions::{Order, Selectable};
use vantage_sql::sqlite::statements::SqliteSelect;
use vantage_sql::sqlite_expr;
let select = SqliteSelect::new()
    .with_source("product")
    .with_field("name")
    .with_field("price")
    .with_condition(sqlite_expr!("\"is_deleted\" = {}", false))
    .with_order(sqlite_expr!("\"price\""), Order::Desc)
    .with_limit(Some(10), None);
}

The as_count() and as_sum() methods should clone the current query, replace the fields with COUNT(*) or SUM(column), drop the ORDER BY (unnecessary for aggregates), and render:

#![allow(unused)]
fn main() {
use vantage_expressions::{Order, Selectable};
use vantage_sql::sqlite::statements::SqliteSelect;
use vantage_sql::sqlite_expr;
let select = SqliteSelect::new()
    .with_source("product")
    .with_condition(sqlite_expr!("\"is_deleted\" = {}", false))
    .with_order(sqlite_expr!("\"price\""), Order::Desc);
let count_expr = select.as_count();  // SELECT COUNT(*) FROM product WHERE ...
let sum_expr = select.as_sum(sqlite_expr!("\"price\""));  // SELECT SUM("price") FROM ...
}

Implement SelectableDataSource

This trait connects the SELECT builder to execution. It tells vantage “this database can create SELECT queries and run them”:

impl SelectableDataSource<AnySqliteType> for SqliteDB {
    type Select = SqliteSelect;

    fn select(&self) -> Self::Select {
        SqliteSelect::new()
    }

    async fn execute_select(&self, select: &Self::Select) -> Result<Vec<AnySqliteType>> {
        // delegate to ExprDataSource::execute()
    }
}

Live tests

Up to now, most tests used in-memory databases created in setup(). For Step 3, start running tests against a real pre-populated database. This catches issues that in-memory tests miss — schema mismatches, type affinity surprises, data edge cases.

Set up a test database with known data (we use a bakery schema translated from SurrealDB’s test fixture), and write tests that query it through the Selectable interface:

use vantage_expressions::{ExprDataSource, Expressive, Order, Selectable};
use vantage_sql::sqlite::statements::SqliteSelect;
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
use vantage_types::{Record, TryFromRecord, entity};
#[entity(SqliteType)]
struct Product { name: String, price: i64 }
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite:../target/bakery.sqlite?mode=ro").await?;

let select = SqliteSelect::new()
    .with_source("product")
    .with_condition(sqlite_expr!("\"price\" > {}", 200i64))
    .with_order(sqlite_expr!("\"price\""), Order::Desc);

let record: Record<AnySqliteType> = db.associate(select.expr()).get().await?;
let product = Product::from_record(record)?;
assert_eq!(product.name, "Enchantment Under the Sea Pie");
Ok(())
}

Implementing complex queries

The Selectable interface gives you the bare bones — fields, conditions, ordering, limits, aggregates. Your database can do much more: JOINs, subqueries, CTEs, window functions, HAVING, UNION, JSON operators, CASE expressions.

The best way to build this out is incrementally, driven by real queries:

  1. Create a test database scaffold with enough tables and data to exercise complex features. Foreign keys, self-referential hierarchies, many-to-many junctions, JSON columns, generated columns — the more variety, the better. (See scripts/sqlite/db/v3.sql for an example.)

  2. Write the queries first in raw SQL, as comments in your test file. Start with queries you know work against your scaffold. Each query should target specific features (JOINs, GROUP BY + HAVING, window functions, etc.).

  3. Implement one query at a time. For each query:

    • Read the SQL and identify which builder methods are missing
    • Add the methods to your SELECT struct (e.g., add_join, add_having, with_cte)
    • Write a render test that checks the generated SQL matches
    • Write a live test that executes against the scaffold and verifies results

This approach has two advantages. First, you don’t over-design — you only add features you actually need. Second, every new feature ships with a test that proves it works against a real database, not just string comparison.

When your queries outgrow what the select struct offers directly, extract primitives and nested structs rather than bloating the builder. For example, Identifier (in vantage-sql/src/primitives/) handles qualified column names ("u"."name") and aliases — it implements Expressive<T> so it plugs straight into expressions. Similarly, SqliteSelectJoin lives inside the select module and renders its own INNER JOIN ... ON ... clause. The select struct just holds a Vec<SqliteSelectJoin> and calls render() on each one. This pattern — small struct with Expressive impl, composed into the builder — scales to CASE, CTE, window specs, and anything else without the select struct growing unbounded.

Other statements

Selectable only covers SELECT. INSERT, UPDATE, and DELETE don’t need a trait at this stage — they just need to implement Expressive<AnySqliteType> so they can be passed to ExprDataSource::execute(). The statement builders from earlier steps still work, they just need their expression type migrated from JsonValue to AnySqliteType to flow directly into execute().

Step 3 conclusion

At this point you should have:

  1. Selectable<AnyType> impl for your SELECT builder — all standard methods implemented, builder pattern provided by trait defaults.

  2. SelectableDataSource<AnyType> impl for your DB struct — select() and execute_select().

  3. Tests in tests/<backend>/3_*.rs covering:

    • SQL rendering via preview() — fields, conditions, ordering, limits, distinct, group by
    • as_count() and as_sum() render correctly
    • Live execution against a test database — SELECT, COUNT, SUM, ORDER+LIMIT
    • Entity deserialization from live query results

Step 5: Table Abstraction and Entity CRUD

The same entities get used hundreds of times across a codebase — constructing a query from scratch every single time is tedious and error-prone. Vantage offers Table<> as an abstraction over your entity definitions: it knows the table name, the columns, their types, and the ID field, so it can build queries for you.

To use your persistence backend as a table source, you need to implement the TableSource trait. Most of the heavy-lifting is done by the vantage-table crate — your job is to implement TableSource trait methods.

Implement TableSource with placeholder methods

Start by adding the required dependencies:

# in your backend's Cargo.toml
vantage-table = { path = "../vantage-table" }
async-trait = "0.1"

Create a new test file (e.g. tests/<backend>/4_table_def.rs) that defines a table and populates its columns. The columns rely on the type system you built in Step 1.

The TableSource implementation also declares several associated types:

  • Column — the Column type supplied by vantage-table is good enough for most backends.
  • AnyType and Value — your type-erased value type from Step 1 (e.g. AnySqliteType).
  • Id — use String for SQL databases, or a custom type if your IDs have special structure (e.g. SurrealDB’s Thing which encodes table:id). Whatever you pick must be covered by your type system.
use async_trait::async_trait;
use vantage_table::column::core::{Column, ColumnType};
use vantage_table::traits::table_source::TableSource;

#[async_trait]
impl TableSource for SqliteDB {
    type Column<Type> = Column<Type> where Type: ColumnType;
    type AnyType = AnySqliteType;
    type Value = AnySqliteType;
    type Id = String;
    // ...
}

Implement the following methods first — they’re all straightforward delegations:

  • Column management — create_column, to_any_column, convert_any_column:
    fn create_column<Type: ColumnType>(&self, name: &str) -> Self::Column<Type> {
        Column::new(name)
    }

    fn to_any_column<Type: ColumnType>(
        &self,
        column: Self::Column<Type>,
    ) -> Self::Column<Self::AnyType> {
        Column::from_column(column)
    }

    fn convert_any_column<Type: ColumnType>(
        &self,
        any_column: Self::Column<Self::AnyType>,
    ) -> Option<Self::Column<Type>> {
        Some(Column::from_column(any_column))
    }
  • Expression factory — expr():
    fn expr(
        &self,
        template: impl Into<String>,
        parameters: Vec<ExpressiveEnum<Self::Value>>,
    ) -> Expression<Self::Value> {
        Expression::new(template, parameters)
    }

Every other method — should start as todo!(). You’ll implement them incrementally in the following sections, driven by tests.

Define entity tables

With TableSource in place, define your entity structs and table constructors. The pattern is the same across all backends — #[entity(YourType)] for the struct, plus a builder method that returns Table<YourDB, Entity>:

use vantage_sql::sqlite::{SqliteType, SqliteDB, AnySqliteType};
use vantage_table::table::Table;
use vantage_types::entity;

#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
struct Product {
    name: String,
    calories: i64,
    price: i64,
    bakery_id: String,
    is_deleted: bool,
    inventory_stock: i64,
}

impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("calories")
            .with_column_of::<i64>("price")
            .with_column_of::<String>("bakery_id")
            .with_column_of::<bool>("is_deleted")
            .with_column_of::<i64>("inventory_stock")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let table = Product::sqlite_table(SqliteDB::connect("sqlite::memory:").await?);
assert_eq!(table.columns().len(), 7);
Ok(())
}

Note that the entity struct does not include the id field — that’s handled separately by with_id_column(), which registers the column and sets the table’s ID field. The remaining columns are added with with_column_of::<Type>(), which creates typed columns via your TableSource::create_column implementation.

Verify with a query generation test

Your first test should build a table, then call table.select(). Just like the Step 3 tests, you can use preview() to check the rendered SQL, and later execute it against a real database:

#![allow(unused)]
fn main() {
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::table::Table;
use vantage_types::entity;
#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
struct Product {
    name: String,
    calories: i64,
    price: i64,
    bakery_id: String,
    is_deleted: bool,
    inventory_stock: i64,
}
impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("calories")
            .with_column_of::<i64>("price")
            .with_column_of::<String>("bakery_id")
            .with_column_of::<bool>("is_deleted")
            .with_column_of::<i64>("inventory_stock")
    }
}
#[tokio::test]
async fn test_product_select() {
    let db = SqliteDB::connect("sqlite::memory:").await.unwrap();
    let table = Product::sqlite_table(db);
    let select = table.select();
    assert_eq!(
        select.preview(),
        "SELECT \"id\", \"name\", \"calories\", \"price\", \
         \"bakery_id\", \"is_deleted\", \"inventory_stock\" FROM \"product\""
    );
}
}

This works because table.select() (provided by vantage-table) calls your SelectableDataSource::select() to get a fresh SELECT builder, then applies the table name via set_source() and adds each registered column via add_field(). None of the todo!() methods are hit — only the column and expression infrastructure you already implemented.

Implement the read methods

Table<T, E> implements two traits from vantage-dataset that provide read access:

  • ReadableValueSet — returns raw Record<Value> (untyped storage values):

    • list_values() → all records as IndexMap<Id, Record<Value>>
    • get_value(id) → Option<Record<Value>> — None if no record matches the id
    • get_some_value() → one arbitrary record (or None if empty)
  • ReadableDataSet<E> — returns deserialized entities (calls E::try_from_record() for you):

    • list() → all entities as IndexMap<Id, E>
    • get(id) → Option<E> — None if no entity matches the id
    • get_some() → one arbitrary entity

Both traits delegate to three TableSource methods: list_table_values, get_table_value, and get_table_some_value. The pattern is the same for all three:

  1. Get the id field name from table.id_field() (falls back to "id")
  2. Build a SELECT using table.select() (which already applies columns, conditions, ordering)
  3. Execute via self.execute(&select.expr())
  4. Parse the result — split each row into an ID and a Record

For get_table_value, add a WHERE condition on the id field and return Ok(None) when the lookup misses — errors are reserved for actual connection or parse failures. For get_table_some_value, set LIMIT 1 and return the first row (or None if empty).

Write tests for both ReadableValueSet and ReadableDataSet in separate files — import the traits from vantage_dataset and call list_values(), get_value(), get_some_value(), list(), get(), get_some() against your pre-populated test database. Keep these tests condition-free — conditions get their own test file next.

Error handling

All TableSource methods return vantage_core::Result<T> (an alias for Result<T, VantageError>). Use the error! macro from vantage_core to create errors with structured context:

use vantage_core::error;

// Simple error message
return Err(error!("expected array result"));

// With key = value context (NOT format args — the macro uses a different syntax)
return Err(error!("row missing id field", field = id_field_name));

// For database-specific errors, convert them with map_err
let rows = query.fetch_all(self.pool()).await
    .map_err(|e| error!("SQLite query failed", details = e.to_string()))?;

The macro automatically captures file, line, and column. The key = value pairs are stored as structured context, not interpolated into the message string.

To wrap external errors with additional context, use the Context trait:

use vantage_core::error;
fn main() -> vantage_core::Result<()> {
use vantage_core::Context;

// Wraps the original error as the "source" of a new VantageError
let data = std::fs::read("config.json")
    .context(error!("failed to load config"))?;
Ok(())
}

This chains errors — the original io::Error is preserved as the source, so Display renders both messages and the source chain is available via std::error::Error::source().

Operation trait — condition building

Each backend provides an operation trait (e.g. SqliteOperation) with .eq(), .ne(), .gt(), .gte(), .lt(), .lte(), and .in_() methods for building conditions. It has a blanket implementation for all Expressive<T> types, so your columns get these methods automatically — no explicit impl needed.

All methods accept impl Expressive<YourAnyType>, so you can pass native Rust values (false, 42, "hello"), other columns (table["other_field"]), or full expressions. This requires your scalar types to implement Expressive<YourAnyType> — the same impls you added in Step 1 for the vendor macro.

Testing conditions

Table carries conditions set via add_condition(), and table.select() applies them automatically as WHERE clauses. Test a few patterns:

  • Custom expression — pass columns as expression arguments via table["field"]:
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
use vantage_table::table::Table;
use vantage_types::entity;
#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
struct Product {
    name: String,
    calories: i64,
    price: i64,
    bakery_id: String,
    is_deleted: bool,
    inventory_stock: i64,
}
impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("calories")
            .with_column_of::<i64>("price")
            .with_column_of::<String>("bakery_id")
            .with_column_of::<bool>("is_deleted")
            .with_column_of::<i64>("inventory_stock")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite::memory:").await?;
let mut table = Product::sqlite_table(db);
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), 130));
Ok(())
}
  • Multiple conditions — combined with AND, including field-to-field comparison:
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
use vantage_table::table::Table;
use vantage_types::entity;
#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
struct Product {
    name: String,
    calories: i64,
    price: i64,
    bakery_id: String,
    is_deleted: bool,
    inventory_stock: i64,
}
impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("calories")
            .with_column_of::<i64>("price")
            .with_column_of::<String>("bakery_id")
            .with_column_of::<bool>("is_deleted")
            .with_column_of::<i64>("inventory_stock")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite::memory:").await?;
let mut table = Product::sqlite_table(db);
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), 130));
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), (table["calories"])));
Ok(())
}
  • SqliteOperation::eq() — the idiomatic way:
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::table::Table;
use vantage_types::entity;
#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
struct Product {
    name: String,
    calories: i64,
    price: i64,
    bakery_id: String,
    is_deleted: bool,
    inventory_stock: i64,
}
impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("calories")
            .with_column_of::<i64>("price")
            .with_column_of::<String>("bakery_id")
            .with_column_of::<bool>("is_deleted")
            .with_column_of::<i64>("inventory_stock")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite::memory:").await?;
use vantage_sql::sqlite::operation::SqliteOperation;

let mut table = Product::sqlite_table(db);
table.add_condition(table["is_deleted"].eq(false));
Ok(())
}

Implement aggregates

Implement get_table_count, get_table_sum, get_table_max, and get_table_min in your TableSource. These build aggregate queries from table.select() and extract the scalar result. Once implemented, Table exposes shorter get_count, get_sum, get_max, get_min methods directly:

use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::table::Table;
use vantage_types::entity;
#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
struct Product {
    name: String,
    calories: i64,
    price: i64,
    bakery_id: String,
    is_deleted: bool,
    inventory_stock: i64,
}
impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("calories")
            .with_column_of::<i64>("price")
            .with_column_of::<String>("bakery_id")
            .with_column_of::<bool>("is_deleted")
            .with_column_of::<i64>("inventory_stock")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite:../target/bakery.sqlite?mode=ro").await?;
let table = Product::sqlite_table(db);
assert_eq!(table.get_count().await.unwrap(), 5);
assert_eq!(table.get_max(&table["price"]).await.unwrap().try_get::<i64>().unwrap(), 299);
Ok(())
}

Implement write operations

Table also implements WritableDataSet (insert, replace, patch, delete) and InsertableDataSet (insert with auto-generated ID). These delegate to six TableSource methods:

  • insert_table_value — INSERT with a known ID. Build an SqliteInsert with the id field and record fields, execute, then read back via get_table_value.
  • replace_table_value — full replacement. For SQLite, use INSERT OR REPLACE INTO.
  • patch_table_value — partial update. Build an SqliteUpdate with only the provided fields and a WHERE condition on the id field.
  • delete_table_value — DELETE with a WHERE condition on the id field.
  • delete_table_all_values — DELETE without conditions.
  • insert_table_return_id_value — INSERT without a known ID (auto-increment). Use RETURNING "id" to get the generated ID back from the database.

Test both WritableValueSet (raw records, no entity) and WritableDataSet (typed entities) using in-memory SQLite:

use vantage_dataset::{InsertableDataSet, ReadableDataSet, WritableDataSet, WritableValueSet};
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::table::Table;
use vantage_types::{Record, entity};
#[entity(SqliteType)]
#[derive(Debug, Clone)]
struct Item {
    name: String,
    price: i64,
}
fn record(pairs: &[(&str, AnySqliteType)]) -> Record<AnySqliteType> {
    pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite::memory:").await?;
let table = Table::<SqliteDB, Item>::new("item", db)
    .with_id_column("id")
    .with_column_of::<String>("name")
    .with_column_of::<i64>("price");
// WritableValueSet — no entity needed
let rec = record(&[("name", "Gamma".into()), ("price", 30i64.into())]);
table.insert_value(&"c".to_string(), &rec).await.unwrap();

// WritableDataSet — typed entities
let item = Item { name: "Gamma".into(), price: 30 };
table.insert(&"c".to_string(), &item).await.unwrap();

// InsertableDataSet — auto-generated ID
let id = table.insert_return_id(&item).await.unwrap();
let fetched = table.get(id).await.unwrap();
Ok(())
}

Step 6: Relationships

Tables can declare relationships using with_one and with_many, then traverse them at runtime with get_ref_as. The relationship system is provided by vantage-table — your backend just needs column_table_values_expr implemented to make it work.

Tip

This step covers what a driver must implement for relationships. For the consumer side — traversing from a loaded record with get_ref, and the foreign-key invariants traversal sets up — see Records: Traversal, Invariants & Hooks.

Implement column_table_values_expr — it builds a subquery for a single column respecting current conditions. For SQL backends this is a SELECT "col" FROM "table" WHERE ... expression.

Define relationships when constructing tables — with_one for foreign-key-to-parent, with_many for parent-to-children. Then traverse:

use vantage_dataset::ReadableDataSet;
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_sql::sqlite_expr;
use vantage_table::table::Table;
use vantage_types::{EmptyEntity, entity};
#[entity(SqliteType)]
#[derive(Debug, Clone)]
struct ClientOrder {
    client_id: String,
}
fn client_order_table(db: SqliteDB) -> Table<SqliteDB, ClientOrder> {
    Table::new("client_order", db)
        .with_id_column("id")
        .with_column_of::<String>("client_id")
}
fn client_table(db: SqliteDB) -> Table<SqliteDB, EmptyEntity> {
    Table::new("client", db)
        .with_id_column("id")
        .with_column_of::<bool>("is_paying_client")
        .with_many("orders", "client_id", client_order_table)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite:../target/bakery.sqlite?mode=ro").await?;
let mut clients = client_table(db);
clients.add_condition(sqlite_expr!("{} = {}", (clients["is_paying_client"]), true));

let orders = clients.get_ref_as::<ClientOrder>("orders").unwrap();

// The generated query includes the subquery:
// SELECT ... FROM "client_order"
//   WHERE client_id IN (SELECT "id" FROM "client" WHERE is_paying_client = 1)
assert_eq!(orders.list().await.unwrap().len(), 3);
Ok(())
}

with_expression adds computed fields to a table using correlated subqueries. It pairs with get_subquery_as which produces target.fk = source.id conditions (vs get_ref_as which uses IN (subquery)).

.with_many("orders", "client_id", Order::sqlite_table)
.with_expression("order_count", |t| {
    t.get_subquery_as::<Order>("orders").unwrap().get_count_query()
})
// Generates: (SELECT COUNT(*) FROM "client_order"
//   WHERE "client_order"."client_id" = "client"."id") AS "order_count"

What to implement: override related_correlated_condition in your TableSource to produce table-qualified equality. Default panics — backends without correlated subquery support (CSV) simply can’t use this feature.

fn related_correlated_condition(&self, target_table: &str, target_field: &str,
    source_table: &str, source_column: &str) -> Self::Condition {
    sqlite_expr!("{} = {}", (ident(target_field).dot_of(target_table)),
        (ident(source_column).dot_of(source_table)))
}

Requires SelectableDataSource (Step 3) since aggregate query builders use table.select().

Step 7: Multi-Backend Applications

At this point your backend works — you can define tables, query data, and traverse relationships. But a real application typically has a model crate that defines entities once and offers table constructors for each backend. That’s bakery_model3 in the Vantage repo. The final piece is type erasure, which lets you treat tables from different backends uniformly.

AnyTable is gone — use Vista

Earlier versions erased the backend with AnyTable. It was removed in 0.5.2; type erasure now lives one layer up in Vista, reached through the driver’s vista factory. The mechanism below is the supported replacement.

Vista: the type-erased handle

db.vista_factory().from_table(table) erases the backend and entity types behind a uniform, schema-bearing Vista carrying Record<ciborium::Value>. This is what makes it possible to write generic UI, CLI, or API code that doesn’t care which database is behind it:

use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::table::Table;
use vantage_types::entity;
#[entity(SqliteType)]
#[derive(Debug, Clone)]
struct Product {
    name: String,
    price: i64,
}
impl Product {
    fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
        Table::new("product", db)
            .with_id_column("id")
            .with_column_of::<String>("name")
            .with_column_of::<i64>("price")
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite::memory:").await?;
// Wrap any typed Table<Driver, Entity> as a backend-agnostic Vista:
let products = db.vista_factory().from_table(Product::sqlite_table(db.clone()))?;
// (or surreal/csv/mongo — the call site is identical)
Ok(())
}

Erasure works because each driver’s vista factory bridges its native AnyType to the CBOR value the Vista exposes — the vantage_type_system! macro and your Step 1 conversions already provide both directions. See Step 8 for the factory and TableShell you implement to enable this.

Building a multi-source CLI

The CLI example in bakery_model3/examples/cli.rs shows the pattern. A build_table function matches on the user’s chosen source, calls the right entity constructor, and wraps it through the vista factory. Once you have a Vista, all commands are backend-agnostic — listing, counting, reading, inserting, and deleting all work identically regardless of which database is behind it.

Because the values flow through as a typed CBOR record, the CLI renderer can inspect types at runtime — booleans like is_deleted display as true/false with color highlighting, numbers stay numeric, and nulls render cleanly. Your type system work in Step 1 ensures these values arrive with the right type rather than everything being a string.

Try it out:

# List products from CSV
cargo run --example cli -- csv product list

# Same thing from SQLite
cargo run --example cli -- sqlite product list

# Count bakeries in SurrealDB
cargo run --example cli -- surreal bakery count

# Get a single product record
cargo run --example cli -- sqlite product get

# Insert a new record
cargo run --example cli -- surreal bakery add myid '{"name":"Test","profit_margin":10}'

# Delete a record
cargo run --example cli -- surreal bakery delete myid

That’s the payoff of implementing a proper type system and TableSource — one line of vista_factory().from_table() bridges the gap between your backend’s native types and a uniform record-based interface.

Step 8: Vista Integration

By the end of Step 6 your backend is a typed persistence: Table<T, E>, conditions, relationships. That’s what business logic wants, and it’s exactly what generic code can’t use.

A CLI that lists “any table from any backend” can’t carry an entity generic. A web admin that draws forms from a YAML schema doesn’t know your Product struct exists. A Rhai script that filters rows by a column name shouldn’t compile against your backend’s condition type. Vista is the bridge: a schema-bearing handle over one table of one backend, with CBOR values and string ids at its surface.

This step adds two pieces to your backend: a shell that executes reads, narrowing and writes for a Vista, and a factory that builds a Vista from a typed Table or a YAML spec.

What Vista is

Vista is a concrete struct, not a trait. It holds a name, the clauses applied to it (a VistaQuery) and a boxed TableShell: your driver’s executor. The schema and the capabilities live on the shell. Vista reads them from there and checks every call against them before the shell sees it.

A Vista is a value. vista.clone().with_condition(..) gives a narrowed set and leaves vista alone, and it does that by cloning the shell and narrowing the clone. That’s why TableShell has Clone as a supertrait: a shell’s clone must carry its current narrowing and own it, while sharing the connection or the store.

Why not hand around Table<T, E>?

You can, and wherever the entity is known at compile time you should: it’s typed, it composes, and it’s what the previous steps built. Vista is for the cases where the entity isn’t known at compile time, or the backend is chosen at runtime:

  • a CLI driven by --source surreal --table client list, with no Client struct in scope;
  • an admin tool that reads its schema from YAML;
  • a Rhai callback that filters rows by a column name.

The price of erasure is that values become CborValue and ids become String. Generic code renders values; it doesn’t deserialize them into structs, so it doesn’t miss the types.

Setting it up

The bridge is opt-in behind a vista feature, so a crate that only uses Table doesn’t pull in vantage-vista. The contract checks used in the tests come from vantage-vista’s contract feature, enabled for tests only:

[features]
default = []
vista = ["dep:vantage-vista"]

[dependencies]
vantage-vista = { path = "../vantage-vista", optional = true }

[dev-dependencies]
vantage-vista = { path = "../vantage-vista", features = ["contract"] }

Everything Vista-related sits under #[cfg(feature = "vista")]. CSV’s layout is the one to copy:

<driver>/src/vista.rs          re-exports, and <Driver>::vista_factory()
<driver>/src/vista/spec.rs     <Driver>TableExtras, <Driver>ColumnExtras, <Driver>VistaSpec
<driver>/src/vista/factory.rs  <Driver>VistaFactory and its impl VistaFactory
<driver>/src/vista/source.rs   <Driver>TableShell and its impl TableShell

A backend whose native values aren’t CBOR-shaped also needs a bridge between the two. MongoDB keeps its BSON ↔ CBOR conversion in types/cbor.rs.

The shell

Here is a complete shell over rows held in memory. It’s the smallest backend there is, so everything in it is the trait’s shape rather than a driver’s detail. It evaluates every clause with the same evaluator a cache uses (VistaQuery::matches and VistaQuery::sort_rows), so it can advertise operator filters, search and order:

use std::sync::Arc;

use async_trait::async_trait;
use indexmap::IndexMap;
use vantage_core::Result;
use vantage_types::Record;
use vantage_vista::{
    Capability, CborValue, Column, ColumnFlag, Condition, FilterOp, Page, Preview, SortDirection,
    TableShell, Vista, VistaCapabilities, VistaMetadata, VistaQuery, condition_column_error,
};

type Rec = Record<CborValue>;

/// Rows held in memory. A clone shares the rows and owns its narrowing.
#[derive(Clone)]
struct VecShell {
    rows: Arc<Vec<(String, Rec)>>,
    query: VistaQuery,
    metadata: VistaMetadata,
    capabilities: VistaCapabilities,
}

impl VecShell {
    fn new(rows: Arc<Vec<(String, Rec)>>, metadata: VistaMetadata) -> Self {
        let capabilities = [
            Capability::FilterOps,
            Capability::Search,
            Capability::Order,
            Capability::Window,
        ]
        .into_iter()
        .collect();
        Self { rows, query: VistaQuery::new(), metadata, capabilities }
    }

    /// The rows the narrowing keeps, in its order.
    fn set(&self) -> Result<Vec<(String, Rec)>> {
        let mut rows = Vec::new();
        for (id, row) in self.rows.iter() {
            if self.query.matches(row, &self.metadata)? {
                rows.push((id.clone(), row.clone()));
            }
        }
        self.query.sort_rows(&mut rows, &self.metadata);
        Ok(rows)
    }

    /// A declared column, or a dotted path into one.
    fn check_column(&self, column: &str) -> Result<()> {
        let root = column.split('.').next().unwrap_or(column);
        let columns = &self.metadata.columns;
        if columns.contains_key(column) || columns.contains_key(root) {
            Ok(())
        } else {
            Err(condition_column_error(&self.metadata, column))
        }
    }
}

#[async_trait]
impl TableShell for VecShell {
    fn metadata(&self) -> &VistaMetadata {
        &self.metadata
    }

    fn capabilities(&self) -> &VistaCapabilities {
        &self.capabilities
    }

    fn driver_name(&self) -> &'static str {
        "vec"
    }

    fn preview(&self) -> Preview {
        Preview::new().with_note("every clause is evaluated in memory")
    }

    async fn list(&self) -> Result<IndexMap<String, Rec>> {
        Ok(self.set()?.into_iter().collect())
    }

    async fn get(&self, id: &str) -> Result<Option<Rec>> {
        Ok(self.set()?.into_iter().find(|(key, _)| key == id).map(|(_, row)| row))
    }

    async fn count(&self) -> Result<i64> {
        Ok(self.set()?.len() as i64)
    }

    async fn fetch_window(&self, offset: usize, limit: usize) -> Result<Page> {
        Ok(Page::from_listing(self.set()?, offset, limit))
    }

    fn add_condition(&mut self, condition: &Condition) -> Result<()> {
        self.check_column(&condition.column)?;
        self.query.conditions.push(condition.clone());
        Ok(())
    }

    fn set_search(&mut self, text: Option<&str>) -> Result<()> {
        self.query.search = text.map(str::to_string);
        Ok(())
    }

    fn set_order(&mut self, order: Option<(&str, SortDirection)>) -> Result<()> {
        if let Some((column, _)) = order {
            self.check_column(column)?;
        }
        self.query.order = order.map(|(column, dir)| (column.to_string(), dir));
        Ok(())
    }
}

fn row(id: &str, name: &str, category: &str) -> (String, Rec) {
    let cells = [("id", id), ("name", name), ("category", category)];
    let record = cells.into_iter().map(|(k, v)| (k.to_string(), CborValue::from(v))).collect();
    (id.to_string(), record)
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let metadata = VistaMetadata::new()
    .with_column(Column::new("id", "string").with_flag(ColumnFlag::Orderable))
    .with_column(Column::new("name", "string").with_flag(ColumnFlag::Searchable))
    .with_column(Column::new("category", "string"))
    .with_id_column("id");
let rows = Arc::new(vec![
    row("p1", "Tea", "drink"),
    row("p2", "Cake", "food"),
    row("p3", "Coffee", "drink"),
]);
let products = Vista::new("product", VecShell::new(rows, metadata));

let drinks = products.clone().with_condition("category", FilterOp::Eq, "drink")?;
assert_eq!(drinks.count().await?, 2);
assert_eq!(products.count().await?, 3);
Ok(())
}

The methods split into what every shell answers and what it opts into.

Every shell answers metadata, capabilities, driver_name, preview, list, get, count, fetch_window and add_condition with equality.

  • metadata is the schema. A shell over a typed table doesn’t build it by hand: CSV’s factory asks the table for it with table.vista_metadata(VistaMetadataOptions { references: true, .. }), which carries the column types, the id and title columns and the relations.
  • fetch_window with offset == 0 is how a capped read and get_some_value read, so every shell that lists must answer it. A backend that can only list cuts the window from its listing with Page::from_listing, which also fills the page’s total. A backend that pages natively pushes LIMIT/OFFSET and sets the total only when the fetch learned it (Page::new(rows).with_total(n)).
  • add_condition refuses a column the shell doesn’t take with condition_column_error. That gives a plain unknown-column error for a column the schema doesn’t declare, and an Unimplemented refusal for a dotted path the shell can’t evaluate. The kinds matter: Vista::with_query hands a refused clause back to the caller to evaluate locally, and passes a plain error on. A shell that errors must leave its narrowing unchanged, which is why VecShell checks before it pushes.
  • preview renders the query the next read would send, without sending it. Lossy is fine, wrong is not: it must not show a clause the shell wouldn’t send.

A shell opts into the rest by implementing the method and advertising its Capability. Each default answers unsupported with the capability that names it:

CapabilityMethods
Insertinsert
Updatereplace, upsert, patch
Deletedelete, delete_all
Importimport, one atomic batch that counts new rows
Subscribewatch
Orderset_order
Searchset_search
FilterOpsadd_condition with any operator other than Eq
NativeConditionsadd_native_condition
Windowfetch_window at any offset
Cursorfetch_cursor
Traverseget_ref
ConfineWritesevery write keeps to the narrowed set

Vista refuses most of these before the shell is called: writes, search, order, operator filters, windows past the first, cursors and subscriptions. get_ref and add_native_condition reach the shell, and the default refuses them there. CSV pushes only equality, so it doesn’t advertise FilterOps, and a Gt never reaches it. Its add_condition still answers unsupported(Capability::FilterOps) for any other operator, so the shell is correct on its own.

Two of the opt-in methods need more than the table above says:

  • get_ref(relation, row) is the one traversal method. With a parent row it returns the related rows; with None it returns the bare target, which is what an edit form lists to pick a related record from. A shell over a typed table forwards both to Table::traverse_erased, passing its own factory to wrap the target, and answers an undeclared relation with unknown_relation. CSV’s get_ref looks the reference up in its metadata and makes that one call. Contained relations route through the same method (Step 9).
  • add_native_condition takes a NativeCondition: a condition in your driver’s own type, passed through Vista unread. Downcast it to the type you take. Another type is the caller’s mistake, so it’s an IncorrectUsage error, not Unsupported.

The factory

The factory is where both construction paths meet. Expose it as an inherent vista_factory() on the data source, so callers don’t name an extra type. It carries two entry points:

  • from_table<E>(table: Table<YourDB, E>) -> Result<Vista>, inherent, because putting it on the trait would make vantage-vista depend on vantage-table;
  • impl VistaFactory, whose build_from_spec lowers a parsed YAML spec. The default from_yaml parses the YAML and calls it.

Keep them on one path. CSV’s build_from_spec builds a Table<Csv, EmptyEntity> from the spec, hands it to from_table, and renames the result with with_name(spec.name), so a spec called client over a file clients.csv shows as client. A capability added later is then a one-line edit in one place, and the two paths can’t drift apart.

For the memory shell above there is no typed table, so the factory lowers the spec straight into metadata:

use std::sync::Arc;
use async_trait::async_trait;
use indexmap::IndexMap;
use vantage_core::Result;
use vantage_types::Record;
use vantage_vista::{
    Capability, CborValue, Column, Condition, Page, Preview, SortDirection, TableShell, Vista,
    VistaCapabilities, VistaMetadata, VistaQuery, condition_column_error,
};
type Rec = Record<CborValue>;
#[derive(Clone)]
struct VecShell {
    rows: Arc<Vec<(String, Rec)>>,
    query: VistaQuery,
    metadata: VistaMetadata,
    capabilities: VistaCapabilities,
}
impl VecShell {
    fn new(rows: Arc<Vec<(String, Rec)>>, metadata: VistaMetadata) -> Self {
        let capabilities = [
            Capability::FilterOps,
            Capability::Search,
            Capability::Order,
            Capability::Window,
        ]
        .into_iter()
        .collect();
        Self { rows, query: VistaQuery::new(), metadata, capabilities }
    }
    fn set(&self) -> Result<Vec<(String, Rec)>> {
        let mut rows = Vec::new();
        for (id, row) in self.rows.iter() {
            if self.query.matches(row, &self.metadata)? {
                rows.push((id.clone(), row.clone()));
            }
        }
        self.query.sort_rows(&mut rows, &self.metadata);
        Ok(rows)
    }
    fn check_column(&self, column: &str) -> Result<()> {
        let root = column.split('.').next().unwrap_or(column);
        let columns = &self.metadata.columns;
        if columns.contains_key(column) || columns.contains_key(root) {
            Ok(())
        } else {
            Err(condition_column_error(&self.metadata, column))
        }
    }
}
#[async_trait]
impl TableShell for VecShell {
    fn metadata(&self) -> &VistaMetadata {
        &self.metadata
    }
    fn capabilities(&self) -> &VistaCapabilities {
        &self.capabilities
    }
    fn driver_name(&self) -> &'static str {
        "vec"
    }
    fn preview(&self) -> Preview {
        Preview::new().with_note("every clause is evaluated in memory")
    }
    async fn list(&self) -> Result<IndexMap<String, Rec>> {
        Ok(self.set()?.into_iter().collect())
    }
    async fn get(&self, id: &str) -> Result<Option<Rec>> {
        Ok(self.set()?.into_iter().find(|(key, _)| key == id).map(|(_, row)| row))
    }
    async fn count(&self) -> Result<i64> {
        Ok(self.set()?.len() as i64)
    }
    async fn fetch_window(&self, offset: usize, limit: usize) -> Result<Page> {
        Ok(Page::from_listing(self.set()?, offset, limit))
    }
    fn add_condition(&mut self, condition: &Condition) -> Result<()> {
        self.check_column(&condition.column)?;
        self.query.conditions.push(condition.clone());
        Ok(())
    }
    fn set_search(&mut self, text: Option<&str>) -> Result<()> {
        self.query.search = text.map(str::to_string);
        Ok(())
    }
    fn set_order(&mut self, order: Option<(&str, SortDirection)>) -> Result<()> {
        if let Some((column, _)) = order {
            self.check_column(column)?;
        }
        self.query.order = order.map(|(column, dir)| (column.to_string(), dir));
        Ok(())
    }
}
fn row(id: &str, name: &str, category: &str) -> (String, Rec) {
    let cells = [("id", id), ("name", name), ("category", category)];
    let record = cells.into_iter().map(|(k, v)| (k.to_string(), CborValue::from(v))).collect();
    (id.to_string(), record)
}
use vantage_vista::{NoExtras, VistaFactory, VistaSpec};

struct VecVistaFactory {
    rows: Arc<Vec<(String, Rec)>>,
}

impl VistaFactory for VecVistaFactory {
    type TableExtras = NoExtras;
    type ColumnExtras = NoExtras;
    type ReferenceExtras = NoExtras;

    fn build_from_spec(&self, spec: VistaSpec) -> Result<Vista> {
        let mut metadata = VistaMetadata::new().with_id_column(spec.resolve_id_column("id"));
        for (name, column) in &spec.columns {
            let declared = match &column.col_type {
                Some(ty) => Column::new(name, ty),
                None => Column::untyped(name),
            };
            let declared = column.flags.iter().cloned().fold(declared, Column::with_flag);
            metadata = metadata.with_column(declared);
        }
        Ok(Vista::new(spec.name, VecShell::new(self.rows.clone(), metadata)))
    }
}

const PRODUCT: &str = r#"
name: product
columns:
  id: { type: string, flags: [id, orderable] }
  name: { type: string, flags: [title, searchable] }
  category: { type: string }
"#;

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let rows = vec![row("p1", "Tea", "drink"), row("p2", "Cake", "food"), row("p3", "Coffee", "drink")];
let factory = VecVistaFactory { rows: Arc::new(rows) };
let products = factory.from_yaml(PRODUCT)?;
assert_eq!(products.id_column(), Some("id"));
assert_eq!(products.title_columns(), ["name"]);
Ok(())
}

Three spec rules hold for every driver:

  • Resolve the id column in a fixed order: id_column: first, then the first column flagged id, then the backend’s own default ("id" for SQL, "_id" for MongoDB). VistaSpec::resolve_id_column does exactly that. Don’t reverse it: an explicit id_column: is how a YAML author corrects a bad flag without editing the schema source.
  • Driver blocks reject typos. Driver-specific YAML lives under the three associated types, conventionally under a key named after the driver (csv: { path }, mongo: { collection }). Put #[serde(deny_unknown_fields)] on every extras struct, or mongo: { collctiom: clients } falls back to defaults and you debug a missing collection at runtime instead of at parse time. Make the fields Option<T> with #[serde(default)] where the spec name is a sensible fallback.
  • Validate up front. Reject bad paths and conflicting options in build_from_spec, with the column name in the error, so the YAML author can find the entry.

The boundary

  • Ids stringify. MongoDB’s ObjectId becomes its hex form, SurrealDB’s Thing its table:id form. Inside the shell you parse the string back. Round-trip it in a test: get must find a row by the very string list returned for it.
  • Values become CBOR. On the way in, unwrap CborValue::Tag(_, inner) and store the inner value. Stringify integers wider than your native type rather than truncating them. Keep the bridge module-private: a public BSON ↔ CBOR conversion gets called from places that should go through a Vista instead.

Why must every advertised capability work?

Because a capability is the only thing a generic caller checks. A UI draws an “add” button when the shell advertises Insert, a grid offers sorting when it advertises Order, and a cache pushes a filter down instead of evaluating it itself when it advertises FilterOps. Nothing else warns the caller, so a flag the shell can’t honour turns a clean “not supported” into a failure in front of a user.

vantage_vista::contract makes that a test failure instead. check_advertised calls the method behind each advertised capability on your fixture, and fails when one refuses, errors on a valid fixture, or does nothing: a search that counts as many rows as before, an ascending and a descending order that list the same row first, a Gt that keeps its own pivot row. It also holds every shell to the leading window and to the dotted-path rule above. check_clone checks that a narrowed clone leaves the original alone. Each driver runs both against its own fixture:

use std::sync::Arc;
use async_trait::async_trait;
use indexmap::IndexMap;
use vantage_core::Result;
use vantage_types::Record;
use vantage_vista::{
    Capability, CborValue, Column, ColumnFlag, Condition, Page, Preview, SortDirection,
    TableShell, Vista, VistaCapabilities, VistaMetadata, VistaQuery, condition_column_error,
};
type Rec = Record<CborValue>;
#[derive(Clone)]
struct VecShell {
    rows: Arc<Vec<(String, Rec)>>,
    query: VistaQuery,
    metadata: VistaMetadata,
    capabilities: VistaCapabilities,
}
impl VecShell {
    fn new(rows: Arc<Vec<(String, Rec)>>, metadata: VistaMetadata) -> Self {
        let capabilities = [
            Capability::FilterOps,
            Capability::Search,
            Capability::Order,
            Capability::Window,
        ]
        .into_iter()
        .collect();
        Self { rows, query: VistaQuery::new(), metadata, capabilities }
    }
    fn set(&self) -> Result<Vec<(String, Rec)>> {
        let mut rows = Vec::new();
        for (id, row) in self.rows.iter() {
            if self.query.matches(row, &self.metadata)? {
                rows.push((id.clone(), row.clone()));
            }
        }
        self.query.sort_rows(&mut rows, &self.metadata);
        Ok(rows)
    }
    fn check_column(&self, column: &str) -> Result<()> {
        let root = column.split('.').next().unwrap_or(column);
        let columns = &self.metadata.columns;
        if columns.contains_key(column) || columns.contains_key(root) {
            Ok(())
        } else {
            Err(condition_column_error(&self.metadata, column))
        }
    }
}
#[async_trait]
impl TableShell for VecShell {
    fn metadata(&self) -> &VistaMetadata {
        &self.metadata
    }
    fn capabilities(&self) -> &VistaCapabilities {
        &self.capabilities
    }
    fn driver_name(&self) -> &'static str {
        "vec"
    }
    fn preview(&self) -> Preview {
        Preview::new().with_note("every clause is evaluated in memory")
    }
    async fn list(&self) -> Result<IndexMap<String, Rec>> {
        Ok(self.set()?.into_iter().collect())
    }
    async fn get(&self, id: &str) -> Result<Option<Rec>> {
        Ok(self.set()?.into_iter().find(|(key, _)| key == id).map(|(_, row)| row))
    }
    async fn count(&self) -> Result<i64> {
        Ok(self.set()?.len() as i64)
    }
    async fn fetch_window(&self, offset: usize, limit: usize) -> Result<Page> {
        Ok(Page::from_listing(self.set()?, offset, limit))
    }
    fn add_condition(&mut self, condition: &Condition) -> Result<()> {
        self.check_column(&condition.column)?;
        self.query.conditions.push(condition.clone());
        Ok(())
    }
    fn set_search(&mut self, text: Option<&str>) -> Result<()> {
        self.query.search = text.map(str::to_string);
        Ok(())
    }
    fn set_order(&mut self, order: Option<(&str, SortDirection)>) -> Result<()> {
        if let Some((column, _)) = order {
            self.check_column(column)?;
        }
        self.query.order = order.map(|(column, dir)| (column.to_string(), dir));
        Ok(())
    }
}
fn row(id: &str, name: &str, category: &str) -> (String, Rec) {
    let cells = [("id", id), ("name", name), ("category", category)];
    let record = cells.into_iter().map(|(k, v)| (k.to_string(), CborValue::from(v))).collect();
    (id.to_string(), record)
}
fn products() -> Vista {
    let metadata = VistaMetadata::new()
        .with_column(Column::new("id", "string").with_flag(ColumnFlag::Orderable))
        .with_column(Column::new("name", "string").with_flag(ColumnFlag::Searchable))
        .with_column(Column::new("category", "string"))
        .with_id_column("id");
    let rows = vec![row("p1", "Tea", "drink"), row("p2", "Cake", "food"), row("p3", "Coffee", "drink")];
    Vista::new("product", VecShell::new(Arc::new(rows), metadata))
}
use vantage_vista::contract;

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
contract::check_advertised(&products()).await;
contract::check_clone(&products(), "category", CborValue::from("drink")).await;
Ok(())
}

A third check, check_advertised_writes, probes the write capabilities. Run it only on a disposable fixture, since a successful probe stores a row.

Leaving a flag off costs nothing: Vista refuses the call, and the caller evaluates the clause itself or hides the control. So start narrow and add a flag the day its method works. A shell that ships Subscribe the week LIVE queries land is healthier than one that advertised it on day one. Postgres is the cautionary case for Subscribe: LISTEN succeeds on a channel no trigger feeds, so its capability is opt-in (PostgresVistaFactory::with_notify), not inferred from the table being writable. A silent stream can’t be told apart from an idle table.

Tests

Gate the Vista tests on feature = "vista". The contract runs against the real backend, as the TableSource tests in earlier steps do. Around it:

  • Unit tests that need no backend: the CBOR bridge round-trips every native variant (lossy ones assert their documented form), and the YAML spec parses, with an unknown field in the driver block failing.
  • Typed and YAML construction: from_table and from_yaml list, get and count the seeded rows, and the spec name overrides the table name.
  • Push-down: a condition narrows both count and list_values, and a second one stacks.
  • Traversal: get_ref from a row and with None, for every relation. A test file that never traverses won’t notice a shell that forgot to forward it.
  • Ids: get_value finds each row by the id list_values returned for it.

Step 8 checklist

At this point your backend has:

  1. A vista feature gating the bridge, and vantage-vista’s contract feature in [dev-dependencies].

  2. <Driver>::vista_factory(), returning a <Driver>VistaFactory with an inherent from_table<E> and an impl VistaFactory whose build_from_spec routes through the same path.

  3. <Driver>TableShell implementing TableShell:

    • Clone carries the narrowing and shares the connection;
    • metadata from table.vista_metadata(..);
    • list, get, count, the leading window, and add_condition with equality, refusing columns through condition_column_error;
    • get_ref through Table::traverse_erased, from a row and with None;
    • preview and driver_name;
    • the opt-in methods the backend can honour, and capabilities() advertising exactly those.
  4. YAML extras in spec.rs, each with deny_unknown_fields, and the id column resolved in the fixed order.

  5. Tests: check_advertised and check_clone against a real fixture, check_advertised_writes where the fixture is disposable, and the construction, push-down, traversal and id tests.

The same generic CLI, admin UI or script that drives CSV and MongoDB now drives your backend, with no entity import and no backend-specific line on the consumer side.

Step 9: Contained Relations

Some data doesn’t live in its own table. A product carries an inventory object; an order carries a lines array. The records are real: they have fields, and you want to list them, add to them and edit one. But they’re embedded in a column of the parent row, not stored in a table of their own.

with_one / with_many (Step 6) can’t model this: they resolve to another table through a foreign key. A contained relation resolves to a sub-Vista backed by one column of the same row. Reads project that column into records; writes patch the column back in place. To the consumer it looks like any other relation: get_ref("lines", Some(&row)), then list_values / insert_value / patch_value / delete.

This step is optional. Skip it unless your backend stores embedded objects or arrays that users should edit as records.

What the framework gives you

Almost all of it is backend-agnostic and already done.

Declaration is on the typed Table, next to with_one. with_contained_one and with_contained_many each register a ContainedRelation (built by ContainedRelation::one or ContainedRelation::many), and vista_contained() describes them to Vista as references of a contained kind, Reference::contains_one or Reference::contains_many:

use vantage_sql::sqlite::SqliteDB;
use vantage_table::table::Table;
use vantage_types::EmptyEntity;
use vantage_vista::Reference;
fn product_table(db: SqliteDB) -> Table<SqliteDB, EmptyEntity> {
    Table::new("product", db)
        .with_id_column("id")
        .with_column_of::<String>("name")
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite::memory:").await?;
let orders = Table::<SqliteDB, EmptyEntity>::new("order", db)
    .with_id_column("id")
    .with_column_of::<String>("lines") // the host column: declare it (see Sharp edges)
    .with_contained_many("lines", "lines", |db| {
        Table::new("lines", db)
            .with_column_of::<String>("line_id")
            .with_column_of::<i64>("quantity")
            .with_column_of::<String>("product")
            .with_one("product", "product", product_table) // a line can traverse out
    }, Some("line_id"));

assert_eq!(orders.vista_contained(), [Reference::contains_many("lines", "lines")]);
Ok(())
}

The closure builds the contained record’s schema. It has the same shape as with_one’s target builder, uses the same type system, and runs lazily, at traversal.

The sub-Vista is build_contained_vista. It holds the column’s records in memory, serves reads from them, and after every write re-serializes the whole collection and hands it to a ContainedWriteback. The writeback (patch the parent row’s host column) is the only persistence-specific part:

use std::sync::{Arc, Mutex};

use vantage_vista::prelude::*;
use vantage_vista::{Column, ContainedWriteback, Reference, VistaMetadata, build_contained_vista};

fn line(id: &str, quantity: i64) -> CborValue {
    CborValue::Map(vec![
        (CborValue::from("line_id"), CborValue::from(id)),
        (CborValue::from("quantity"), CborValue::from(quantity)),
    ])
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let schema = VistaMetadata::new()
    .with_column(Column::new("line_id", "string"))
    .with_column(Column::new("quantity", "int"))
    .with_id_column("line_id");
let host = CborValue::Array(vec![line("l1", 2), line("l2", 1)]);

// Stands in for "patch the parent row's host column".
let written = Arc::new(Mutex::new(None));
let sink = written.clone();
let writeback: ContainedWriteback = Arc::new(move |collection: CborValue| {
    *sink.lock().unwrap() = Some(collection);
    Box::pin(async { Ok(()) })
});

let lines = Reference::contains_many("lines", "lines");
let records = build_contained_vista(&lines, schema, Some(&host), Some(writeback), None)?;
assert_eq!(records.list_values().await?.len(), 2);

records.delete("l1").await?;
let collection = written.lock().unwrap().clone();
assert_eq!(collection, Some(CborValue::Array(vec![line("l2", 1)])));
Ok(())
}

The skeleton every driver shares is Table::get_contained_ref. It builds the contained schema from the closure, seeds the records from the parent row, wires the writeback to patch_value on the parent table, and resolves traverse-out (line → product) through the contained table’s own relations. You pass it only what the driver knows: the parent row with its id in your native id type, a wrap closure that turns a target Table into a Vista through your factory, and the host column’s codec.

Wiring it into your shell

Two changes, both small:

  • Metadata. Ask the table for its contained relations as well: table.vista_metadata(VistaMetadataOptions { contained: true, .. }). They join the joined relations in metadata().references, so consumers see one list.
  • get_ref. A contained relation routes through the same method as a join. When the reference’s kind.is_contained(), call get_contained_ref instead of traverse_erased. With a row, pass the row and its id; with None, pass None, and you get the bare target: the contained schema with no records and no write capabilities, since there’s no parent row to write into.

The SQL drivers’ shell is the worked source (vantage-sql/src/sql_vista/refs.rs). Their host column is TEXT holding JSON, so the codec parses on the way in and serializes on the way out:

use vantage_sql::sqlite::SqliteDB;
use vantage_table::table::Table;
use vantage_types::{EmptyEntity, PlainDialect, Record, cbor_to_json, json_to_cbor};
use vantage_vista::prelude::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = SqliteDB::connect("sqlite::memory:").await?;
let orders = Table::<SqliteDB, EmptyEntity>::new("order", db.clone())
    .with_id_column("id")
    .with_column_of::<String>("lines")
    .with_contained_many("lines", "lines", |db| {
        Table::new("lines", db)
            .with_column_of::<String>("line_id")
            .with_column_of::<i64>("quantity")
    }, Some("line_id"));
// The parent row as the shell read it: `lines` is TEXT holding JSON.
let order: Record<CborValue> = [
    ("id".to_string(), CborValue::from("o1")),
    ("lines".to_string(), CborValue::from(r#"[{"line_id": "l1", "quantity": 2}]"#)),
]
.into_iter()
.collect();

let factory_db = db.clone();
let lines = orders.get_contained_ref(
    "lines",
    Some((&order, "o1".to_string())),
    move |target| factory_db.vista_factory().from_table(target),
    |host| match host {
        CborValue::Text(json) => serde_json::from_str(json).ok().map(json_to_cbor),
        _ => None,
    },
    |collection| CborValue::Text(cbor_to_json(&PlainDialect, collection).to_string()),
)?;
assert_eq!(lines.list_values().await?.len(), 1);
Ok(())
}

Nothing is written here: the records come from the row, and only a write through lines would patch order.lines on the database.

The contract test from Step 8 covers the wiring. check_advertised traverses every reference, contained ones included, from a row and with None, so a shell that advertises Traverse and forgets the contained branch fails it.

Native vs JSON-blob

The codec is the one place native and JSON-blob backends differ.

Native backends (SurrealDB, MongoDB) store the host column as a real nested object or array. The value arrives as CborValue::Map / Array already, and the writeback patches it back as it is (SurrealDB UPDATE … MERGE, MongoDB $set). Decode is a clone; encode is the identity.

JSON-blob backends (SQL with no native nesting, the SQLite path above) store the collection as a JSON string in a TEXT column: parse on read, serialize on write. The SQL drivers’ decoder also passes a Map / Array straight through, so the same code copes with a backend that parses JSON columns natively (Postgres jsonb, MySQL json). There, declaring the host column as TEXT keeps the round-trip a plain string and avoids binding a nested value on write, which is why Postgres and MySQL share the SQLite implementation.

Why not just flatten the keys?

You can, for reading a fixed shape: inventory.stock as a scalar column is fine when there’s one known field. It falls apart the moment the embedded data is a collection (an order has N lines, not a fixed set), or the user needs to add and remove elements. A contained relation gives you a record set with ids, not a bag of dotted columns.

Why not model it as a foreign-key relation?

Because there’s no other table to point at, and no foreign key to join on. The data is in the row. Forcing it into with_many would mean inventing a synthetic table and a join the storage engine can’t honour. Traversal here is a column projection, not a query.

Eager writeback

Every mutation on the sub-Vista patches the parent row immediately; there’s no flush. The sub-Vista and the parent row stay coherent at all times, and that’s deliberate: batching belongs to a higher layer (a UI’s write queue), not the storage boundary. The cost is one parent patch per edit, which is trivial for the small collections this targets.

The flip side: the whole collection is re-serialized and written each time. A contained relation is for line items and embedded objects, not for a thousand-element array mutated in a tight loop.

Sharp edges

Declare the host column. This is the bug every backend hits. If lines isn’t a declared column, your read path won’t select or project it (SQL builds its SELECT from declared columns; MongoDB projects from its column paths), so the parent row arrives without it and traversal sees an empty collection. Declare it alongside the with_contained_* call.

Positional ids shift. With no id column, contained-many records are keyed by position ("0", "1", …), and deleting element 0 renumbers the rest. Give the relation an id column (with_contained_many(…, Some("line_id"))) when callers hold on to ids across mutations.

Contained-one uses a fixed id. A single embedded object is addressed as "0".

The writeback is not atomic with the contained record’s own children. A contained record can traverse out (line → product), but each backend write is its own statement. That’s the same best-effort contract as nested insert: fine for these shapes, not a transaction.

From YAML

Contained relations are declarable in a YAML spec too, in a contained: section next to columns: and references::

name: order
columns:
  id: { type: string, flags: [id] }
  lines: { type: string }          # the host column: declare it so it's selected
sqlite:
  table: order
contained:
  lines:
    host_column: lines
    kind: contains_many            # or contains_one, the default
    id_column: line_id             # optional; omit for positional ids
    columns:
      line_id: { type: string }
      quantity: { type: int }

The factory lowers it through one generic helper, Table::with_contained_specs, which calls your driver’s existing column builder on each contained column. Wiring it is one line in the factory’s spec-to-table code: table = table.with_contained_specs(&spec.contained, build_column)?;. The YAML and code-first paths then register the same ContainedRelation.

Limitation: YAML-declared contained records carry columns only, so traverse-out (line.product) isn’t expressible from YAML yet; the code-first closure supports it, since it can add with_one to the contained table. Lifting this means letting contained columns carry references: sugar plus a resolver, the same machinery YAML foreign-key references need.

Step 9 checklist

A backend supports contained relations once it has:

  1. Metadata built with VistaMetadataOptions { contained: true, .. }, so the relations appear in metadata().references.

  2. get_ref routing a contained reference to Table::get_contained_ref, passing the parent row and its native id (or None for the bare target), the factory’s wrap, and the host codec: passthrough for native nesting, JSON parse and serialize for a TEXT column.

  3. YAML: one with_contained_specs line in the factory’s spec-to-table code.

  4. Tests against a real backend, gated on feature = "vista": declare a host column holding a collection (code-first and through from_yaml), traverse it, insert and patch through the sub-Vista, and re-read the parent row to prove the writeback landed. The Step 8 contract test covers the traversal with and without a row.

Native and JSON-blob backends differ only in two closures: how the host value comes in and how the writeback goes out. Everything between is shared.

Augmentation

A Vista reads one table from one backend. But a row is often only half the story: an S3 bucket listing tells you a file’s key and size, not how many resources the terraform state inside it declares. A REST list of repositories doesn’t carry the CI status that lives behind a shell command. The data you want to show lives in a second source, keyed off the first.

Augmentation wires two Vistas into one Dio: a master that is listed, and a detail source that is loaded one row at a time and merged on top. The two can be the same Vista (a backend whose list and detail are separate operations) or entirely different backends — a REST master enriched by a cmd detail, or the reverse. The detail source is resolved by name through a VistaResolver, usually a Catalog, so it is persistence-agnostic.

Why this lives in the Dio, not the Vista

This is a join, and joins are governed by the capability contract. A SQL Vista can join its own tables; a REST Vista cannot; two different backends can never push a join down to either engine. Where the backend can’t, the layer above fills the gap — that is the whole job of the Dio.

So augmentation is deliberately a Dio-layer feature. A Vista honestly describes one backend and has no cache, no viewport, and no catalog; declaring “enrich from another Vista, loaded lazily and cached” on it would be a promise it can’t keep. The Dio has all three, so it owns the augmentation: it lists the master cheaply, and for each row the user actually scrolls to, it fetches the detail and stitches the columns together. It is a per-row join, executed client-side, exactly where a push-down is impossible.

It reduces to the old two-pass

The previous progressive-loading path — a cmd table with a detail script — is just the special case where the detail source is the master and the key is the id. Expressed as augmentation it is table: <self>, source: id, fetch: per_row. Nothing about that behaviour changed; it stopped being a backend quirk.

How it runs

Augmentation engages the two-pass machinery:

  1. List pass — the master is listed cheaply (a page at a time). Each row is written to the cache as Incomplete, carrying only the columns the list returns. When the master can serve windows (Capability::Window) the page is pushed down; otherwise the set is listed and windowed locally.
  2. Detail pass — viewport-driven. For each visible Incomplete row, every augmentation resolves its detail Vista, fetches the matching record, and merges the chosen columns onto the cached row, which flips to Fresh. Rows off-screen are never fetched; rows already hydrated are never re-fetched.

A failed detail fetch marks only that row, leaving its cheap columns intact — the rest of the page hydrates normally.

Declaring it

Augmentation is a property of the Dio, not the Lens: it is fixed when the Dio is built. Hand the DioBuilder one or more augmentations between lens.dio(master) and build():

use std::sync::Arc;

use vantage_diorama::{Augmentation, Cache, Detail, Fetch, Lens, MergeRule, Source};
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, Vista, VistaMetadata};

fn meta(columns: &[&str]) -> VistaMetadata {
    columns
        .iter()
        .fold(VistaMetadata::new(), |m, c| m.with_column(Column::new(*c, "String")))
        .with_id_column("id")
}

fn detail() -> Vista {
    Vista::new("tfstate_detail", MockShell::new().with_metadata(meta(&["id", "resources", "serial"])))
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let master = Vista::new("buckets", MockShell::new().with_metadata(meta(&["id", "key", "size"])));
let lens = Lens::builder().cache(Cache::memory()).build()?;
let augmentation = Augmentation::new(
    Detail::Fixed(Arc::new(detail())),
    Source::Column { from: "key".into(), to: None },
    Fetch::PerRow,
    MergeRule::lift(["resources", "serial"]),
);
let dio = lens
    .dio(master)
    .augment(vec![augmentation]) // ≥1 engages two-pass
    .build()
    .await?;
let _ = dio;
Ok(())
}

Each Augmentation has four parts. Here the detail is resolved by name through a Catalog:

#![allow(unused)]
fn main() {
use std::sync::Arc;

use vantage_diorama::{Augmentation, Detail, Fetch, MergeRule, Source};
use vantage_vista::mocks::MockShell;
use vantage_vista::{Catalog, Vista, VistaResolver};

fn detail() -> Vista {
    Vista::new("tfstate_detail", MockShell::new())
}

let resolver: Arc<dyn VistaResolver> =
    Arc::new(Catalog::new().with("tfstate_detail", |_: &str| Ok(detail())));

let augmentation = Augmentation::new(
    Detail::Resolved { resolver, name: "tfstate_detail".into() }, // resolved by name
    Source::Column { from: "key".into(), to: None },
    Fetch::PerRow,
    MergeRule::lift(["resources", "serial"]),
);
let _ = augmentation;
}

Detail::Fixed(Arc<Vista>) takes a detail Vista directly instead, for a side table no resolver knows.

source and fetch are two orthogonal axes:

Sourcehow a master row selects its detail
Idmaster.id → detail.id
Column { from, to }master[from] → detail[to or detail.id]
Build(closure)arbitrary narrowing from the whole row — per-row only
Fetchhow the detail is read
PerRowone record per master row (get_value, or narrow-and-take-first)
Custom(closure)a caller-supplied async fetch

Id and id-keyed Column sources read by key through get_value — the uniform “one record by key” primitive (a cmd detail script, a SQL WHERE id =, a REST GET /{id}). Build returns an arbitrary narrowed Vista, so it is per-row only.

Declaring one

An Augmentation is built in Rust. There is no serde mirror of these types: a consumer already has its own config vocabulary — vantage-ui reads an augment: block naming a source table, a key and a column list — and maps that onto Augmentation directly. A second, parallel YAML shape in diorama would be one more spelling of the same idea for each consumer to ignore.

Rhai as a closure factory

The runtime types carry closures, not script strings — Source::Build is a Fn(&row, base) -> Vista. Rhai is one factory that produces such a closure; hand-written Rust is another. A consumer that wants a scripted narrowing builds the closure itself and hands it to Source::Build. The diorama core never sees a script string. The vocabulary such a script would use is the table handle’s, covered in Scripting with Rhai.

Merging

merge.columns lists the detail columns to lift; an empty list lifts all of them. The detail record is the authoritative hydration of the row, so on a name clash its value wins — it overwrites the cheap list-pass value and adds its new columns. (An empty list plus overwrite is exactly the old cmd two-pass: the detail script returns the full record, which replaces the stub.) The augmented Dio therefore advertises the union of the master’s columns and the lifted detail columns — the “Dio advertises a superset” principle: below it, sources are partial; above it, the view is whole.

Anticipated objections

Why not model the detail as a foreign-key relation?

A relation resolves within one persistence and can be pushed down as a join. The detail here may be a different backend entirely; there is nothing to join on and no engine that could honour it. Augmentation is the cross-Vista form, stitched by the Dio.

Why one row at a time?

Because the expensive work should follow the user’s attention. The list pass is cheap and immediate; only rows that reach the viewport pay for their detail, and they pay once.

What’s next

Three things are planned but not yet implemented; each fails honestly today rather than degrading silently:

  • Batched fetch — collect the window’s distinct keys into one set query and scatter the results back, for Id/Column sources.
  • Detail-key cache — dedupe fetches across master rows that share a key (non-unique Column sources).
  • Scripted fetch — a script choosing how to pull.

Checklist

To augment a master with a second source:

  1. Register the detail model in a Catalog by name (Catalog::new().with(..)), or hold it directly for Detail::Fixed.
  2. Build the Augmentation — Detail::Resolved { resolver, name }, a Source (id, column, or a closure), a Fetch (PerRow, or Custom), and the MergeRule columns.
  3. Wire the Dio — lens.dio(master).augment(vec![..]).build().await?; the two-pass machinery engages for every table scenery opened on it.
  4. Show the columns — the lifted detail columns appear on hydrated rows alongside the master’s.

A runnable end-to-end example (two in-memory Vistas, list pass then detail pass) lives in vantage-diorama/examples/augmentation.rs:

cargo run -p vantage-diorama --example augmentation

Persistence-aligned Type System

Every Vantage persistence defines its own type trait — SqliteType, PostgresType, SurrealType, MongoType, CsvType — that maps Rust values to and from the storage format. This is how expressions like sqlite_expr!("price > {}", 150i64) know how to bind 150i64 as a parameter.

Built-in implementations cover the common ground: bool, i64, f64, String, Option<T>, chrono date/time types, and more. Each persistence supports exactly the types its backend can handle natively.

Because the trait is open, you can implement it for your own types — enums, newtypes, domain objects — and they’ll work everywhere expressions are used: conditions, inserts, updates.

See Adding Custom Types for a walkthrough.

Adding Custom Types

You can teach Vantage to store any Rust type by implementing the persistence type trait. Here’s the pattern, using an Animal enum that maps to a text column across every backend:

  1. Define your type — a plain Rust enum (or struct).
  2. Implement the persistence trait — SqliteType, PostgresType, etc. Each impl says how to convert to/from the storage format (CBOR for SQL backends, BSON for MongoDB).
  3. Use it in expressions — once the trait is implemented, sqlite_expr!("species = {}", animal) just works.

A single type can implement traits for multiple backends, so the same Animal enum works with SQLite, Postgres, SurrealDB, MongoDB, and CSV — each with its own serialization logic.

SQL: PostgreSQL, MySQL & SQLite

The vantage-sql crate provides persistence implementations for three SQL databases via sqlx. All three share the same architecture — CBOR-based type systems, expression engine, query builder, and full TableSource implementation — but each has vendor-specific behaviour around type affinity, quoting, and parameter binding.

Backends

BackendStructType SystemParam styleID quoting
PostgreSQLPostgresDBAnyPostgresType$1, $2"double_quote"
MySQLMysqlDBAnyMysqlType?, ?`backtick`
SQLiteSqliteDBAnySqliteType?1, ?2"double_quote"

MysqlDB supports MySQL 8 and MariaDB 11; both run the same test suite in CI. MariaDB differences: JSON_EXTRACT / .as_json() returns quoted JSON text; there is no GROUPING(), no ORDER BY with WITH ROLLUP, and no REGEXP_LIKE (use REGEXP); DateTime<FixedOffset> binds are rejected.

What they implement

All three implement the full trait stack:

  • DataSource — marker
  • ExprDataSource — parametric SQL execution with CBOR values
  • SelectableDataSource — query builder with JOINs, CTEs, window functions
  • TableSource — full CRUD, columns, conditions, aggregates
  • TableQuerySource — table definition → full query

The Vista

With the vista feature, each backend’s factory builds a Vista from a YAML spec or a typed table. All three advertise the same reads, because SQL can do each of them in the query: Order, Search, FilterOps, Window (a LIMIT/OFFSET window, served by fetch_window) and Traverse. A table-sourced Vista adds the writes, Insert, Update, Delete and ConfineWrites. A query-sourced one (rhai: or base:) takes no writes, because no single table could receive them. Per backend:

  • PostgreSQL adds Subscribe when the factory is built with_notify(true). Postgres can’t tell whether a {table}_changed trigger exists, so the application has to say so.
  • MySQL adds NativeConditions (the with_condition verb in modify: scripts) and, on a table-sourced Vista, Import.

A quicksearch is a LIKE on each searchable column, joined with OR. The searchable columns are the ones flagged searchable, or every column when none is flagged. Every driver reads that rule from Table::search_columns (VistaMetadata::search_columns states it over a Vista’s columns), so no backend picks its own. Imported reference columns are left out, because they exist only as projection aliases, and a table with nothing to search matches no rows. PostgreSQL uses ILIKE on the column cast to text. SQLite’s LIKE already ignores case for ASCII text, and MySQL’s follows the column’s collation.

CBOR value representation

All SQL backends use CBOR (ciborium::Value) as their internal value type — not JSON. This preserves type fidelity that JSON loses:

  • Integer vs Float — JSON’s 42 is ambiguous; CBOR distinguishes Integer(42) from Float(42.0)
  • Binary data — CBOR has native byte arrays; JSON would need base64 encoding
  • Precise decimals — stored as tagged CBOR values, not lossy floats

Values are converted to CBOR on write (via the type system’s to_cbor()) and read back as untyped CBOR (via from_cbor()). The type markers from vantage_type_system! ensure correct binding — integers bind as i64, text as &str, booleans as bool.

Type conversion reference

Each database handles Rust types differently depending on the column type. See the Type Conversions reference for detailed round-trip tables covering:

  • Chrono types — NaiveDate, NaiveTime, NaiveDateTime, DateTime<Utc>, DateTime<FixedOffset>
  • Numeric types — Decimal, i64, f64
  • Exact vs lossy vs truncated behaviour per column type
  • Cross-type coercion rules and error cases

SQL Primitives

Primitives are reusable building blocks for constructing SQL expressions. They handle quoting, escaping, vendor-specific syntax, and logical composition so you don’t have to.

Conditions built with typed columns and SqliteOperation cover simple comparisons, but real queries need more — OR groups, function calls, string concatenation, date formatting. That’s what primitives are for.

Import them with:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::*;
use vantage_sql::sqlite::sqlite_ident as ident;
}

Primitives are not part of the prelude — import them explicitly when needed. The second line pins ident to SQLite; ident() explains why the examples need it.

Macros and structs

Some primitives have convenience macros that accept a variable number of arguments and call .expr() on each one automatically: fx! → Fx, concat_! → Concat. The macros are syntactic sugar — if you need to build arguments programmatically (e.g. from a Vec), use the underlying struct directly.

or_() / and_() — Logical Combinators

By default, multiple calls to .with_condition() combine with AND. When you need OR, use or_():

#![allow(unused)]
fn main() {
use vantage_sql::primitives::*;
use vantage_sql::sqlite::operation::SqliteOperation;
use vantage_sql::sqlite::sqlite_ident as ident;

// role = 'admin' OR role = 'superuser'
let cond = or_(ident("role").eq("admin"), ident("role").eq("superuser"));
}

For nested logic, combine with and_():

#![allow(unused)]
fn main() {
use vantage_sql::primitives::*;
use vantage_sql::sqlite::operation::SqliteOperation;
use vantage_sql::sqlite::sqlite_ident as ident;
// (price > 100 AND in_stock = 1) OR (featured = 1)
let cond = or_(
    and_(ident("price").gt(100i64), ident("in_stock").eq(true)),
    ident("featured").eq(true),
);
}

Both return a ConditionGroup, which writes its own brackets and is Expressive, so it plugs directly into .with_condition(). A chain stays flat: a.or_(b).or_(c) renders (a OR b OR c).

ident() — Identifiers

Creates a quoted column or table name. Quoting adapts per backend (" for SQLite/Postgres, ` for MySQL).

#![allow(unused)]
fn main() {
use vantage_sql::sqlite::sqlite_ident as ident;
let col = ident("price");                     // "price"
let qualified = ident("name").dot_of("u");    // "u"."name"
let aliased = ident("total").with_alias("t"); // "total" AS "t"
}

ident() is a shorthand for Identifier::new(). Every name is quoted, and a quote character inside the name is doubled, so a name can’t break out of its quotes.

Each backend also has a typed identifier: sqlite_ident(), pg_ident() (in vantage_sql::postgres) and mysql_ident() (in vantage_sql::mysql). Each returns a wrapper that only renders for its own backend.

Why not the generic ident() everywhere?

The generic Identifier renders for every backend feature you enabled. With one feature on, that is unambiguous. With two, ident("price").gt(100i64) could build a SQLite condition or a PostgreSQL one, and the compiler stops with “type annotations needed”. This book builds SQLite and PostgreSQL, and most applications that talk to two databases do too.

The generic one still works where the backend is fixed by context: inside sqlite_expr!(), or passed where a specific Expressive<T> is expected. Use a typed identifier when you call an operation on it directly. The examples on this page import sqlite_ident as ident, so they read the same as single-backend code.

fx! — Function Calls

The fx! macro builds a SQL function call. Arguments are passed directly — .expr() is called on each one automatically:

#![allow(unused)]
fn main() {
use vantage_sql::sqlite::sqlite_ident as ident;
use vantage_sql::{fx, sqlite_expr};

let count = fx!("count", sqlite_expr!("*"));
// => COUNT(*)

let average = fx!("avg", ident("price"));
// => AVG("price")

// Multiple arguments
let nickname = fx!("coalesce", ident("nickname"), "anonymous");
// => COALESCE("nickname", 'anonymous')

// Nested
let rounded = fx!("round", fx!("avg", ident("price")), 2i64);
// => ROUND(AVG("price"), 2)
}

If you need to build arguments programmatically (e.g. from a Vec), use Fx::new() directly:

#![allow(unused)]
fn main() {
use vantage_expressions::{Expression, Expressive};
use vantage_sql::primitives::*;
use vantage_sql::sqlite::AnySqliteType;
use vantage_sql::sqlite::sqlite_ident as ident;
let columns = [ident("nickname"), ident("name")];
let args: Vec<Expression<AnySqliteType>> = columns.iter().map(|c| c.expr()).collect();
let f = Fx::new("coalesce", args);
}

ternary() — Conditional Expression

Three-valued conditional. Renders as IIF() on SQLite, IF() on MySQL, and CASE WHEN ... THEN ... ELSE ... END on PostgreSQL:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::*;
use vantage_sql::sqlite::operation::SqliteOperation;
use vantage_sql::sqlite::sqlite_ident as ident;
let expr = ternary(
    ident("stock").gt(0i64),
    "in stock",
    "sold out",
);
}

ternary() is a shorthand for Ternary::new().

Case — CASE Expressions

For more than two branches, use Case to build a full CASE WHEN ... END block:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::*;
use vantage_sql::sqlite::operation::SqliteOperation;
use vantage_sql::sqlite::sqlite_ident as ident;
let expr = Case::new()
    .when(ident("status").eq("active"), "yes")
    .when(ident("status").eq("banned"), "no")
    .else_("unknown");
}

concat_! — String Concatenation

Concatenates expressions. Renders as || on SQLite/Postgres, CONCAT() on MySQL. The concat_! macro calls .expr() on each argument automatically:

#![allow(unused)]
fn main() {
use vantage_sql::sqlite::sqlite_ident as ident;
use vantage_sql::concat_;

let full_name = concat_!(ident("first_name"), " ", ident("last_name"));
}

Use .ws() for a separator — it accepts any Expressive<T>, including string literals:

#![allow(unused)]
fn main() {
use vantage_sql::concat_;
use vantage_sql::sqlite::sqlite_ident as ident;
let listed = concat_!(ident("first_name"), ident("last_name")).ws(", ");
// SQLite:   "first_name" || ', ' || "last_name"
// MySQL:    CONCAT_WS(', ', `first_name`, `last_name`)
}

Interval — Date Intervals

Portable date interval that adapts per backend:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::*;
let i = Interval::new(30, "day");
// SQLite:   30  (used with date functions)
// MySQL:    INTERVAL 30 DAY
// Postgres: INTERVAL '30 day'
}

Interval::new(amount, unit) takes the unit as text: day, week, month, year or hour, singular or plural. Use the singular form when the query may run on MySQL, which accepts only DAY, MONTH and so on. SQLite has no interval type, so it renders the interval as a number of days: a week is 7, a month 30, a year 365, and hours round to whole days.

date_format() — Date Formatting

Portable strftime-style formatting. Translates format tokens per backend:

#![allow(unused)]
fn main() {
use vantage_sql::primitives::*;
use vantage_sql::sqlite::sqlite_ident as ident;
let formatted = date_format(ident("created_at"), "%Y-%m-%d");
// SQLite:   STRFTIME('%Y-%m-%d', "created_at")
// MySQL:    DATE_FORMAT("created_at", '%Y-%m-%d')
// Postgres: TO_CHAR("created_at", 'YYYY-MM-DD')
}

date_format() is a shorthand for DateFormat::new(). To skip token translation and pass a native format string, build it with DateFormat::raw_format() instead.

Type Conversions

How Rust native types round-trip through different SQL column types. Tested via entity insert + read-back (Table API).

Legend:

  • exact = lossless round-trip, value identical after insert + read
  • lossy = value stored but precision reduced (e.g. f64 rounding)
  • truncated = value stored but digits beyond column precision are cut
  • err = database rejects the insert or entity conversion fails on read

Chrono Types

MySQL

tests/mysql/1_chrono.rs

Column \ Rust typeNaiveDateNaiveTimeNaiveDateTimeDateTime<Utc>DateTime<FixedOffset>
VARCHARexactexactexactexactexact
DATEexacterrerrerr—
TIMEerrtruncating µserrerr—
TIME(6)errexacterrerr—
DATETIMEerrerrtruncating µstruncating µsoffset → +00:00
DATETIME(6)errerrexactexact—
TIMESTAMPerrerrtruncating µstruncating µsoffset → +00:00
TIMESTAMP(6)errerrexactexact—
  • Format: "2025-01-10 12:00:00" (space separator, no T); FixedOffset appends +05:30
  • TIME/DATETIME/TIMESTAMP default to 0 fractional digits — use (6) for microseconds
  • DateTime<FixedOffset>: VARCHAR preserves offset; DATETIME/TIMESTAMP normalize to UTC
  • MariaDB 11 rejects DateTime<FixedOffset> into DATETIME/TIMESTAMP (no offset literals) — use DateTime<Utc>
  • Cross-type coercions (e.g. NaiveTime → DATE) fail with variant mismatch

PostgreSQL

tests/postgres/1_chrono.rs

Column \ Rust typeNaiveDateNaiveTimeNaiveDateTimeDateTime<Utc>DateTime<FixedOffset>
VARCHARexactexactexactexactoffset → +00:00
DATEexacterrerrerr—
TIMEerrexacterrerr—
TIMESTAMPerrerrexactexactoffset → +00:00
TIMESTAMPTZerrerrexactexactoffset → +00:00
  • Format: "2025-01-10 12:00:00+00" (space separator, abbreviated tz offset)
  • Microsecond precision by default — no (6) suffix needed
  • Typed binds (chrono types, not text) required for DATE/TIME/TIMESTAMP/TIMESTAMPTZ
  • DateTime<FixedOffset>: offset always normalized to UTC (typed binds), no column preserves it
  • Cross-type coercions fail with variant mismatch

SQLite

tests/sqlite/1_chrono.rs

Column \ Rust typeNaiveDateNaiveTimeNaiveDateTimeDateTime<Utc>DateTime<FixedOffset>
TEXTexactexactexactexactexact
  • All dates stored as TEXT — format: ISO 8601 with T separator ("2025-01-10T12:00:00Z")
  • Subsecond precision and timezone offsets preserved as-is

Numeric Types

MySQL

tests/mysql/1_decimal.rs

Column \ Rust typeDecimali64f64
VARCHARexactexactexact
DECIMAL(20,6)truncated to 6 placeserrerr
DECIMAL(38,15)exacterrerr
DOUBLElossy (~15 digits)errexact
FLOATlossy (~7 digits)errlossy (~7 digits)
BIGINTfractional part lostexacterr
  • No cross-conversion between Integer, Float, and Decimal CBOR types
  • VARCHAR works for all types — from_cbor parses text as fallback

PostgreSQL

tests/postgres/1_decimal.rs

Column \ Rust typeDecimali64f64
VARCHARexactexactexact
NUMERIC(20,6)truncated to 6 placeserrerr
NUMERIC(38,15)exacterrerr
DOUBLE PRECISIONlossy (~15 digits)errexact
REALlossy (~7 digits)errlossy (~7 digits)
BIGINTfractional part lostexacterr
  • Typed binds (rust_decimal::Decimal) used for NUMERIC columns

SQLite

tests/sqlite/1_decimal.rs

Column \ Rust typeDecimali64f64
TEXTexactexactexact
NUMERIClossy (~15 digits, stored as REAL)exactexact
REALlossy (~15 digits)errexact
INTEGERfractional part lostexacterr
  • SQLite infers CBOR type from stored value, not declared column type
  • NUMERIC/REAL affinities coerce to float — i64 in REAL comes back as Float and fails
  • Store Decimal in TEXT for lossless precision

SurrealDB

The vantage-surrealdb crate targets SurrealDB — a document-graph database whose query language (SurrealQL) is close enough to SQL to share a vocabulary, but diverges where it matters: graph traversals instead of joins, embedded arrays with closures, record links, and a math::/array::/string:: function namespace.

Behind the rhai feature the crate exposes a scripting surface: a Rhai engine (registered by the register_surreal_engine! macro) that builds SELECT statements from the same named primitives as vantage-sql where the two overlap, and surreal-specific ones where they don’t.

The Vista

With the vista feature, SurrealVistaFactory builds a Vista from a YAML spec. It advertises the reads SurrealQL can do in the query: Order, Search, FilterOps, NativeConditions (the with_condition verb in modify: scripts), Window (LIMIT … START …, served by fetch_window) and Traverse. A table-sourced Vista adds Insert, Update, Delete, ConfineWrites and Subscribe, the last through a LIVE SELECT. A query-sourced one (rhai: or base:) has no table to write to or to subscribe to, so it advertises the reads only.

A quicksearch reads the columns flagged searchable, or every column when none is flagged: the same rule as every other driver (Table::search_columns). Each column is matched as a case-insensitive substring, string::contains(string::lowercase(<string>(col)), $text) with the text lowercased, and the matches are joined with OR. A computed (expr:) column is searched through its expression, because its alias doesn’t exist where WHERE runs. Imported reference columns are left out.

Pages

  • Primitives — the named expression vocabulary and how each lowers to SurrealQL.

SurrealDB Primitives

Primitives are the named building blocks for SurrealQL expressions. Each one does a single job and lowers to the SurrealQL it’s named for — you call surreal::coalesce(a, b) and get a ?? b, not a string you had to escape yourself.

The guiding rule is one meaningful name per concept, reusing the vantage-sql name wherever the concept already exists. A script that says count(), avg(), round(), coalesce(), case_when(), date_format() reads the same against SQLite, Postgres, or SurrealDB; only the lowering differs (avg → AVG on SQL, math::mean on SurrealDB). Where SurrealDB has no SQL analogue — graph traversals, embedded-array closures, SPLIT — the primitive carries SurrealDB’s own term.

The surface is the Rhai engine built by register_surreal_engine!. The examples below are Rhai; the // → comment shows what each renders to.

The surreal:: namespace

Every free function here, and the me anchor, lives in the surreal:: namespace: surreal::count(), surreal::ident("name"), surreal::me. The SQL builders use sql:: for the same names, so a script names its backend once per call. Methods (.alias, .when, .map, .from, …), the […] indexer and the operators are called on their receiver without a prefix. The tables below leave the prefix out.

Escape hatches

surreal::expr("…") and surreal::fx("name", […]) drop to raw SurrealQL when no primitive fits — surreal::expr("tags"), surreal::fx("crypto::md5", [surreal::ident("email")]). Prefer a named primitive; reach for these only at the edges.

The shared vocabulary

Same names as vantage-sql, lowered to SurrealQL:

PrimitiveSurrealQLNotes
count()count()zero-arg row count
count(expr)count(expr)count truthy / array values
count_distinct(expr)count(array::distinct(expr))
sum(expr)math::sum(expr)
avg(expr)math::mean(expr)SQL name, surreal lowering
min(expr) / max(expr)math::min / math::max
round(expr)math::round(expr)nearest integer
round(expr, n)math::fixed(expr, n)round to n decimals — math::round has no places arg
coalesce(a, b)a ?? bnull-coalescing
nullif(a, b)IF a = b THEN NONE ELSE a END
cast(expr, "int")type::int(expr)also float/string/decimal/datetime/number/bool
date_format(expr, fmt)time::format(expr, "fmt")
surreal::coalesce(surreal::ident("nickname"), "anonymous")   // → nickname ?? "anonymous"
surreal::round(surreal::avg(surreal::ident("price")), 2)     // → math::fixed(math::mean(price), 2)
surreal::cast(surreal::ident("qty"), "float")                // → type::float(qty)

case_when() — multi-branch conditional

Builds a SurrealQL IF … THEN … ELSE … END. Note SurrealQL uses a single trailing END, not one per branch:

surreal::case_when()
    .when(surreal::avg(surreal::ident("price")) >= 250, "premium")
    .when(surreal::avg(surreal::ident("price")) >= 150, "mid")
    .else_("value")
    .expr()
// → IF math::mean(price) >= 250 THEN "premium" ELSE IF math::mean(price) >= 150 THEN "mid" ELSE "value" END

Surreal-specific functions

Stats, collection, string, and time helpers — each a single-purpose name over a math::/array::/ object::/string::/time:: function:

PrimitiveSurrealQL
first(expr)array::first(expr)
len(expr)array::len(expr)
stddev(expr)math::stddev(expr)
median(expr)math::median(expr)
lower(expr)string::lowercase(expr)
words(expr)string::words(expr)
object_entries(expr)object::entries(expr)
object_values(expr)object::values(expr)
similarity(expr, term)string::similarity::jaro_winkler(expr, "term")
time_group(expr, unit)time::group(expr, "unit")

similarity and time_group bind their second argument (the search term, the bucket unit) as a parameter, like any other string operand.

Field and element access

Dotted paths and indexing use the […] indexer rather than a separate primitive:

surreal::ident("department")["name"]      // → department.name
surreal::ident("nutrition")["sugar"]      // → nutrition.sugar
some_subquery[0]                          // → (…)[0]   (integer index)

surreal::ident("t")["col"].alias("x") projects a path: col_path AS x.

Graph traversal

SurrealDB replaces joins with graph paths (->edge->table, <-edge<-table). One positional primitive expresses both directions — exactly one argument is the anchor (surreal::me, the current record, or a nested surreal::graph(…)), and its position sets direction:

surreal::graph(surreal::me, "placed", "order")      // → ->placed->order   (anchor left = outward)
surreal::graph("reports_to", surreal::me)           // → <-reports_to      (anchor right = inward)
surreal::graph(surreal::me, "reports_to", "employee")["name"]   // → ->reports_to->employee.name

Mixed direction comes from nesting — each graph() appends one directed hop:

surreal::graph("client", "placed", surreal::graph(surreal::me, "placed", "order"))
// → ->placed->order<-placed<-client

recurse(path, min, max) wraps a path in ranged recursion:

surreal::recurse(surreal::graph("employee", "reports_to", surreal::me), 1, 5)["name"]
// → @.{1..5}(<-reports_to<-employee).name

Why position, not direction names?

An out/in/up/down vocabulary either assumes a hierarchy or names the arrow rather than the meaning. Anchoring by position keeps the call reading like the path it produces, and composition (nesting) carries mixed direction without per-edge glyph overrides.

Select clauses

Surreal-only clauses on the select builder:

surreal::select().value().expression(surreal::max(surreal::ident("total"))).from("order").group_all()
// → SELECT VALUE math::max(total) FROM order GROUP ALL

surreal::select().field("price").from("product").split("tags")
// → SELECT price FROM product SPLIT tags

.subquery() parenthesizes a select so it composes as a scalar expression — it bridges a select into the expression layer, where it pairs with the [n] indexer, .alias(), comparisons, and from():

surreal::select().value().expression(surreal::max(surreal::ident("total")))
    .from("order").where(surreal::ident("client") == surreal::parent("id")).group_all()
    .subquery()[0].alias("biggest_order")
// → (SELECT VALUE math::max(total) FROM order WHERE client = $parent.id GROUP ALL)[0] AS biggest_order

Parameters

param(name) is any SurrealDB $-parameter ($auth, $this, $parent, a LET-bound name). “Parameter” is SurrealDB’s own term for $-prefixed names, so the primitive is named for it — and not var, which Rhai reserves as a keyword.

surreal::param("auth")["id"]        // → $auth.id
surreal::parent("id")               // → $parent.id   (sugar for the correlated-subquery case)

Embedded-array closures

SurrealDB’s array.map/fold/filter take an inline closure — the one place it exceeds the SQL vocabulary. These are written as native Rhai closures, not a closure-as-data constructor: each parameter is bound to a placeholder expression and the closure runs symbolically, so every operator and indexer in the body builds SurrealQL instead of computing a value. A #{…} map lowers to an object literal, […] to an array literal, and * + - / render parenthesized. map, fold and filter are methods, so they take no namespace.

surreal::ident("lines").map(|l| #{
    product:  l["product"]["name"],
    subtotal: l["quantity"] * l["price"]
})
// → lines.map(|$value| { product: $value.product.name, subtotal: ($value.quantity * $value.price) })

surreal::ident("lines").fold(0, |acc, l| acc + l["quantity"] * l["price"])
// → lines.fold(0, |$acc, $value| ($acc + ($value.quantity * $value.price)))

The closure parameter name is engine-chosen

A Rhai local can’t carry a $, so the engine never sees your |l| — it binds its own placeholders ($value for the item, $acc for a fold accumulator). The emitted SurrealQL therefore reads |$value| …, not |$l| …. This is cosmetic: SurrealQL doesn’t care about the parameter name, and the result executes identically.

Checklist

  • Reach for a named primitive first; it handles the lowering, quoting, and escaping.
  • Call free functions through surreal::; call methods, indexers and operators on their receiver.
  • Use the vantage-sql name when the concept overlaps (count, avg, round, coalesce, case_when, date_format) — the same name works under sql::.
  • Build field paths with the ["col"] indexer, graph paths with surreal::graph() + surreal::me, and mixed direction by nesting rather than glyphs.
  • Write array closures as native |l| … Rhai closures; expect engine-chosen $value/$acc parameter names in the output.
  • Drop to surreal::expr("…") / surreal::fx(…) only when no primitive fits.

HTTP APIs: REST & GraphQL

The vantage-api-client crate reads tables from HTTP APIs. RestApi turns a table into GET {base_url}/{endpoint}?…, and GraphqlApi turns it into one query document posted to an endpoint. Both send through vantage-api-pool’s ResilientClient, which caps parallel requests, retries, rate-limits and trips a circuit breaker per API.

Both drivers read a YAML spec through their Vista factory, the same way the database drivers do. The universal keys (name, id_column, columns, references) work as everywhere else, with two differences in references: REST takes multi-column keys: but not a column-level references:, and GraphQL takes a column-level references: but not keys:. Each driver adds one table-level block: api: for REST and graphql: for GraphQL. GraphQL also has a column-level graphql: block.

Neither driver picks its own quicksearch columns. Both read the shared rule: the columns flagged searchable, or every column when none is flagged. GraphQL sends the search to the server when the table’s search setting allows it. A REST Vista, and a GraphQL one with search off, refuses the search and leaves it to the consumer, which applies the same rule to the rows it holds.

Settings that belong to the endpoint as a whole (base URL, auth, response envelope, paging parameter names, timeouts) are set once on the RestApi / GraphqlApi builder. Every table built from that API shares them. The YAML blocks carry only what differs per table.

Both drivers read only. Their Vistas advertise no write capability, so a write is refused with an Unsupported error before any request, and so are sum, min and max on the typed tables. Send a write yourself with RestApi::http_request or GraphqlApi::post_graphql. These go through the same client, so they share its cap, breaker and observer.

Pages

REST: the api: block

A REST spec is a RestApiVistaSpec. Its only driver-specific key is the table-level api: block. Columns and references use the universal spec keys, with no api: block of their own.

name: albums
columns:
  id: { type: int, flags: [id] }
  userId: { type: int }
  title: { type: string, flags: [title] }
api:
  endpoint: users/{userId}/albums
references:
  photos: { table: photos, kind: has_many, foreign_key: albumId }
use vantage_api_client::{ResponseShape, RestApi};
const ALBUMS_YAML: &str = r#"
name: albums
columns:
  id: { type: int, flags: [id] }
  userId: { type: int }
  title: { type: string, flags: [title] }
api:
  endpoint: users/{userId}/albums
references:
  photos: { table: photos, kind: has_many, foreign_key: albumId }
"#;
const PHOTOS_YAML: &str = r#"
name: photos
columns:
  id: { type: int, flags: [id] }
  albumId: { type: int }
  title: { type: string, flags: [title] }
"#;
fn main() -> vantage_core::Result<()> {
let api = RestApi::builder("https://jsonplaceholder.typicode.com")
    .response_shape(ResponseShape::BareArray)
    .build();
let mut factory = api.vista_factory();
factory.register_yaml(ALBUMS_YAML)?;
factory.register_yaml(PHOTOS_YAML)?;
let albums = factory.build("albums")?;
let _ = albums;
Ok(())
}

Building the factory sends nothing; the first read does. The block is compiled but not run, because RestApi::build makes a reqwest client, which reads the system’s proxy settings.

Table keys

KeyTypeDefaultMeaning
api.endpointstringthe spec namePath under the API’s base URL. May hold {placeholder} segments.

The whole api: block is optional. Unknown keys are a parse error.

Endpoint templates

Each {placeholder} is filled at request time from the eq-condition on the column of that name, and that condition is used up. Any eq-condition left over goes into the query string (?title=x), or is applied to the fetched rows when the API was built with FilterStrategy::Client. A placeholder with no condition to fill it is an error (No eq-condition provided for URI placeholder), so nothing is sent.

So users/{userId}/albums, narrowed to userId = 1, fetches GET /users/1/albums. Traversal from a user to its albums arrives at that URL without any code.

Columns

typeColumn
int, integer, i64, i32i64
float, double, f64, f32f64
bool, booleanbool
string, text, str (default)String
json, anyraw CBOR value

Any other type fails the build. flags: works as in every driver (id, title, hidden, …). A column with a lazy: script is computed from each fetched record. It needs the crate’s rhai feature.

A row is keyed by its id column. A row whose id is missing, null, empty, or not a string or number is keyed ~row:<position>, where the position is absolute in the result. Two rows with the same id in one response are an error.

References

references: takes the universal form: table, kind (has_one / has_many) and foreign_key, or keys: for a multi-column join. Traversal from a row reads each join value from the parent row and gives the target one eq-condition per key. Each condition then fills a placeholder in the target’s endpoint, becomes a query parameter, or filters rows, by the rules above.

  • has_one: the parent’s foreign_key value narrows the target’s id column.
  • has_many: the parent’s id narrows the target’s foreign_key column.
  • keys:: a list of { from, to } pairs, a parent column to a target column. When given, the pairs are the whole join and foreign_key is ignored. A deployment narrowed by both its product and its version is keys: [{ from: product_id, to: product_id }, { from: version_id, to: version_id }].

The REST factory reads the references: map only. A column-level references: (the userId: { type: int, references: users } form other drivers accept) is parsed but doesn’t become a relation, so declare REST joins in the map.

Targets are found by name. register_yaml keeps them in the factory’s own registry. If the models live across several drivers, with_model_resolver(resolver) takes an Arc<dyn VistaResolver> that returns a built Vista for a name instead: a closure, or a Catalog.

Builder settings

The block is deliberately small. Everything that describes the API, rather than one table, is set on RestApi::builder(base_url):

Builder methodDefaultMeaning
response_shape(ResponseShape)Wrapped { array_key: "data" }Where the rows are: BareArray, Wrapped { array_key }, WrappedByTableName
pagination_params(PaginationParams)page_limit("_page", "_limit")Paging parameter names; skip_limit("skip", "limit") for 0-based offsets. Setting it enables windowed reads
no_pagination()offSend no paging parameters; the first page is the whole set
total_key("count")noneEnvelope key holding the total row count
filter_strategy(FilterStrategy)QueryClient keeps non-path conditions off the wire
ordering(OrderingParams::new("ordering", "-"))noneSort parameter; makes every column orderable
auth(..) / auth_refresher(..) / bearer_source(..)noneAuthorization header: fixed, refreshed after a 401, or asked for on every request
max_parallel, rate_limit, request_timeout, observer, http_client4, none, 30 s, none, defaultTransport; see the crate’s README_transport.md

When each signed-in session has its own token, build one RestApi and hand each session api.with_bearer_source(source): a clone that shares the client (connection pool, cap, breaker) but asks its own source for the token on every request. Don’t give the shared API an auth_refresher, since the client adds that header itself and a clone can’t drop it.

A REST Vista can always count and traverse. It advertises Window when the API pages (a total_key puts the grand total on every page), and Order when ordering is set. It never advertises Search, so the Vista refuses a quicksearch rather than send one the API can’t apply. A Dio over it searches the rows it holds instead, over the columns flagged searchable, or every column when none is flagged.

GraphQL: the graphql: blocks

A GraphQL spec is a GraphqlApiVistaSpec. It has a table-level graphql: block for the root field and what the server accepts, and an optional column-level graphql: block for a column whose server field has a different name.

name: launches
id_column: id
columns:
  id: { type: string, flags: [id] }
  mission: { type: string, flags: [title, searchable], graphql: { field: mission_name } }
  launch_year: { type: int }
  rocket_id: { type: string }
  rocket.rocket_name: { type: string }
references:
  rocket: { table: rockets, kind: has_one, foreign_key: rocket_id }
graphql:
  root_field: launches
  dialect: hasura
  paginate: true
  count: aggregate
use vantage_api_client::{GraphqlApi, GraphqlApiVistaSpec};
use vantage_vista::VistaFactory;
const LAUNCHES_YAML: &str = r#"
name: launches
id_column: id
columns:
  id: { type: string, flags: [id] }
  mission: { type: string, flags: [title, searchable], graphql: { field: mission_name } }
  launch_year: { type: int }
  rocket_id: { type: string }
  rocket.rocket_name: { type: string }
references:
  rocket: { table: rockets, kind: has_one, foreign_key: rocket_id }
graphql:
  root_field: launches
  dialect: hasura
  paginate: true
  count: aggregate
"#;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let api = GraphqlApi::new("https://hasura.example.com/v1/graphql");
let spec: GraphqlApiVistaSpec = serde_yaml_ng::from_str(LAUNCHES_YAML)?;
let launches = api.vista_factory().build_from_spec(spec)?;
let _ = launches;
Ok(())
}

build_from_spec comes from the VistaFactory trait, so it needs use vantage_vista::VistaFactory;. The block is compiled but not run: building the API makes a reqwest client, which reads the system’s proxy settings.

Table keys

KeyTypeDefaultMeaning
root_fieldnamethe spec nameQuery field the rows are read from
dialectgeneric | hasurathe API’s dialectHow filters are spelled; lowercase only
filter_argnamethe API’s, else where (Hasura) / find (Generic)Name of the filter argument
argsmapthe API’s root_argsLiteral arguments always passed to the root field
response_pathdotted paththe API’sPath walked into the root field’s value to reach the rows (edges.node)
filterbooltrueConditions may be sent to the server
orderbooltrue on Hasura, else falseorder_by: may be sent
searchbooltrue on Hasura, else falseQuicksearch may be sent as _ilikes; never with filter: false
paginateboolfalselimit: / offset: may be sent
countlist | aggregatelistHow get_count is answered
count_fieldnamethe API’s, else <root_field>_aggregateAggregate field for count: aggregate

The whole block is optional, and unknown keys or values are a parse error. Every name the block puts into a query is checked against the GraphQL name grammar ([_A-Za-z][_0-9A-Za-z]*, dotted paths segment by segment) when the Vista is built.

The block’s filter, order, search, paginate and count override the API’s Supports for that table, key by key. A key left out keeps the builder’s setting, and the default in the table above applies only when the builder left it unset too.

Dialects

DialectFilterOperators
hasuralaunches(where: {launch_year: {_eq: 2018}})the full set, plus _and / _or / _not
genericlaunches(find: {launch_year: 2018})equality only

A condition the dialect can’t spell fails before anything is sent. On Generic, a Vista contains filter is refused as unsupported, so the consumer filters locally.

filter: false

This is for a root field that takes no filter argument. Conditions, including traversal conditions, are applied to the fetched rows instead. Search, operators and paging switch off, and counts list.

Paging

With paginate: true and filter on, the Vista advertises Window and serves fetch_window, sent as $limit / $offset variables. It pages by offset only, so it never advertises Cursor. With filter: false a server page would be cut short by the local filter, so paging is not offered. On Hasura, a paged read with no order of its own is ordered by the id column so that pages stay stable. A Generic schema can’t sort, so its pages are only as stable as the server’s natural order.

Counting

count: list lists the rows and counts them, which works for every role. count: aggregate (Hasura, with filter on) sends <count_field>(where: …) { aggregate { count } }. That needs a role with allow_aggregations. If the server rejects the query, the count fails with an error that suggests count: list; it never falls back silently. Generic tables always list.

Search and ordering

A quicksearch ORs _ilike over the columns every driver searches: those flagged searchable, or every column when none is flagged (Table::search_columns). _ilike takes text, so when nothing is flagged GraphQL keeps only the string-typed columns other than the id. Dotted columns are then skipped. The rule is settled before they are, so a table whose only searchable columns are dotted matches nothing rather than widening to every string column. With no column left the search matches nothing. %, _ and \ in the text are escaped. Ordering a dotted column is refused.

Column keys

KeyTypeDefaultMeaning
graphql.fieldnamethe column nameServer field the column reads

The query selects an aliased column as mission: mission_name, so rows keep the column name. Conditions, sort keys and search on the column are sent as the server field. Both names must be plain (undotted).

A dotted column (rocket.rocket_name) renders as a nested selection, rocket { rocket_name }. A lazy: column is computed from each fetched row and never selected (crate feature rhai).

typeColumn
int, integer, i32i32
bigint, i64, longi64
float, double, f64, f32f64
bool, booleanbool
string, text, str, id (default string)String
datetime, timestamp, timestamptzDateTime<Utc>
date, timeNaiveDate, NaiveTime
uuidUuid
jsonserde_json::Value

Get-by-id sends an integer literal for an int id column, and a quoted string otherwise. A row whose id is null or empty is keyed ~row:<position>. Two rows with the same id in one response are an error.

References

A relation is declared in one of two places:

  • a references: entry: table, kind (has_one / has_many) and foreign_key;
  • a column-level references:, for a has-one keyed by the column itself. rocket_id: { type: string, references: rockets } is a has-one named rocket_id, joining rocket_id to the id of rockets. An entry of the same name in the references: map wins.

Multi-column keys: on a references: entry are refused when the Vista is built, with an unsupported error naming the relation. A GraphQL filter argument narrows by one field per relation here, so the driver refuses a join it would only half apply.

Traversal gives the target an eq-condition: has_one narrows the target’s id by the parent’s foreign_key, and has_many narrows the target’s foreign_key by the parent’s id. A text key is sent as an integer when the target column is an int.

A target is built from its own spec on the factory’s API, without the parent table’s graphql: overrides. Targets come from the factory’s registry (register_yaml, then build(name)), or from a spec resolver:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::Arc;
use vantage_api_client::{GraphqlApi, GraphqlApiVistaSpec};
use vantage_vista::SpecResolver;
let api = GraphqlApi::new("https://hasura.example.com/v1/graphql");
let specs: HashMap<String, GraphqlApiVistaSpec> = HashMap::new();
let resolver: Arc<dyn SpecResolver<GraphqlApiVistaSpec>> =
    Arc::new(move |name: &str| specs.get(name).cloned());
let factory = api.vista_factory().with_resolver(resolver);
}

Any Fn(&str) -> Option<GraphqlApiVistaSpec> closure is a SpecResolver; a spec is looked up each time a traversal builds its target, so the target always reflects the current spec.

A reference to a model with no spec is an error naming the relation and the target, and no request is sent.