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 current 0.6 release line.

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; operations are idempotent wherever possible, so retrying is always an option.
  • 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 ends by moving the same app from SQLite to PostgreSQL with a single switch.

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():

#![allow(unused)]
fn main() {
let db = SqliteDB::connect("sqlite:products.db?mode=ro")
    .await
    .context("Failed to connect to products.db")?;
}

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() {
let select = SqliteSelect::new()
    .with_source("product")
    .with_field("name")
    .with_field("price");

println!("{}", select.preview());
// 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

#![allow(unused)]
fn main() {
let result = db.execute(&select.expr()).await?;
println!("{:?}", result);
}

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() {
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
}

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() {
let is_deleted = Column::<bool>::new("is_deleted");
let condition = is_deleted.eq(false);
}

Same result, 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() {
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
}

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
#![allow(unused)]
fn main() {
use vantage_types::prelude::*;
}

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:

#![allow(unused)]
fn main() {
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
// ...
}

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:

#![allow(unused)]
fn main() {
let price_cbor = rec["price"].value();
println!("{:?}", price_cbor);
// Integer(Integer(120))
}

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:

#![allow(unused)]
fn main() {
#[entity(SqliteType)]
struct Product {
    name: String,
    price: i64,
}

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);
}
}

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:

#![allow(unused)]
fn main() {
let products = Product::table(db);

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

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:

#![allow(unused)]
fn main() {
// "notify customers who have an unpaid invoice older than 30 days"
let mut overdue = Invoice::table(db);
overdue.add_condition(overdue["status"].eq("unpaid"));
overdue.add_condition(overdue["issued_at"].lt(days_ago(30)));

overdue.ref_customer().send_reminder().await;
}
#![allow(unused)]
fn main() {
// "apply a discount to customers who spent more than $500 this month"
let mut customers = Customer::table(db);
customers.add_expression("spent_this_month", |c| {
    c.subquery_orders().only_this_month().field_sum("total")
});
customers.add_expression("discount", |c| {
    primitives::ternary(c["spent_this_month"].gt(500), 10, 0)
});
}

Don’t worry about the exact syntax — we’ll get to all of it. 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 keyreplace() and delete() are idempotent
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:

#![allow(unused)]
fn main() {
// 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))
    }
}
}

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:

#![allow(unused)]
fn main() {
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"

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

// Sum query for a specific column
let sum_query = table.get_sum_query(&table["price"]);
// SELECT SUM("price") FROM "product"
}

These return the same SqliteSelect and Expression types from chapter 1 — the table just assembles them for you. 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:

#![allow(unused)]
fn main() {
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".to_string(), &muffin).await?;

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

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

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.

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 duplicating the replace() and delete() calls — the result is the same. Replacing a record that already has the new values is a no-op. Deleting a record that’s already gone succeeds silently. This makes table operations safe to retry without worrying about side effects.


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:

#![allow(unused)]
fn main() {
async fn list_table<E: Entity + std::fmt::Debug>(
    table: &impl ReadableDataSet<E>,
) -> VantageResult<()> {
    for (id, entity) in table.list().await? {
        println!("  {}: {:?}", id, entity);
    }
    Ok(())
}
}

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:

#![allow(unused)]
fn main() {
// src/category.rs
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")
            .with_many("products", "category_id", Product::table)
    }
}
}

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:

#![allow(unused)]
fn main() {
let categories = Category::table(db.clone());
let products = categories.get_ref_as::<Product>("products")?;
}

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:

mod category;
mod product;

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):

#![allow(unused)]
fn main() {
.with_one("category", "category_id", Category::table)
}

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:

#![allow(unused)]
fn main() {
.with_expression("category", |t| {
    t.get_subquery_as::<Category>("category")
        .unwrap()
        .select_column("name")
})
}

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.

Here’s the updated src/product.rs:

#![allow(unused)]
fn main() {
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")
            })
    }
}
}

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:

#![allow(unused)]
fn main() {
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(())
}
}

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")
})
}

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:

#![allow(unused)]
fn main() {
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()
    }
}
}

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:

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

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

With both traits in place, code reads naturally:

#![allow(unused)]
fn main() {
let products = categories.ref_products();
let expensive = products.with_condition(products.price().gt(200));
}

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:

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

#[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)
    }
}
}

src/product.rs:

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

#[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)
    }
}
}

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:

mod category;
mod product;

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:

#![allow(unused)]
fn main() {
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)
                // ...columns unchanged...
                .with_many("products", "category_id", |db| Product::table(db).clone())
        })
    }
}
}

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 add conditions — like with_condition, with_search, with_pagination, and with_order — consume self and return a new Table. 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:

#![allow(unused)]
fn main() {
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()
    }
}
}

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:

#![allow(unused)]
fn main() {
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())
}
}

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:

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/categories", get(list_categories))
    .route("/categories/{id}/products", get(list_category_products));
}

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:

#![allow(unused)]
fn main() {
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,
{
    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();
                    WritableDataSet::<E>::delete(&f(db(), &params), &id).await.unwrap();
                    StatusCode::NO_CONTENT
                }
            }),
        )
}
}

Five handler bodies, each tiny, all generic over the entity type. 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:

#![allow(unused)]
fn main() {
.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:

#![allow(unused)]
fn main() {
.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:

#![allow(unused)]
fn main() {
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()
    }
}
}

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:

#![allow(unused)]
fn main() {
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>;
}

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>:

#![allow(unused)]
fn main() {
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()))
    }
})
}

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

#![allow(unused)]
fn main() {
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))
    }
})
}

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":"patch_table_value: no row found (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 still 500s for now — patching doesn’t go through get, so there’s no Option to intercept; adding a pre-flight get-and-ok_or(not_found) before the patch call would give you 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:

#![allow(unused)]
fn main() {
use axum::extract::Query;
use vantage_table::pagination::Pagination;

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

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

#![allow(unused)]
fn main() {
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.add_search(term);
        }
        let rows = t.list().await?;
        ApiResult::Ok(Json::<Vec<E>>(rows.into_values().collect()))
    }
})
}

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.
  • add_search(term) is the .with_search we used in chapter 2 to add a LIKE filter across all columns. Both end up as extra WHERE 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 add_order(column.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, you can simply narrow with let mut t = Category::table(db()) .clone(); t.add_order(sqlite_expr!("{}", ident("name")).ascending());. 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 = "0.6"
vantage-dataset = "0.6"
vantage-expressions = "0.6"
vantage-mongodb = "0.6"
vantage-table = "0.6"
vantage-types = { version = "0.6", 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:

#![allow(unused)]
fn main() {
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())
        })
    }
}
}

src/product.rs gets the symmetric changes:

#![allow(unused)]
fn main() {
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())
        })
    }
}
}

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:

#![allow(unused)]
fn main() {
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()
    }
}
}

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:

#![allow(unused)]
fn main() {
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.

#![allow(unused)]
fn main() {
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,
}

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

#![allow(unused)]
fn main() {
// 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();
}

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/vantage_axum.rs (one file, ~120 lines) — everything HTTP: ApiError, ListQuery, and the crud<E, F> helper. Accepts any entity E that implements Entity<AnyMongoType> plus the usual serde bounds.

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.

src/main.rs — now down to ~30 lines:

mod category;
mod db;
mod product;
mod vantage_axum;

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.
#![allow(unused)]
fn main() {
.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. Add conditions, search, and ordering through the Vista API
  4. Fetch paginated results with fetch_page and fetch_next
  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 capability flags.

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.

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:

#![allow(unused)]
fn main() {
use vantage_sql::prelude::*;
use vantage_vista::Vista;

let table = Product::table(db.clone());
let vista = SqliteVistaFactory::new(db).from_table(table)?;
}

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 it would be MongoVistaFactory, for AWS it would be AwsVistaFactory — same shape, different import. The resulting Vista is identical regardless of which factory produced it.


Reading schema

Once you have a Vista, everything is runtime introspection:

#![allow(unused)]
fn main() {
// Columns — name, original type, flags
for name in vista.get_column_names() {
    let col = vista.get_column(name).unwrap();
    println!("{}: {}", col.name, col.original_type);
}
// name: String
// price: i64
// is_deleted: bool

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

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

// References — relationships declared on the typed table
for (name, kind) in vista.list_references() {
    println!("ref: {} ({:?})", name, kind);
}
// ref: products (HasMany)
}

Column carries an original_type string (preserved from the typed column definition) and a set of flags. The standard flag vocabulary:

FlagMeaning
"id"This column is the primary key
"title"Human-readable label column
"searchable"Included in quicksearch
"orderable"Can be sorted server-side
"hidden"Don’t show in default views
"mandatory"Required on insert

Flags are open — drivers and consumers can add their own.


Adding conditions

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:

#![allow(unused)]
fn main() {
let mut v = vista.clone();
v.add_condition_eq("category_id", 1.into())?;

let rows = v.list_values().await?;
// Only products with category_id == 1
}

.into() converts the i64 into a CborValue. The driver’s shell translates that into a native condition — Expression for SQL, bson::Document for MongoDB, AwsCondition::Eq for AWS — and pushes it onto the wrapped table.

Conditions mutate the shell

Unlike Table’s .with_condition() (which consumes and returns a new table), Vista’s add_condition_eq mutates in place. Vista is a runtime handle — there’s no builder pattern to preserve. The mutability rules:

OperationSemantics
add_condition_eq, with_idAccrete — each call narrows further; none can be removed
add_search, add_orderReplace — calling again swaps the previous one out
WideningClone first — narrow the clone, keep the original

A narrowed Vista is a different, smaller set — not a view you toggle filters on.

Narrowing by id

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

#![allow(unused)]
fn main() {
let mut v = vista.clone();
v.with_id("7")?;
let row = v.get_some_value().await?;
}

with_id reads the id column name from the schema and calls add_condition_eq for you. Returns &mut Self so you can chain.


Search and ordering

#![allow(unused)]
fn main() {
let mut v = vista.clone();

// Quicksearch — fans across columns flagged SEARCHABLE
v.add_search("tart")?;

// Sort by column
v.add_order("price", SortDirection::Descending)?;

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

Both are replace semantics — calling again drops the previous filter/order. Both return an error if the driver doesn’t support them. Check capabilities first (or just 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 declares its capability flags accordingly:

#![allow(unused)]
fn main() {
let caps = vista.capabilities();
if caps.can_search {
    v.add_search("query")?;
}
if caps.can_order {
    v.add_order("name", SortDirection::Ascending)?;
}
}

Calling a method the driver doesn’t advertise returns an Unsupported error. This is by design — it’s better to fail clearly than to silently return unfiltered results.


Pagination

Vista exposes two pagination primitives. Not every driver supports both — capabilities tell you which to use.

Offset pagination: fetch_page

Random-access — jump to any page by number:

#![allow(unused)]
fn main() {
let mut v = vista.clone();
v.set_page_size(25)?;

let page1 = v.fetch_page(1).await?;   // first 25 rows
let page2 = v.fetch_page(2).await?;   // next 25 rows
}

Works for SQL databases, MongoDB, anything with LIMIT … OFFSET. Requires can_fetch_page.

Cursor pagination: fetch_next

Forward-only — each call returns a token for the next:

#![allow(unused)]
fn main() {
let v = vista.clone();

let (rows, token) = v.fetch_next(None).await?;        // first page
let (rows, token) = v.fetch_next(token).await?;      // second page
// token is None when exhausted
}

The token is opaque — its shape is driver-private (a DynamoDB LastEvaluatedKey, a REST nextToken, an offset counter). Just round-trip it. Requires can_fetch_next.

When neither is available

If both can_fetch_page and can_fetch_next are false, the driver has no native pagination. Fall through to list_values() which returns everything. This is 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:

#![allow(unused)]
fn main() {
// Given a category row, traverse to its products
let products = vista.get_ref("products", &category_row)?;
for (id, row) in products.list_values().await? {
    println!("  {} — {:?}", id, row["name"]);
}
}

get_ref needs a Record<CborValue> — the parent row. It reads the join field (e.g. category_id) out of that row to build the eq-condition on the target. You can’t traverse from a Vista alone; you need a row first. Typical flow: fetch the parent with get_some_value() or list_values(), then traverse from each row.

get_ref routes in order:

  1. Contained relations — records embedded in a column of the parent row (an order’s lines array) resolve first, 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.

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 vantage-vista-factory’s VistaCatalog: register a loader per model name, declare the relation, and catalog.traverse_from("category", "products", &row) resolves 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 struct of booleans. The driver sets each flag to reflect what it actually implements:

#![allow(unused)]
fn main() {
let caps = vista.capabilities();
println!("can_count: {}", caps.can_count);
println!("can_insert: {}", caps.can_insert);
println!("can_update: {}", caps.can_update);
println!("can_delete: {}", caps.can_delete);
println!("can_order: {}", caps.can_order);
println!("can_search: {}", caps.can_search);
println!("can_fetch_page: {}", caps.can_fetch_page);
println!("can_fetch_next: {}", caps.can_fetch_next);
}

A CSV file sets can_count and nothing else — it’s read-only. A SQL database sets everything. AWS DynamoDB sets can_count and can_fetch_next (cursor-only) but not can_fetch_page (no random access).

Calling unsupported methods is an error

The capability flags aren’t suggestions — they’re a contract. If can_search is false, calling add_search() returns an Unsupported error. If a flag is true but the driver forgot to implement the method, you get an Unimplemented error instead. Both are VantageError variants you can match on.

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


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_vista::Vista;

async fn print_vista(vista: &Vista) -> VantageResult<()> {
    let columns = vista.get_column_names();
    let caps = vista.capabilities();

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

    // Count
    if caps.can_count {
        println!("  ({} rows)", vista.get_count().await?);
    }

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

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
add_condition_eqNarrow results to field == value
with_idConvenience: narrow by primary key
add_search / add_orderQuicksearch and sorting (replace semantics)
fetch_page / fetch_nextOffset and cursor pagination
get_refTraverse a relationship from a parent row
VistaCatalogCross-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 ends by moving the app from SQLite to PostgreSQL with a single switch, proving the backend was never load-bearing.

Take Path B → — steps 5–7.

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 paginate a listing. 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 immediatelyEnqueued — routed by policy
ChangesYou re-queryYou re-queryAnnounced on an event bus
CapabilitiesWhatever the backend supportsHonestly advertisedExtended — the cache fills the gaps
ModeTransactionalTransactionalLive
LifecycleA definition — cheap to clone, narrowed per useA handle — built, narrowed, droppedLong-lived — 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.vista() 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 possible Lens pumps the master into the cache once, when the Dio loads:

#![allow(unused)]
fn main() {
let lens = Arc::new(
    Lens::new()
        .cache_at("cache.redb")
        .on_start(|dio| {
            let dio = dio.clone();
            async move {
                // Pump: read everything from the master, write it to the cache.
                let rows = dio.master().list_values().await?;
                dio.cache().insert_values(rows).await
            }
        })
        .build()?,
);

let dio = lens.make_dio(vista).await?;   // runs on_start, returns a warm Dio
}

With the Dio in place, there are two ways to consume it. The first is the facade Vistadio.vista() — 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, because the Lens decides what to add: a read-only master gains writes when the Lens routes them somewhere, and counting is always on the menu because the cache can count. 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.vista() 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.vista() to chapter 4’s print_vista and it just works — it never learns a cache is underneath. 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 solves that — it carries the master’s capabilities, and the Lens can manipulate and extend them. Our read-only S3 listing gains can_insert the moment an on_write callback gives writes somewhere to go. The honesty contract still holds; the facade just advertises what the pipeline can do, not what the backend alone can.

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 againsubscribe() → generation channel
CapabilitiesThe master’s, plus whatever the Lens addsOrdering, 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 6augmentation: 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.


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() {
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 — build the table, list it, print:

mod files;

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:

#![allow(unused)]
fn main() {
let master = aws
    .vista_factory()
    .from_table(File::table(aws.clone(), BUCKET, PREFIX))?;
}

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

#![allow(unused)]
fn main() {
let caps = master.capabilities();
// can_count: true    can_fetch_next: true — and that's it.
// can_search: false   can_order: false   can_insert: false
}

can_fetch_next is worth pausing on — it’s the one capability this chapter leans on. fetch_next(token) is Vista’s cursor-style 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 callback — on start, synchronize the cache with the bucket:

#![allow(unused)]
fn main() {
let lens = Arc::new(
    Lens::new()
        .cache_at("cache.redb")
        .on_start(|dio| {
            let dio = dio.clone();
            async move { sync(&dio).await }
        })
        .build()
        .context("Failed to build lens")?,
);
let dio = lens.make_dio(master).await?;
}
  • .cache_at("cache.redb") opens a redb file on disk. This is why the cache survives process restarts. An in-memory alternative, .cache_in_memory(), is there for when persistence isn’t wanted.
  • .on_start(...) fires once, inside make_dio. Callbacks receive &Dio and clone it to hold across .await — a cheap Arc bump; every Lens callback follows this shape.
  • make_dio does the rest in one call: opens a cache table named after the master, spawns the write worker, runs on_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() {
/// 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(dio: &Dio) -> VantageResult<()> {
    let mut token: Option<CborValue> = dio
        .cache()
        .list_values()
        .await?
        .keys()
        .last()
        .cloned()
        .map(Into::into);
    loop {
        let start = Instant::now();
        let (page, next) = dio.master().fetch_next(token).await?;
        let count = page.len();
        dio.cache().insert_values(page.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.

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 directly (dio.master() to fetch, dio.cache() to store and resume); a consumer asks dio.vista() 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:

#![allow(unused)]
fn main() {
let start = Instant::now();
let listing = dio.vista().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());
}

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 — wipe and re-pump:

#![allow(unused)]
fn main() {
if std::env::args().any(|a| a == "--invalidate") {
    dio.cache().clear().await?;
    sync(&dio).await?;
}
}
$ cargo run -- --invalidate
fetched 0 files in 370ms
fetched 100 files in 215ms
...
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 make_dio set up, invisibly: every Dio carries a broadcast event bus. Anything that changes data announces it there as a DioEventDatasetChanged when the set of records was rewritten wholesale, RecordChanged / RecordInserted / RecordRemoved for row-level changes, Refreshing when a reconcile starts, WriteFailed when a queued write fails. 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 dio.cache().insert_values(...), which stores rows and announces nothing — harmless while nothing listens, invisible data the moment something does. When observers exist, either use the Dio’s row-level helpers that write and announce in one motion — patched(id, record), removed(id) — or follow a bulk cache write with notify_dataset_changed(), the “re-read everything” announcement. Chapter 7’s live view does exactly that.

Poll or push — the Lens doesn’t care

Our S3 listing has no way to notify us, so freshness is pulled: sync on start, --invalidate by hand, or refresh_every(duration) on a timer (chapter 7’s live view uses it). Backends that can push — a SurrealDB live query, a Kafka topic, a webhook — feed ChangeEvents into dio.handle_event(...) and reconcile through an on_event callback instead, using the same row-level helpers. Same cache, same events — only the trigger differs.

Writes, on a read-only master

S3’s listing Vista advertises can_insert: false, and the facade won’t pretend otherwise — by default. Register an on_write callback on the Lens and the write queue becomes yours: each queued WriteOp is handed to your closure, which can append to a journal, call a different API, or write a queue — this is exactly the introduction’s “a read-only CSV file accepts writes by routing them into a queue”. Without on_write, queued ops are applied to the master directly, and a master that can’t take them surfaces DioEvent::WriteFailed on the bus — never a silent drop.

Callback summary

CallbackWhen it firesIn this chapter
on_startOnce at make_diosync — pump pages, resume from cache
on_refreshrefresh() + timer(not used — chapter 7 reconciles on a timer)
on_writeEvery WriteOp(not used — S3 listing is read-only)
on_eventUpstream ChangeEvent(not used — S3 can’t push)
on_list_pageScenery list passChapter 7
on_load_detailScenery detail passChapter 7
total_provider / on_load_chunkScenery loadingChapter 7

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 infrastructure: cache, callbacks, refresh policy
DioBinding of master Vista + Lens; owns cache, queue, event bus
cache_at / CacheBackendPersistent (redb) or in-memory storage for cached rows
fetch_next(token)One page per call; S3’s token is the last key — durable
dio.cache() / dio.master()The two sides of the Dio, directly addressable from callbacks
dio.vista()Facade Vista: reads from cache, writes through the queue
DioEventBus event: invalidated, record-level changes, write failures

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() {
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. See Config-Driven Vistas.

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 — this is the only structural addition to chapter 5’s run():

#![allow(unused)]
fn main() {
let augmenter = aws
    .vista_factory()
    .from_table(Readings::table(aws.clone(), BUCKET))?;

let dio = lens.make_dio(master).await?.augment(
    Arc::new(VistaCatalog::new()),
    vec![Augmentation {
        detail: Detail::Fixed(Arc::new(augmenter)),
        source: Source::Column {
            from: "Key".into(),
            to: Some("prefix".into()),
        },
        fetch: Fetch::PerRow,
        merge: MergeRule {
            columns: vec!["rows".into(), "latest".into()],
        },
    }],
);
}

An Augmentation answers four questions:

  • detailwhere do detail records come from? Detail::Fixed holds our augmenter Vista directly. (Detail::Catalog(name) resolves one by name instead — the config-driven form, which is also why augment takes a VistaCatalog. Building an empty catalog just to satisfy the signature is admittedly awkward; the parameter earns its place only in the catalog form.)
  • sourcehow 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.
  • fetchFetch::PerRow: one detail fetch per master row.
  • mergewhich 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.vista() 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() {
// The listing stays instant — cheap rows, no downloads.
let listing = dio.vista().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 window = dio.vista().fetch_window(0, 10).await?;
for (filename, file) in &window {
    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}");
}
}

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() {
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::CatalogDetail Vista held directly, or resolved by name from a catalog
Source::Column { from, to }Master column → detail condition (our Keyprefix)
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 view variant, shared when identical
LifecycleLong-lived; owns cache and background tasksOpened by a view, dropped with it
ConsumerLens callbacks, 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) — 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 running total that ticks upward as data lands.

Measure chapter 6’s ending against that. It read from the Dio with dio.vista().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 the moment it opens — whatever the cache already holds, with standing in for details still pending;
  • every landed detail updates its own row and bumps the Generation — one tick, one repaint;
  • set_viewport(range) 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 listing still streaming in and the first two visible rows already hydrated:

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 15586 · ↑/↓ 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() {
        // 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. Three additions to the builder:

#![allow(unused)]
fn main() {
let lens = Arc::new(
    Lens::new()
        .cache_at("cache.redb")
        .on_start(|dio| {
            let dio = dio.clone();
            async move { sync(&dio).await }
        })
        // Don't wait for the sync: the UI opens on whatever the cache
        // holds (nothing, on a first run) and rows stream in behind it.
        .on_start_blocking(false)
        // The scenery's list pages come straight from the warmed cache —
        // zero network. The master is only contacted by the refresh
        // reconcile and the per-row detail fetches.
        .on_list_page(|dio, q| {
            let dio = dio.clone();
            async move {
                Ok(dio
                    .cache()
                    .list_values()
                    .await?
                    .into_iter()
                    .skip(q.offset)
                    .take(q.limit)
                    .collect())
            }
        })
        // 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.
        .refresh_every(Duration::from_secs(60))
        .build()
        .context("Failed to build lens")?,
);
}

on_start_blocking(false) detaches the sync: make_dio returns at once and on_start runs as a background task. The UI’s first frame shows an empty table; a few minutes later it shows 122,000 rows — filling in page by page in between, a thousand at a time. For that to be visible, the sync itself gains one line — after each landed page it announces the change on the event bus, and every open view re-reads:

#![allow(unused)]
fn main() {
        dio.cache().insert_values(page.into_iter().collect()).await?;
        dio.notify_dataset_changed();
}

(The println! narration from chapter 5 is gone — a terminal UI owns the screen now.)

on_list_page needs the most context, because it names how a TableScenery loads. 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 passes are separately pluggable. The detail pass is already defined — it’s chapter 6’s augmentation. The list pass, left alone, would ask the master — a full 122-request S3 walk every time a view (re)builds its spine. But chapter 5 built something better: a cache that is the listing. Registering on_list_page overrides the list pass; the QueryDescriptor argument carries the window (offset, limit — plus conditions, sort, and search when the view has them), and our implementation is a window over the cache. The scenery’s spine now costs zero network.

refresh_every 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.

Creating the reactive UI

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

The table

#![allow(unused)]
fn main() {
// 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?;
}

.open() seeds the scenery from the cache, 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(range) — more on this in a moment;
  • subscribe() — the generation channel from the start of the chapter.

The builder can also chain sort(col, dir), search(text), and where_eq(col, value) — 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.

A UI rarely commits to one order at open time, though — and it doesn’t have to reopen. The handle mutates in place: set_sort(col, dir) and set_search(text), bound to a key or a header click, re-point the scenery at the ordered index for the new variant, 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. Conditions are the exception: where_eq defines what the view is, so it’s set at open, not toggled on a live scenery.

One thing probably caught your eye: page_size(200_000)?! That’s the list pass — how many spine rows one on_list_page call returns. 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, the first list call builds the entire spine — one cheap 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(range), the method on the handle we just opened. 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 running total

#![allow(unused)]
fn main() {
// Grand total of the ROWS column — recomputes reactively as files
// hydrate, so the status bar counts up while data arrives.
let totals = dio.value_scenery().sum("rows").open().await?;
}

Hold on — how can it sum rows when most rows aren’t augmented yet? It doesn’t wait for them:

  • it recomputes over the cache whenever the Dio announces a change;
  • rows that have no rows column yet are skipped — the sum covers what has been observed so far;
  • each detail fetch that merges rows into the cache fires RecordChanged; the scenery recomputes and bumps its generation only when the value actually moved.

So the number in the status bar is honest about coverage: it starts at zero and climbs with hydration, one landed file at a time.

(There is a third kind, RecordScenerydio.record_scenery(id) — 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 running total, a detail pane — and identical opens share one instance under the hood (the sharing key is the scenery’s query — sort, search, conditions — plus the columns it demands; two views asking the same question get the same scenery). The other half of that contract: release them when the page closes. Dropping the handle stops the scenery’s tasks and withdraws its demand, so a closed page stops pulling data. Chapter 8 meets the one case where sharing is wrong — two remote viewers asking the same question but scrolling different pages — and opts out per scenery.

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() {
    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 our total rows sum ticks live.
  • .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 runs 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. Within a second the first thousand filenames arrive; the row counter keeps climbing as the background sync streams pages, passing 122,000 a few minutes in. 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 and total rows sum tick upward with each one. Move the cursor, and the band follows; jump to End, and the last stations of the alphabet get their turn. 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
ValueSceneryReactive aggregate — count, sum, max, min, or custom
RecordSceneryOne record by id, same subscribe/redraw contract
Generation / subscribe()Latest-value watch channel — one bump, one repaint
Two-pass loadingList pass builds the spine; detail pass hydrates rows in view
on_list_pagePlug the list pass — ours serves pages from the chapter-5 cache
set_viewport(range)Declares what’s visible — and thereby what hydrates
on_start_blocking(false)UI first: make_dio returns immediately, the sync streams behind it
refresh_every + notify_dataset_changedStanding freshness: reconcile on a timer, announce every change
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 simply 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?

Until now, each scenery ran its own detail fetches, inline, for its own viewport. One terminal, one viewport — fine. But 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 had no way to say “me next”. The fetches were nobody’s job to coordinate.

They are now the Dio’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 scenery; when the last 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::new().augment_workers(4) hydrates four rows at a time when the detail source can take it.

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

One builder flag is new. Sceneries de-duplicate: two identical opens share one instance, which is right for two widgets showing the same grid — but wrong for two clients watching different pages of the same query, where each connection must keep its own viewport. The adapter opens its sceneries with .exclusive(): never shared, still counted in the demand union — the Dio hydrates the union of the columns every open scenery demands — and still 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;

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();
}

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&…An .exclusive() TableSceneryStreams 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:

#![allow(unused)]
fn main() {
let cache = Arc::new(RedbCache::open("cache.redb").context("Failed to open cache")?);
let contents = ContentsCache::new(cache.open_table("contents").await?);
}

A Lens normally opens cache.redb itself (cache_at); cache_source(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, on_start stays blocking (the default — learn-6 turned it off to open its UI on an empty table). A server should answer its first request from a warm cache, so make_dio runs the chapter-5 sync to completion before axum::serve ever binds the port. On a restart the sync 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() {
let app = axum::Router::new()
    .nest("/api/files", api)
    .fallback_service(ServeDir::new("frontend/dist"));

axum::serve(TcpListener::bind("0.0.0.0:3007").await?, app).await
}

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 simply proceeds.

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() {
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() {
pub async fn get_or_fetch<F, Fut>(&self, key: &str, fetch: F) -> VantageResult<String>
}

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() {
.with_lazy_expression("contents", move |row| {
    // …
    async move {
        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
augment_workers(n)Worker pool size — 1 (deterministic order) by default
.exclusive()A scenery that never shares — 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 on_startPre-fetch: the port opens only once the listing is cached
cache_source + 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. It is a fully capable backend, which raises a fair question: if the database already does all that, what is a caching-and-events layer even 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 — reads answered from memory, not 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 straight 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 exactly 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” will sell items so the data actually moves. This chapter builds the cache; the next serves it as a live watch; the last moves the whole thing to PostgreSQL without touching a line of 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. That is the whole bet of the four-layer model — 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 exactly the way the Introduction built one: hand the table to its datasource’s Vista factory.

#![allow(unused)]
fn main() {
let db = SqliteDB::connect(&url).await?;
let master = db.vista_factory().from_table(Product::table(db.clone()))?;
}

Ask this Vista what it can do and it answers yes to everything — can_order, can_fetch_window, can_insert, can_update, can_delete. Contrast that with the S3 path, where the Vista admits it cannot sort or search. There is no gap to fill here, so there is no capability injection in this path. What we still want is the cache.

The lens: eager and reactive

A slow, paginated backend like S3 is loaded a page at a time, resuming from a cursor. A SQL table needs none of that ceremony — it hands back the whole set in one call — so this path uses the eager shape of a Lens: load everything on start, then reconcile on a timer.

#![allow(unused)]
fn main() {
let lens = Arc::new(
    Lens::new()
        .cache_in_memory()
        .on_start(|dio| {
            let dio = dio.clone();
            async move {
                let rows = dio.master().list_values().await?;
                dio.cache().insert_values(rows).await?;
                Ok(())
            }
        })
        .on_refresh(|dio| {
            let dio = dio.clone();
            async move {
                let rows = dio.master().list_values().await?;
                dio.cache().clear().await?;
                dio.cache().insert_values(rows).await?;
                Ok(())
            }
        })
        .refresh_every(Duration::from_secs(1))
        .build()?,
);
let dio = lens.make_dio(master).await?;
}

Three builder calls carry the whole behaviour:

  • cache_in_memory() — the cache is ephemeral; a restart re-reads the table. (The S3 path persists its cache with cache_at("cache.redb") because re-listing a remote bucket is expensive. Re-reading a local table is not.)
  • on_start — before the server answers a single request, list_values() pulls the entire table into the cache. make_dio blocks on this, so the first read is already warm.
  • on_refresh + refresh_every — once a second, the cache is rebuilt from the master. This is what makes the copy live: a sale the till commits, or an edit you type into sqlite3, is reconciled on the next tick, and a DatasetChanged event goes out to every watcher. The clear-then-reload is deliberate — a product that has sold out is simply absent from the master’s answer, so clearing first drops it cleanly.

Reads come from the cache

From here on, reads never touch SQLite. make_dio returned a Dio, and everything a consumer asks of it — a listing, a page, a single record — is answered from the in-memory cache the on_start warmed. The database is consulted only by the once-a-second reconcile, never by a reader. The next chapter puts a DioRouter over this Dio and you’ll see a plain GET return the shelf instantly, without a query reaching the table.

That is the inversion the whole guide has been climbing toward: the backend is 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 shape changed — eager full-load instead of paged, in-memory instead of redb, no augmentation because a SQL row already carries its columns. What didn’t change is everything above make_dio: the Dio, its event bus, and the sceneries and watch server the next chapter opens over it are byte-for-byte the same code the S3 path uses. The reactive stack never learns which backend it sits on.

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

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 neither knows nor cares 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.

#![allow(unused)]
fn main() {
use vantage_api_adapters::axum_dio::DioRouter;

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?;
}

A plain GET is the cache, answered instantly — 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 just 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 so often takes a delivery. These are ordinary SQL writes, on the same connection the master reads from:

#![allow(unused)]
fn main() {
// src/sim.rs — the interesting line
sqlx::query(
    "UPDATE product SET stock = stock - 1
     WHERE id = (SELECT id FROM product WHERE stock > 0 ORDER BY RANDOM() LIMIT 1)",
)
.execute(db.pool())
.await?;
}

The server itself never calls the till and the till never calls the server. They meet only through the database and the once-a-second reconcile — which is precisely the point: any writer, in any process, moves the shelf. The sqlite3 CLI will do just as well, as we’ll see.

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 the moment 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, a 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, never DELETED — it diffs each page by position. So 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. Getting 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 entirely. 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, 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.

Next: the same app, the same shelf, the same watch — served from PostgreSQL instead of a file, 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 — concurrent writers, a second instance, a backup story — you reach for a real database server. In most stacks that is a migration project. Here it is a refactor that touches one new file and 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 exactly 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 — the two backends implement one interface — so connect() is the whole of the connection difference: a file path on one side, a DATABASE_URL on the other.

Two attributes on the model

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

#![allow(unused)]
fn main() {
// 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, the id — does not move. #[entity(...)] is a list of the backends a struct serves, not a choice of one; listing PostgresType alongside SqliteType just generates a second set of conversions. The same Product could serve six backends at once, as bakery_model3 does.

Everything else — main.rs, sim.rs, the lens, the Dio, the DioRouter — is unchanged. It was written against Db and the portable data-set methods, so it never named a backend to begin with. The reconcile still calls 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() — all of 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 genuinely differ (SQLite’s INTEGER for booleans vs Postgres’s real BOOLEAN, say), the difference lives in the schema strings in db.rs — never in the model or the app.

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 exactly as it did on SQLite — same routes, same NDJSON, 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}}

And the it-really-is-the-database proof works over Postgres too — edit a row with psql, 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, 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. This is not a toy path: the repo’s tests/postgres/6_vista.rs exercises a Vista over a real Postgres, and the launch-control example deploys the identical model to AWS Lambda against Aurora Postgres while defaulting to SQLite locally.

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 — paged versus eager, capability-injecting versus not — but above make_dio they run the same Dio, the same sceneries, and the same watch adapter. That is the claim the whole guide was built to earn: 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.

There is still 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 real-time push: the database tells us the moment a row changes.

Real-Time Push with LISTEN/NOTIFY

The app so far reconciles on a timerLens::refresh_every(Duration::from_secs(1)). That is honest polling: once a second the Dio re-reads the master and diffs. 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 — it can’t call you back. A PostgreSQL server can: it can tell you the instant a row changes, and then the poll disappears.

This chapter does exactly that. Having proven the app is portable, we commit to Postgres — drop the feature flag, delete sim.rs, and split the writer into its own process — and replace the refresh timer with a single dio.watch(). 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 fires pg_notify on every write; anyone LISTENing on that channel wakes up. So the only schema we add beyond the table is a trigger:

#![allow(unused)]
fn main() {
// src/db.rs — after CREATE TABLE product (...)
sqlx::query(
    "CREATE OR REPLACE FUNCTION product_notify() RETURNS trigger AS $$
     BEGIN PERFORM pg_notify('product_changed', ''); RETURN NULL; END;
     $$ LANGUAGE plpgsql",
).execute(db.pool()).await?;

sqlx::query(
    "CREATE TRIGGER product_notify_trg
     AFTER INSERT OR UPDATE OR DELETE ON product
     FOR EACH STATEMENT EXECUTE FUNCTION product_notify()",
).execute(db.pool()).await?;
}

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).

Tell the Vista the trigger exists

Postgres offers no way to ask “is anything going to notify me on this channel?” — LISTEN on a channel nobody feeds succeeds and then waits forever. A Vista that assumed it could push just because the table is writable would advertise a feed that may never arrive, and no consumer could tell the difference between that and a quiet table. So the application, which is the only party that knows, says so:

#![allow(unused)]
fn main() {
let mut master = db
    .vista_factory()
    .with_notify(true)   // we installed product_notify_trg above
    .from_table(Product::table(db.clone()))?;
}

That one call is what makes can_watch() answer true in the next section. Leave it off and dio.watch() becomes a no-op — which matters here, because this chapter is about to delete the refresh timer. Push and a refresh_every poll are the two ways the cache learns about a write; an app that declines both never updates.

One line replaces the poll

The server builds the Dio exactly as before — an eager in-memory cache, an on_start that loads it and an on_refresh that reloads it — but with no refresh_every. Instead:

#![allow(unused)]
fn main() {
let dio = lens.make_dio(master).await?;

// The transparent live feed. The master Vista advertises `can_watch`, so this
// subscribes over LISTEN/NOTIFY and reconciles the instant a write lands — no
// polling timer.
dio.watch().await?;
}

That is the whole change. Dio::watch() asks the master Vista whether it can push (Vista::can_watch()); because we opted in above, the Postgres Vista answers yes and opens a PgListener on the product_changed channel. Each notification drives one reconcile. If the backend couldn’t push, watch() would be a no-op and a refresh_every timer would carry the load instead — the same call is correct either way, which is the point of the capability.

The server still never writes

dio.watch() only reads and reconciles. Nothing in the server process writes to product. That separation is what makes the demo honest: whatever you see on screen arrived over the database.

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 — no hand-written SQL. list_entities() hands back drinks that each carry their own id and datasource, so selling one is just:

#![allow(unused)]
fn main() {
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
}
}

Two independent processes that share nothing but the database and the notify channel: the strongest possible proof that the reactive stack tracks the data, not some 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 real removal rather than a silent list-shrink:

#![allow(unused)]
fn main() {
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();
}

With key_by("id"), the watch stream gains a third event — DELETED — alongside ADDED and MODIFIED. A small React page (frontend/index.html, served by the same axum app) consumes the stream and animates each drink: arriving with a flash, ticking down as it sells, and disposing with a “sold out” stamp when a DELETED lands. It is plain React over the watch endpoint — not part of Vantage — and, notably, the exact same file the next chapter reuses 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 real 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 dio.watch() learns only that something changed and responds by re-reading the whole set to reconcile. That is perfectly correct and, for a shelf of a few dozen drinks, instant. But it is a coarse signal: one write means one full reconcile. Hold that thought — the next chapter switches to a database whose push carries the changed row itself, so no re-query is needed at all.

Next: the same app, the same frontend, a different database — SurrealDB, whose native live queries push the actual change 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 entirely: multi-model, schemaless by default, and reached over a WebSocket rather than a pooled SQL connection. Adopting it is the real test of the claim the whole path has been making. The goal here is to change as little as possible — and then to cash in the one thing SurrealDB does better than a SQL server: it can push the actual change, not just a ping.

The finished crate is learn-11. Set it next to learn-10 and the diff is small and boring — which is the result we want.

What changes

Three touch-points name the backend, and nothing else does.

The entity marker — swap the backend in the attribute list:

#![allow(unused)]
fn main() {
#[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() {
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 — notably — no trigger. Where Postgres needed a pg_notify function and an AFTER … FOR EACH STATEMENT trigger, SurrealDB emits change frames on its own. Setup is just making sure the table exists:

#![allow(unused)]
fn main() {
let client = SurrealConnection::dsn(&dsn)?.connect().await?;
// The one bit of schema. SurrealDB streams changes natively via LIVE SELECT,
// so there is nothing else to wire.
client.query("DEFINE TABLE IF NOT EXISTS product SCHEMALESS", None).await?;
let db = SurrealDB::new(client);
}

The till (the mutator) is the same active-entity code as before; only the id it hands new_entity is a Thing:

#![allow(unused)]
fn main() {
table.new_entity(
    Thing::new("product", key),          // was: a String id
    Product { name, price, stock, created },
).save().await?;
}

What doesn’t change

The server’s reactive core is untouched. The Vista comes from SurrealVistaFactory instead of db.vista_factory(), and then every line is identical to the Postgres chapter — including the one that matters:

#![allow(unused)]
fn main() {
let mut master = SurrealVistaFactory::new(db.clone())
    .from_table(Product::surreal_table(db.clone()))?;
master.add_order("created", SortDirection::Ascending)?;

let dio = lens.make_dio(master).await?;   // same Lens, same cache, same on_start/on_refresh
dio.watch().await?;                        // the same call — now backed by LIVE SELECT
}

And the frontend is byte-for-byte 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 clearest statement the guide can make that the reactive layer, up to and including the UI, does not depend on the backend.

The payoff: precise push, no re-query

dio.watch() looks the same, but underneath it is doing something better than it could on Postgres. SurrealDB’s LIVE SELECT delivers a typed, per-row notification — the action (CREATE / UPDATE / DELETE) and the affected record — for every change. So the Dio applies each change directly to its cache: 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 already 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.)

One capability, three freshness models

The product shelf has now run behind three backends, and the app above make_dio never changed:

BackendHow dio.watch() learns of a changeGranularity
SQLitea refresh_every timer (it can’t push)poll, whole-set
PostgreSQLLISTEN/NOTIFY — a payload-less ping → reconcilecoarse push, re-read on each write
SurrealDBLIVE SELECT — the action + the rowfine-grained push, apply the row directly

Poll, coarse push, fine push — the same dio.watch() call, chosen transparently from what the Vista advertises. 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 the asymmetry in what each one costs you: SurrealDB is watchable the moment you build the Vista, while the PostgreSQL row above 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.

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

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.

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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.

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
// 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:

#![allow(unused)]
fn main() {
// 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:

#![allow(unused)]
fn main() {
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.

egui, GPUI, Slint, Tauri, Cursive, Ratatui — same data, six frameworks


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.

#![allow(unused)]
fn main() {
// 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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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

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:

#![allow(unused)]
fn main() {
// 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:

#![allow(unused)]
fn main() {
// 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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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.

#![allow(unused)]
fn main() {
// 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.

#![allow(unused)]
fn main() {
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 builderSurrealSelect, 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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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.

#![allow(unused)]
fn main() {
// 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:

#![allow(unused)]
fn main() {
// 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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
// 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 traversalIN 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 gapsVec<u8> (binary data) and Uuid need type trait implementations across backends. Bind/read paths exist — just missing the impl XxxType wiring.

Query buildersql_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

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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
// 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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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.

#![allow(unused)]
fn main() {
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 queriescan_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 orderingadd_order is single-column for now; the signature already accommodates the multi-column future.

Type system gapsVec<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.

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() {
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)
}

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.

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() {
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);
}

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() {
let condition = sqlite_expr!("{} > {}", (ident("price")), 100i64);
let query = sqlite_expr!("SELECT {} FROM {} WHERE {}",
    (ident("name")), (ident("product")), (condition));
}

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() {
let row1 = sqlite_expr!("({}, {})", "tart", 220i64);
let row2 = sqlite_expr!("({}, {})", "pie", 299i64);
let rows = Expression::from_vec(vec![row1, row2], ", ");
}

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() {
// 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:

#![allow(unused)]
fn main() {
// 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?;
}

AssociatedExpression<'a, DS, T, R> carries both the expression and a reference to the datasource. Call .get() 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:

#![allow(unused)]
fn main() {
// 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
}

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() {
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"))), false)
    .with_limit(Some(10), None);
}

Builder methods come free from the trait — with_source, with_field, with_condition, with_order, 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() {
let count = select.as_count();                              // SELECT COUNT(*) FROM ...
let total = select.as_sum(sqlite_expr!("{}", (ident("price")))); // SELECT SUM("price") FROM ...
}

SelectableDataSource — wiring it up

SelectableDataSource<T> connects the query builder to execution:

#![allow(unused)]
fn main() {
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>> {
        self.execute(&select.expr()).await
    }
}
}

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.


Expressive trait

Anything that implements Expressive<T> can be used inside an expression. This includes:

  • Columnstable["price"]
  • Operationstable["price"].gt(100)
  • Identifiersident("name")
  • Query buildersselect.expr()
  • Sort orderstable["name"].desc()
  • Scalar values42i64, "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.

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).

#![allow(unused)]
fn main() {
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?;
}

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).

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.

Expression scopes never register an invariant. You can also set one explicitly with Table::with_invariant(column, value) / add_invariant.

On every insert / replace / patch, 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).

#![allow(unused)]
fn main() {
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());
}

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.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use vantage_table::prelude::{Hook, Phase};

let launches = Launch::table(db).with_hook(Hook::BeforeInsert(Phase::Populate, Arc::new(stamp_created)));
}

The Hook variants

One enum carries a placement-specific closure, so each hook receives exactly what’s available at its stage. Before-write hooks get the record (mutable) and the entity-erased table; the delete hook gets the id and the row’s former contents:

variantwhenreceivesmay
BeforeInsert(Phase, _)before an insert&mut Record, &Tablemutate / Err to cancel
BeforeUpdate(Phase, _)before replace/patch&mut Record, &Tablemutate / Err to cancel
BeforeSave(Phase, _)before insert and update&mut Record, &Tablemutate / Err to cancel
BeforeDelete(_)before a delete&Id, &Record (former), &TableErr to veto, or HookReturn::Handled to take over
AfterInsert / AfterUpdate / AfterSaveafter the write commits&Id, &Record, &Tableside-effects
AfterDelete(_)after a delete&Id, &Record (former), &Tableside-effects

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.

Ordering and control flow

  • Before-write hooks run ahead of invariant enforcement, ordered by Phase: NormalizePopulate (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; delete_all fires no hooks.
  • 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.

Writing a hook

Hooks are boxed async closures. The reliable construction is a free fn returning a boxed future whose lifetime ties to the arguments, then Arc::new(it):

#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use vantage_core::Result;
use vantage_types::Record;
use vantage_sql::sqlite::{AnySqliteType, SqliteDB};
use vantage_table::table::Table;

// before-insert: stamp an audit field
fn stamp_created<'a>(
    rec: &'a mut Record<AnySqliteType>,
    _t: &'a Table<SqliteDB, vantage_types::EmptyEntity>,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
    Box::pin(async move {
        rec.insert("created".into(), AnySqliteType::new(now_iso8601()));
        Ok(())
    })
}

// before-delete: soft-delete instead of removing the row
fn soft_delete<'a>(
    id: &'a String,
    _former: &'a Record<AnySqliteType>,
    table: &'a Table<SqliteDB, vantage_types::EmptyEntity>,
) -> Pin<Box<dyn Future<Output = Result<vantage_table::prelude::HookReturn>> + Send + 'a>> {
    Box::pin(async move {
        let mut patch = Record::new();
        patch.insert("deleted".into(), AnySqliteType::new(now_iso8601()));
        table.patch_value(id.clone(), &patch).await?;
        Ok(vantage_table::prelude::HookReturn::Handled)
    })
}
}

To pull in outside context (e.g. an updated_by actor), have the hook capture an Arc or read a task-local — the closure runs in async context. A capturing closure works too, but needs its boxed return type annotated explicitly so it coerces to the hook type.

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 the VistaCatalog additionally lets you register relations between catalog models — including targets 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() {
.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:

#![allow(unused)]
fn main() {
pub trait ClientMailLog {
    fn ref_mail_log(&self) -> Table<Mailchimp, MailLogEntry>;
}

impl<'a> ClientMailLog for ActiveEntity<'a, Table<SqliteDB, Client>, Client> {
    fn ref_mail_log(&self) -> Table<Mailchimp, MailLogEntry> {
        let mut log = MailLogEntry::table(mailchimp());
        log.add_condition(log["email"].eq(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 Mailchimp-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() {
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() {
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:

#![allow(unused)]
fn main() {
#[entity(CsvType, SurrealType, SqliteType, PostgresType, MongoType, DynamoType)]
#[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() {
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”:

#![allow(unused)]
fn main() {
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")?;
}

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:

#![allow(unused)]
fn main() {
let orders = paying.ref_orders();   // Table<SqliteDB, Order> — type inferred, no string
}

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() {
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. Insert into it, and the new record belongs to the set you traversed to:

#![allow(unused)]
fn main() {
client.ref_orders().insert_return_id(&order).await?;
}

The order’s client_id is filled in automatically — you don’t set it, and if you set a conflicting one the write is rejected. 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 full range of traversal forms: set-to-set (fetching and embedding), row-in-hand, record-level, and contained.
  • Expressions & Subqueries — correlated lookups, counts, and how relation-derived expressions compose.
  • Implicit References — dotted column names, the declarative way to pull a field across a relation.
  • Vista, YAML and Rhai — vista factories, traversal after type erasure, capability checks, and declaring relations in YAML and Rhai.
  • Relations and Dio — where cross-datasource enrichment lives.

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:

#![allow(unused)]
fn main() {
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)
}

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.

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:

#![allow(unused)]
fn main() {
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
}

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:

#![allow(unused)]
fn main() {
let orders = clients.get_ref_from_row::<Order>("orders", &row)?;
// WHERE "client_id" = <the id read out of row> — one eq-condition
}

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

Sometimes you want the relation’s target table with no condition at all — Table::get_ref_target::<E2>(relation) builds exactly that. Where the traversal forms narrow the target to related rows, get_ref_target 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).

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() {
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 — 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, straight from the select_column rustdoc:

#![allow(unused)]
fn main() {
.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() {
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 aggregatesget_count_query() and get_sum_query(&col) on the subquery table.
  3. Compose expressionsget_column_expr(name) resolves columns and expressions alike, so expressions can build on each other inside one query.
  4. Nest subqueriesselect_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:

#![allow(unused)]
fn main() {
// 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
])?;
}

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:

#![allow(unused)]
fn main() {
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"),
    );
}
}

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 (MongoDB, CSV, REST) — its TableSource::supports_traversal is false, and dotted names are refused up front. 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 and the can_traverse_in_columns capability surface 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, and cardinalities are introspectable, so a consumer can discover them at runtime.
  2. Traversalget_ref still works. Hand it a loaded row, get back a new Vista narrowed to the related rows.
  3. Capabilities — an honest contract about which traversal forms this backend actually serves, so a consumer knows what to offer before trying.

The factory

A Vista is never constructed by hand — construction goes through a vista factory (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 tablefrom_table wraps the table your model already builds:

    #![allow(unused)]
    fn main() {
    let vistas = vec![
        db.vista_factory().from_table(Client::surreal_table(db.clone()))?,
        sqlite.vista_factory().from_table(Product::sqlite_table(sqlite.clone()))?,
        csv.vista_factory().from_table(Order::csv_table(csv.clone()))?,
    ];
    }
  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 — name, target, cardinality, foreign key. 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

Vista exposes the relation metadata directly:

  • get_references() -> Vec<String> — the relation names.
  • list_references() -> Vec<(String, ReferenceKind)> — names paired with cardinality (ReferenceKind::HasOne / HasMany).
  • get_reference(name) -> Option<&Reference> — the full reference entry.
  • list_contained() — contained (embedded) relations with their kind.

This is what lets a UI render relation tabs or context-menu entries without knowing the model. list_references tells it “orders (has_many), country (has_one)” — 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.

Traversal: get_ref on a loaded row

The erased twin of the typed get_ref_from_row:

#![allow(unused)]
fn main() {
pub fn get_ref(&self, relation: &str, row: &Record<CborValue>) -> Result<Vista>
}

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 metadata folded in by the factory supplies the target and foreign key.

There is also get_ref_target(relation) -> Result<Vista> — 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.

Both 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

The same traversal is exposed to Rhai data scripts (the vantage-vista rhai feature). A script holding a row can hop a relation:

let t = table("tag");
let row = t.get_some();
let course = t.get_ref("golf_course", row);

There’s no separate scripting implementation here — the erased Vista is what the script engine wraps, and get_ref there follows the same metadata.

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. The flags relevant to relations:

  • can_traverse_to_record — “Record-level reference traversal via get_ref(relation, row) — read the join value out of a known row and narrow the target with a plain eq-condition. Every backend that can filter by equality supports this (SQL, CSV, Mongo, Surreal, REST/GraphQL).”
  • can_traverse_to_set — “Set-level reference traversal — narrow the target with an IN (subquery) derived from the parent’s own conditions (the get_ref_as / reports path). Requires the backend to support subqueries; SQL and SurrealDB do, CSV/Mongo/REST do not.”
  • can_build_ref_via_script — “Per-reference Rhai-scripted traversal — a reference carrying a build_script resolves through the script engine … rather than the fixed FK eq-condition path.”
  • can_traverse_in_columns — whether the backend can lower a dotted active column (country.name) into its own query; true for SQL and SurrealDB shells, false for CSV/Mongo/REST.

Consumers branch on these before offering the corresponding affordance: a report builder checks can_traverse_to_set before offering set-level aggregation; a grid checks can_traverse_in_columns before letting the user add a dotted column. The flags are also readable by name (capability_flag("can_traverse_to_set")) and appear in the rhai capabilities map, so scripts and config-driven tools can branch the same way.

Across datasources: the VistaCatalog

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. Cross-persistence traversal lives one layer up, in the VistaCatalog (vantage-vista-factory): a name → Vista catalog spanning many datasources, plus reference traversal between the models it holds.

You register models by name — each as a ModelLoader closure that builds a fresh, unconditioned Vista, from whichever backend backs it — and then register relations between catalog models:

#![allow(unused)]
fn main() {
let mut cat = VistaCatalog::new();
cat.register("client", Arc::new(|| /* build the client Vista — one datasource */));
cat.register("bakery", Arc::new(|| /* build the bakery Vista — possibly another */));

cat.register_relation(
    "client",
    Relation::single_key("bakery", "bakery", ReferenceKind::HasOne, "id", "bakery_id"),
);

// From a loaded client row, traverse into the (possibly foreign) bakery model:
let bakery = cat.traverse(&cat.relations_for("client")[0], &client_row)?;
}

A Relation is a single-key (target.foreign_key == parent_row[narrow_via]) or multi-key join description; traverse builds the target by name and pushes one eq-condition per key. How each condition is honoured — SQL WHERE, in-memory filter, REST path/query param — is the target driver’s concern at fetch time.

traverse_from is the unified entry point: it prefers the parent Vista’s own same-persistence reference when the shell declares it and advertises can_traverse_to_record (that path stays entirely inside one driver), and otherwise falls back to a registered cross-persistence Relation. The catalog is also what the Dio layer’s augmentation uses to resolve its detail sources — the next page picks that up.

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:

columns:
  - name: batch
    type: string
    references: batch        # sugar: has_one to table "batch", FK = this column
  - name: batch.name         # implicit reference through that relation
    type: string
    optional: true
references:
  tags:
    table: tag
    kind: has_many
    foreign_key: batch

The column-level references: batch sugar names the relation after the target table, which is the common case. The full form (references: { table, kind, foreign_key, name }) can name it differently — you need that when two relations point at the same table.

When a relation needs more than a plain foreign-key match — extra conditions, ordering, a search — give the reference a rhai: build script:

references:
  recent_orders:
    table: order
    kind: has_many
    foreign_key: client
    rhai: |
      table("order").add_condition_eq("client", row.id).add_order("created_at", "desc")

The script runs lazily when the relation is traversed, with the parent record in scope as row, and must return a Vista — start it with table("<name>") and chain the conventional verbs (add_condition_eq, add_order, add_search, set_page_size, with_id). Without rhai:, the relation falls back to the plain foreign_key match. This is the can_build_ref_via_script path from the capabilities list — the reference resolves through the script engine instead of the fixed eq-condition.

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.

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 relationsget_references, list_references with cardinality, get_reference for the full entry, list_contained for embedded relations.
  2. Traverse from a loaded erased rowget_ref(relation, row) returns a narrowed Vista; get_ref_target(relation) returns the bare target for nested creates.
  3. Branch on the four traversal capabilitiescan_traverse_to_record, can_traverse_to_set, can_build_ref_via_script, can_traverse_in_columns — before offering an affordance.
  4. Traverse across datasources — register models and Relations on a VistaCatalog; traverse_from prefers the same-persistence reference and falls back to the catalog join.
  5. Declare relations in YAML and Rhai — column-level references: sugar, the full top-level form, implicit dotted columns through a relation, and rhai: build scripts for traversals that need more than an FK match — all consumed by the same vista factory that wraps typed tables.
  6. 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), reads 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, call Vista::get_ref(relation, row) to build the narrowed detail Vista — the same erased traversal from the Vistas chapter, or VistaCatalog::traverse_from when the detail may live in another datasource — and put a Dio in front of that for the detail pane. Each narrowed detail set gets its own cache entry, so switching between master rows switches between already-cached detail sets rather than re-narrowing one shared cache.

The join the layers below cannot express

The VistaCatalog can already navigate across datasources — hand it a parent row 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 the VistaCatalog, 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 configured on the Lens. The catalog is what makes the detail side resolvable by name; supplying at least one augmentation is what engages the two-pass behavior:

#![allow(unused)]
fn main() {
let lens = Lens::new()
    .cache_at(cache_path)
    .catalog(catalog)                       // resolves `table:` names
    .augment(vec![augmentation])            // ≥1 engages two-pass
    .build()?;
}

Each augmentation answers four questions — which detail Vista, how a master row selects its detail, how it is fetched, and which columns to lift:

#![allow(unused)]
fn main() {
Augmentation {
    table:  "tfstate_detail".into(),  // catalog name of the detail Vista
    source: Source::Column { from: "key".into(), to: None },
    fetch:  Fetch::PerRow,
    merge:  MergeRule { columns: vec!["resources".into(), "serial".into()] },
}
}
  • 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 today; Batched is planned.
  • merge.columns lists the detail columns to lift. Empty means all; on a name clash the detail wins.

The same declaration works from YAML:

augment:
  - table: tfstate_detail
    source: { kind: column, from: key }
    fetch:  { kind: per_row }
    merge:  [resources, serial, outputs]

Script sources

A { kind: script, code: "self.add_condition_eq(\"key\", row.key)" } source lowers (under the rhai feature) to a Build closure — the same machinery as a reference build-script, pointed at a possibly different persistence.

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) — 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/detailVista::get_ref(relation, row) for the narrowed detail Vista, with a Dio (and its own cache entry) per detail set.
  3. Declare an augmentation — catalog + source + fetch + merge — in Rust or YAML.
  4. 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
 │    ├── 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/
      ├── cli.rs          ← multi-source CLI using Vista
      └── 0-intro.rs      ← direct SurrealDB queries

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() {
#[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.

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() {
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 only SurrealDB gets the computed order_count expression field (which requires correlated subquery support).


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:

#![allow(unused)]
fn main() {
let paying = Client::surreal_table(db)
    .with_condition(clients["is_paying_client"].eq(true));

// Traverse — returns Table<SurrealDB, Order> with subquery condition
let orders = paying.get_ref_as::<SurrealDB, Order>("orders").unwrap();

// The generated query filters orders by paying clients automatically
for (id, order) in orders.list().await? {
    println!("{}: {}", id, order.total);
}
}

Computed fields

with_expression adds fields that don’t exist in the database — they’re computed via correlated subqueries:

#![allow(unused)]
fn main() {
.with_expression("order_count", |t| {
    let orders = t.get_subquery_as::<Order>("orders").unwrap();
    orders.get_count_query()
})
// SELECT *, (SELECT COUNT(*) FROM order WHERE order.client = 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:

#![allow(unused)]
fn main() {
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
}

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). 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 nor an idiom path (e.g. MongoDB, 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 uses a OnceLock<SurrealDB> pattern for global access, with a DSN-based connection function:

#![allow(unused)]
fn main() {
pub async fn connect_surrealdb() -> Result<()> {
    let dsn = std::env::var("SURREALDB_URL")
        .unwrap_or_else(|_| "cbor://root:root@localhost:8000/bakery/v2".into());

    let client = SurrealConnection::dsn(&dsn)?
        .connect().await?;

    set_surrealdb(SurrealDB::new(client))
}
}

Consumers call connect_surrealdb() once at startup, then use Client::surreal_table(surrealdb()) 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:

#![allow(unused)]
fn main() {
// In an Axum handler
async fn list_clients() -> Json<Vec<Client>> {
    let clients = Client::surreal_table(surrealdb())
        .with_condition(clients["is_paying_client"].eq(true));
    Json(clients.list().await.unwrap().into_values().collect())
}

// In a CLI tool
let table = Client::sqlite_table(db);
println!("{} clients", table.get_count().await?);

// In a test — no database needed
let mock = MockTableSource::new()
    .with_data("client", test_data).await;
let table = Table::<MockTableSource, Client>::new("client", mock);
assert_eq!(table.get_count().await?, 3);
}

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:

#![allow(unused)]
fn main() {
let vistas = vec![
    db.vista_factory().from_table(Client::surreal_table(db.clone()))?,
    sqlite.vista_factory().from_table(Product::sqlite_table(sqlite.clone()))?,
    csv.vista_factory().from_table(Order::csv_table(csv.clone()))?,
];

// Same code handles all three — different databases, same interface
for v in &vistas {
    println!("{} records", v.list_values().await?.len());
}
}

See also: bakery_model3/examples/cli.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.

#![allow(unused)]
fn main() {
// Define once
#[entity(SurrealType, SqliteType, PostgresType)]
struct Product {
    name: String,
    price: i64,
    is_deleted: bool,
}

// Use everywhere
let table = Product::surreal_table(db);
let expensive = table.with_condition(table["price"].gt(200));
let count = expensive.get_count().await?;

let mut item = table.get_entity(&id).await?.unwrap();
item.price = 350;
item.save().await?;
}

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_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);
}

PostgreSQL — DISTINCT ON, LATERAL JOIN, array ops

#![allow(unused)]
fn main() {
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 APIsvantage-api-client wraps any paginated JSON API as a read-only TableSource
  • API poolsvantage-api-pool adds connection pooling, prefetching, and rate limiting
  • CSV filesvantage-csv reads structured files with in-memory conditions
  • Local cachesvantage-live syncs fast cache (ImTable, ReDB) with slow backend (Postgres, SurrealDB) 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
#![allow(unused)]
fn main() {
// Read from a REST API
let api_products = Product::api_table(RestApi::new("https://api.example.com/products"));

// Cache locally for fast UI
let live = LiveTable::new(
    Product::surreal_table(db),   // permanent backend
    ImTable::new(&cache, "products"),  // fast cache
);

// Both implement the same traits — your UI code doesn't care
let products: Vec<Product> = live.list().await?.into_values().collect();
}

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          │
│   Table<LiveTable, Product>   ← cached + reactive            │
│                                                              │
│   All 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:

#![allow(unused)]
fn main() {
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.get_id_column(), Some("id"));
let rows = vista.list_values().await?;   // reads the `product` table
}

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, <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

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

Three shapes are available:

  • Full form (above): table, kind (has_one / has_many), foreign_key.

  • Column sugar — when the foreign key is the column, declare it inline:

    columns:
      category_id:
        type: string
        references: category      # has_one, foreign_key = the column itself
    
  • Multi-key joinskeys: lists { to, from } pairs when the child is narrowed by more than one parent field (a deployment matched on both product_id and version_id).

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:

#![allow(unused)]
fn main() {
let resolver: SqliteSpecResolver = Arc::new(move |name| 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 = /* a category row */;
let products = category.get_ref("products", &row)?;
}

The resolver is a plain closure — 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. Without a resolver, a traversal falls back to a column-less target and the next query fails loudly.


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. Rhai appears in a spec in four places, each with a distinct job.

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: |
    select().from("product").field("id").field("name").where(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 clears can_insert / can_update / can_delete, 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(expr("SUM(price) AS total_price"))
        .group_by(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)

A script applied to the finished vista, exposed as self, using the builder verbs plus vendor expressions YAML keys can’t state:

name: active_products
columns:
  id: { type: thing, flags: [id] }
  name: { type: string, flags: [title] }
surreal:
  table: product
  modify: |
    self.with_condition(ident("is_deleted") == false)
        .add_order("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:.

4. Scripted reference traversal — reference-level rhai: (SurrealDB)

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").add_condition_eq("category", row.id)

The script returns the narrowed target vista; foreign_key remains as metadata for consumers that introspect the relation.

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 VistaCatalog

A single Vista is strictly single-backend. When a system spans several — categories in Postgres, products behind a REST API — VistaCatalog sits one layer up: it holds a loader per model name and traverses relations whose target lives in a different persistence:

#![allow(unused)]
fn main() {
let mut catalog = VistaCatalog::new();
catalog.register("category", Arc::new(move || pg_factory.from_yaml(&category_yaml)));
catalog.register("product",  Arc::new(move || api_factory.from_yaml(&product_yaml)));
catalog.register_relation("category", Relation::single_key(
    "products", "product", ReferenceKind::HasMany,
    "category_id",   // target column to constrain
    "id",            // parent-row field whose value narrows it
));

let category = catalog.build_vista("category")?;
let row = /* a category row */;
let products = catalog.traverse_from("category", "products", &row)?;  // Postgres → REST
}

Loaders return a fresh, unconditioned Vista on every call, so the catalog composes with hot-reloading inventories the same way spec resolvers do. The catalog 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 and a handful of read verbs fetch data:

let o = table("orders").add_condition_eq("status", "unpaid").get_some();
if o != () {
    table("orders").get_ref("client", o).get_some()
}

The vocabulary is deliberately small: builder verbs (add_condition_eq, add_order, get_ref …) plus terminals — list, get_some, count, capabilities, columns, references. Every list() is capped (50 rows hard ceiling) — this is an inspection and automation surface, not a bulk reader. 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.


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.

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). dio.watch() is the same call either way — it asks the master Vista whether it can push, subscribes if so, and quietly does nothing if not.

That last part is the bit worth knowing before you pick a backend. watch() returning Ok(()) does not mean you have a live feed; it means nothing went wrong. This page says exactly what each shipped driver delivers today, and what — if anything — you have to do to get it.


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
SQLitepoll
MySQLpoll
MongoDBpoll
CSV filespoll
REST / GraphQLpoll
AWSpoll
Kubernetespoll
CLI toolspoll
Append logs— (write-only sink)n/a

Only three drivers implement watch_vista at all. Everything in the poll rows inherits the trait default, which reports the capability as unsupported and leaves can_subscribe false — so dio.watch() on those is a no-op and a refresh_every timer 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 refresh_every, 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.

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 can_subscribe is simply true 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:

#![allow(unused)]
fn main() {
let master = db
    .vista_factory()
    .with_notify(true)   // yes, I installed the trigger
    .from_table(Product::table(db.clone()))?;
}

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.
  • 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. Dio::watch() already does this for you.

The consequence worth internalising: can_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 slow timer, 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 override watch_vista, and when you may honestly advertise the capability — is step 8 of Adding a New Persistence. Start with the capability false; turning it on the day the feed works is a healthier state than shipping a flag that lies.

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.

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 over 200,000 generated people: the row list, the quick-search box, the row count, and the details panel for the selected row

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, and the YAML above is a BackendShape in a file. This code builds the same datasource:

#![allow(unused)]
fn main() {
use std::time::Duration;
use vantage_faker::{BackendShape, FakerTable, Latency, LatencyModel, StaticEffect};
use vantage_vista::VistaCapabilities;

let table = FakerTable::build_shaped(
    "people",
    columns,                       // Vec<FakerColumn> — name, type, flags
    "id",
    Box::new(StaticEffect { count: 200_000 }),
    BackendShape {
        capabilities: VistaCapabilities {
            can_count: true,
            can_fetch_window: true,
            can_order: true,
            can_search: true,
            ..VistaCapabilities::default()
        },
        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()
    },
);

// From here, this is the normal reactive stack. Nothing is faker-specific.
let (vista, _handle) = table.split();
let dio = lens.make_dio(vista).await?;
}

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.

Keep the handle. If you drop the handle, the effect loop stops, and a live effect then stops changing the data.

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?”.

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.

The grid during a load: the total is known, so the rows exist and the scrollbar has the correct size. The contents have not arrived.

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 twelve 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.
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.
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

You do not select the method. It follows from what the source can do.

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”.

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:

#![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");
}

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() {
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.

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
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. Adds YAML schema loading and condition delegation so callers don’t need a Rust entity struct.

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. 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 extractiontry_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() {
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
}

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() {
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,
        }
    }
}
}

And here’s how the type safety works in practice:

#![allow(unused)]
fn main() {
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() {
let val: AnySqliteType = 42i64.into();
let val: AnySqliteType = "hello".into();
let val: AnySqliteType = true.into();
}

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() {
#[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.into_record();

// 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() {
#[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() {
#[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() {
#[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() {
#[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:

#![allow(unused)]
fn main() {
// 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() {
// Writing — typed, variant enforced
let val = AnySqliteType::new(true);            // type_variant: Some(Bool)
val.try_get::<bool>();   // Some(true) — variant matches
val.try_get::<i64>();    // None — Bool ≠ Integer, blocked by type boundary
val.try_get::<String>(); // None — Bool ≠ 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() {
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:

#![allow(unused)]
fn main() {
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::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() {
let expr = sqlite_expr!("SELECT {}", (Identifier::with_dot("u", "name")));
// → 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:

#![allow(unused)]
fn main() {
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?;
}

Nested expressions let you build multi-row inserts from composable parts:

#![allow(unused)]
fn main() {
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?;
}

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.

#![allow(unused)]
fn main() {
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?;
}

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:

#![allow(unused)]
fn main() {
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<JsonValue> = match result.into_value() {
    JsonValue::Array(arr) => arr,
    _ => panic!("expected rows"),
};
let record: Record<JsonValue> = rows[0].clone().into();
}

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:

#![allow(unused)]
fn main() {
// 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?;
}

From a Record, you can deserialize into a struct. For the #[entity] path:

#![allow(unused)]
fn main() {
#[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)?;
}

Or for the serde path with Record<JsonValue>:

#![allow(unused)]
fn main() {
#[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)?;
}

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:

#![allow(unused)]
fn main() {
// 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>>:

#![allow(unused)]
fn main() {
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() {
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:

#![allow(unused)]
fn main() {
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 extractionself.expr().template gives the column name for simple columns. Complex expressions produce the template string as the field path.
  • ChainingMongoCondition 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:

#![allow(unused)]
fn main() {
// In your prelude:
pub use crate::sqlite::operation::SqliteOperation;
}

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():

#![allow(unused)]
fn main() {
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() {
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\""), false)
    .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() {
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”:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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\""), false);

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");
}

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.
#![allow(unused)]
fn main() {
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 managementcreate_column, to_any_column, convert_any_column:
#![allow(unused)]
fn main() {
    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 factoryexpr():
#![allow(unused)]
fn main() {
    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>:

#![allow(unused)]
fn main() {
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")
    }
}
}

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() {
#[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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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"))?;
}

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"]:
#![allow(unused)]
fn main() {
let mut table = Product::sqlite_table(db);
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), 130));
}
  • Multiple conditions — combined with AND, including field-to-field comparison:
#![allow(unused)]
fn main() {
let mut table = Product::sqlite_table(db);
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), 130));
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), (table["calories"])));
}
  • SqliteOperation::eq() — the idiomatic way:
#![allow(unused)]
fn main() {
use vantage_sql::sqlite::operation::SqliteOperation;

let mut table = Product::sqlite_table(db);
table.add_condition(table["is_deleted"].eq(false));
}

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:

#![allow(unused)]
fn main() {
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);
}

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:

#![allow(unused)]
fn main() {
// 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();
}

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:

#![allow(unused)]
fn main() {
let mut clients = client_table(db);
clients.add_condition(sqlite_expr!("{} = {}", (clients["is_paying_client"]), true));

let orders = clients.get_ref_as::<SqliteDB, 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);
}

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)).

#![allow(unused)]
fn main() {
.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.

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
// Wrap any typed Table<Driver, Entity> as a backend-agnostic Vista:
let products = Product::sqlite_table(db).vista_factory().from_table(Product::sqlite_table(db));
// (or surreal/csv/mongo — the call site is identical)
}

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 fully-featured persistence — typed Table<T, E>, conditions, relationships, the lot. That’s exactly what business logic wants. It’s also exactly what generic code can’t consume.

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 string field name shouldn’t have to compile against your backend’s condition type. Vista is the bridge: a schema-bearing handle that wraps a typed Table and exposes it through a uniform, CBOR-typed surface.

This step adds Vista support to your backend. The work is small — one factory, one source, a few hundred lines — but it’s the doorway through which UI, CLI, and config-driven tooling start seeing your database at all.

Before you start

Vista is a thin layer over what Steps 1–6 already gave you. Most of the work here is decisions rather than code. Confirm you have:

  • A working TableSource impl (Step 4) — Vista’s read/write path delegates to it unchanged.
  • A native value type with an Into<CborValue> story (Step 1). If your value type is already JSON- shaped you’re done; otherwise you need a dedicated bridge (see cbor.rs below).
  • An id type that round-trips through StringFromStr and Display impls. If the native id doesn’t have these yet, add them before going further. The vista boundary stringifies ids unconditionally.

What Vista actually is

Vista is a concrete struct in vantage-vista (no consumer-facing trait surface). It owns universal metadata — name, columns, references, capabilities, id column — and a boxed TableShell that does the real work. Your job as a driver author is two-fold:

  1. A factory that produces a Vista from either a typed Table<YourDB, E> or a YAML schema.
  2. A source that implements TableShell — the per-driver executor Vista delegates to.

Both construction paths converge on the same source-creation code. That’s a deliberate constraint: post-construction Vista usage is fully database-agnostic, so the same UI/CLI/script drives a Mongo Vista, a SurrealDB Vista, or your CSV one without caring how it got built.

Why not just hand around Table<T, E>?

You can! Anywhere the entity is known at compile time, Table<T, E> is the better tool — it’s typed, it composes, and it’s what you’ve spent six steps building. Vista exists for the cases where the entity isn’t known at compile time, or where the backend itself is chosen at runtime:

  • A CLI driven by --source surreal --table client list — no Client struct in scope.
  • A YAML-driven admin tool that reads schema from disk.
  • A Rhai callback running in an editor that filters rows by a string column name.

For those cases you need erasure. The price of erasure is that values become CborValue and ids become String at the boundary; the type system from Step 1 doesn’t propagate any further. That’s fine — generic code is rendering values, not deserialising into structs.

Cargo wiring

The bridge is opt-in via a vista feature so non-Vista users don’t transitively pull in vantage-vista:

# in your backend's Cargo.toml
[features]
default = []
vista = ["dep:vantage-vista"]

[dependencies]
vantage-vista = { path = "../vantage-vista", optional = true }

Everything Vista-related — the factory module, the source, the YAML extras — sits under #[cfg(feature = "vista")]. The TableSource path is unaffected, and downstream crates compile without vantage-vista in their tree.

File layout

Both in-tree drivers (CSV and MongoDB) converged on the same shape:

<driver>/src/vista/
├── mod.rs       re-exports + <Driver>::vista_factory() inherent impl
├── spec.rs      <Driver>TableExtras / <Driver>ColumnExtras / <Driver>VistaSpec
├── factory.rs   <Driver>VistaFactory + impl VistaFactory + spec→table helpers
├── source.rs    <Driver>TableShell + impl TableShell
└── cbor.rs      native ↔ CBOR bridge (only when native value type ≠ JSON-shaped)

CSV doesn’t have cbor.rsFrom<AnyCsvType> for CborValue already lived in the type-system module for the AnyTable path, and the source reuses it. MongoDB’s bson::Bson needs a richer bridge (ObjectId, DateTime, Timestamp, Decimal128 each have lossy paths) so it gets a dedicated file. Pick whichever fits — the trait shape doesn’t change either way.

The factory

Drivers expose a vista_factory() inherent method on the data source struct, so users construct factories without naming an extra type:

#![allow(unused)]
fn main() {
impl YourDB {
    pub fn vista_factory(&self) -> YourVistaFactory {
        YourVistaFactory::new(self.clone())
    }
}
}

The factory struct holds whatever connection state it needs, plus two entry points and a trait impl:

#![allow(unused)]
fn main() {
pub struct YourVistaFactory { db: YourDB }

impl YourVistaFactory {
    pub fn new(db: YourDB) -> Self { Self { db } }

    /// Typed entry point — kept off the `VistaFactory` trait to avoid making
    /// vantage-vista depend on vantage-table.
    pub fn from_table<E>(&self, table: Table<YourDB, E>) -> Result<Vista>
    where E: Entity<AnyYourType> + 'static
    { /* ... */ }
}

impl VistaFactory for YourVistaFactory {
    type TableExtras = YourTableExtras;
    type ColumnExtras = YourColumnExtras;
    type ReferenceExtras = NoExtras;

    fn build_from_spec(&self, spec: YourVistaSpec) -> Result<Vista> { /* ... */ }
}
}

The from_table method is inherent, not on the trait. That’s deliberate: putting it on the trait would force vantage-vista to depend on vantage-table, which would couple the two crates unnecessarily. Drivers want both, of course — but the universal Vista crate doesn’t.

One source, two paths

The two construction paths must converge on identical source-creation code. Here’s the pattern from MongoVistaFactory:

#![allow(unused)]
fn main() {
pub fn from_table<E>(&self, table: Table<YourDB, E>) -> Result<Vista>
where E: Entity<AnyYourType> + 'static
{
    let name = table.table_name().to_string();
    let any_table = table.into_entity::<EmptyEntity>();
    let column_paths = paths_from_table_columns(&any_table);   // typed → paths
    Ok(self.wrap(any_table, column_paths, name))
}

fn build_from_spec(&self, spec: YourVistaSpec) -> Result<Vista> {
    let column_paths = self.paths_from_spec(&spec)?;            // YAML → paths
    let table = self.table_from_spec(&spec)?;
    Ok(self.wrap(table, column_paths, spec.name))
}

fn wrap(&self, table: Table<YourDB, EmptyEntity>, column_paths: ..., name: String) -> Vista {
    // single Vista::new call site — capability flags, source construction
}
}

The two paths only differ in where they get their inputs from — column metadata, the path map, the table itself. Once the inputs are gathered, they go through the same wrap helper. That means a future capability flip (advertising can_subscribe, say) is a one-line edit, not two. Drift between the two construction paths is the most common Vista bug; this pattern is what keeps it out.

Two boundary details that bite

Two more details that look incidental but trip every driver:

The vista’s display name comes from spec.name, not the underlying table name. A spec called client mapped to a Mongo clients collection should expose vista.name() == "client" — that’s what UIs label their tabs with. The pattern: build the table from the spec (it gets the collection/file/table name), wrap it via the same code as from_table (which sets the vista’s name from the table), then call vista.set_name(spec.name) to override. CSV’s factory does this in build_from_spec with one extra line; the typed from_table path doesn’t need it because there is no separate spec name.

Resolve the id column in a fixed order: explicit spec.id_column first, then the first column flagged with id, then a backend default ("_id" for Mongo, "id" for most SQL, whatever your backend’s idiom is). Both in-tree drivers ship a resolve_id_column helper following exactly this order. Don’t reverse it — spec.id_column overrides flags is the rule that lets a YAML author correct a bad column flag without editing the schema source.

Harvesting metadata from a typed table

The typed entry path needs to project the typed table’s columns into vista’s universal column metadata. Both in-tree drivers ship a near-identical helper:

#![allow(unused)]
fn main() {
fn metadata_from_table<T, E>(table: &Table<T, E>) -> VistaMetadata
where
    T: TableSource,
    E: Entity<T::Value>,
    T::Column<T::AnyType>: ColumnLike<T::AnyType>,
{
    let mut metadata = VistaMetadata::new();
    for (name, col) in table.columns() {
        let mut vc = VistaColumn::new(name.clone(), col.get_type().to_string());
        if col.flags().contains(&ColumnFlag::Hidden) {
            vc = vc.with_flag(vista_flags::HIDDEN);
        }
        metadata = metadata.with_column(vc);
    }
    if let Some(id) = table.id_field() {
        metadata = metadata.with_id_column(id.name().to_string());
    }
    for title in table.title_fields() {
        if let Some(col) = metadata.columns.get_mut(title) {
            col.flags.push(vista_flags::TITLE.to_string());
        }
    }
    metadata
}
}

The helper is generic enough to live in vantage-vista itself, but the in-tree drivers keep their own copy. The reason is that metadata is just the universal projection — the moment you start adding driver-specific column attributes (Mongo’s BSON path, CSV’s header alias) the helper has to diverge. Keeping it next to the factory leaves room for that divergence without a refactor.

The source

TableShell is the trait your executor implements. It mirrors TableSource in spirit — most methods take &Vista so the source can read the current condition state, columns, and metadata — but the value carrier is CborValue and the id is String:

#![allow(unused)]
fn main() {
#[async_trait]
impl TableShell for YourTableShell {
    async fn list_vista_values(&self, _vista: &Vista)
        -> Result<IndexMap<String, Record<CborValue>>>
    { self.read_all().await }

    async fn get_vista_value(&self, _vista: &Vista, id: &String)
        -> Result<Option<Record<CborValue>>>
    { /* ... */ }

    fn add_eq_condition(&mut self, field: &str, value: &CborValue) -> Result<()> {
        let condition = /* translate (field, value) → native condition */;
        self.table.add_condition(condition);
        Ok(())
    }

    fn capabilities(&self) -> &VistaCapabilities { &self.capabilities }
}
}

Two boundary conventions you must honour:

  • Ids stringify at the boundary. Mongo’s ObjectId becomes its 24-char hex; SurrealDB’s Thing becomes its table:id form; AWS composite keys become whatever stable string they round-trip through. Inside the source you parse the string back to the native id type. MongoTableShell’s parse_id is one line — MongoId::from_str(id) with a String fallback so non-hex ids still flow through the same call.

  • Values translate to CBOR at the boundary. Drivers with already-JSON-shaped values (CSV strings, REST JSON) reuse their existing Into<CborValue> impls. Drivers with richer native values (BSON, Surreal CBOR) need a dedicated bridge. The cbor.rs module is where lossy paths live — flag them in module docs, write round-trip tests for the lossless ones, and document the rest. Mongo’s bson_to_cbor collapses ObjectId, DateTime, Decimal128, Regex, JavaScriptCode, and Symbol to strings; consumers wanting the native types back need to go through Table<T, E> directly.

    Two non-obvious conventions: on the way in (CBOR → native), unwrap CborValue::Tag(_, inner) and drop the tag — the inner value is what the backend stores. And integers wider than your native signed 64-bit type (i128, big BigInts) should stringify rather than silently truncate. Mongo’s cbor_to_bson does both. Keep the bridge module-private (pub(crate) at most); a leaking BSON ↔ CBOR conversion gets called from places that should be going through Vista instead.

Optional overrides

TableShell ships defaults for several methods beyond the read/write quartet the example above overrides. The defaults are safe — each either returns the right error kind or falls back to a slow-but-correct path — but most drivers can do better:

  • get_vista_count defaults to list_vista_values(...).await?.len(). Override with the backend’s native count path: SELECT COUNT(*) for SQL, count_documents for Mongo, whatever yours gives you for free. The default is fine for testing; it stops being fine the first time someone calls vista.get_count() on a 50M-row table.
  • stream_vista_values defaults to materialising the full result via list_vista_values and yielding from the resulting map. Cursor-based backends — Mongo find cursors, paginated REST endpoints — should override to stream lazily. Same reasoning as the count override: the default works, it just doesn’t scale.
  • insert_vista_return_id_value defaults to Unsupported. Override when the backend generates the id server-side (Mongo’s ObjectId::new(), Postgres’ RETURNING id, a REST POST returning a Location header), and advertise can_insert: true to match.
  • driver_name defaults to "unknown". Override it unconditionally; a one-line fn driver_name(&self) -> &'static str { "yourdriver" } lights up Vista::driver() for CLI output and diagnostics, and there’s no reason not to.

The defaults exist for cases where the override would be a no-op or a net pessimisation. Otherwise: override. Leaving get_vista_count defaulted on a real table is the same shape of bug as not pushing down conditions — it works, but it works the wrong way.

Capabilities — the explicit contract

VistaCapabilities is six booleans plus a PaginateKind. They’re the contract a generic UI relies on to decide which buttons to draw:

#![allow(unused)]
fn main() {
VistaCapabilities {
    can_count: true,
    can_insert: true,
    can_update: true,
    can_delete: true,
    can_subscribe: false,
    can_invalidate: false,
    paginate_kind: PaginateKind::None,
}
}

Set a flag to true only if you actually override the matching TableShell method. Default trait impls return default_error(method, capability, vista), which produces one of two error kinds:

  • Flag is falseErrorKind::Unsupported (“backend doesn’t claim to do this; caller should have checked capabilities first”).
  • Flag is trueErrorKind::Unimplemented (“backend advertised support but didn’t override the method — placeholder bug”).

This is the lie detector. If a UI sees can_insert: true and calls insert_value, it must either succeed or fail with a real driver error — never a “you advertised this but didn’t ship it” placeholder. CSV illustrates the read-only end of this: can_count: true and everything else false, so writes return Unsupported and the test asserts the kind explicitly.

Conditions delegate; they never live on Vista

This is the design decision that made everything else click into place, and it’s worth dwelling on because the original plan had it backwards.

Vista::add_condition_eq(field, CborValue) delegates straight to TableShell::add_eq_condition(&mut self, field, value). The source translates the pair into the driver’s native condition type and pushes it onto the wrapped Table’s condition list. Vista itself stores no condition state.

#![allow(unused)]
fn main() {
fn add_eq_condition(&mut self, field: &str, value: &CborValue) -> Result<()> {
    // CSV: build an Expression<AnyCsvType> via the operation trait
    let column = self.table.columns().get(field)
        .ok_or_else(|| error!("Unknown column for eq condition", field = field))?
        .clone();
    let csv_value: AnyCsvType = value.clone().into();
    self.table.add_condition(column.eq(csv_value));
    Ok(())
}

// Mongo: build a bson::Document with dot-notation for nested fields
fn add_eq_condition(&mut self, field: &str, value: &CborValue) -> Result<()> {
    let dotted = self.dotted_path(field);
    let bson_value = cbor_to_bson(value);
    self.table.add_condition(doc! { dotted: bson_value });
    Ok(())
}
}

Why not filter in memory after the fetch?

Because that defeats the database. A REST source pulling 50,000 rows over the wire to discard 49,990 in-memory is not a useful product. Every backend that supports server-side filtering — and that’s all of them — benefits from push-down. SQL drivers translate to WHERE, Mongo to find filter, REST to query parameters, AWS to DynamoDB filter expressions. The universal CBOR pair is the lowest common denominator that drivers translate up from.

Why &CborValue and not a typed value?

Because at the Vista boundary the Rust type isn’t known. The caller is a CLI parsing a string argument, or a YAML field, or a Rhai script. CBOR is the carrier; the driver decides how to project it onto its native type. CSV’s From<CborValue> for AnyCsvType and Mongo’s cbor_to_bson are the two halves of that translation in the in-tree drivers.

References delegate too

Vista::get_ref(relation, row) is the eq-condition delegation one rung up: same principle, same Vista-stores-nothing rule. The call lands on TableShell::get_ref, which forwards through to the wrapped Table’s with_one/with_many machinery and re-wraps the result as a fresh Vista. Vista itself holds reference metadata (the YAML-friendly Reference { name, target, kind, foreign_key } struct) but no live traversal state.

The default trait impl returns Unimplemented. This is the most-forgotten override on the trait, because the underlying Table<T, E> you handed to the factory already supports traversal — nothing rewires it into the Vista surface unless you write the glue (SQLite’s shell, verbatim):

#![allow(unused)]
fn main() {
fn get_ref(&self, relation: &str, row: &Record<CborValue>) -> Result<Vista> {
    let native_row = to_native_record(row);
    let target = self.table.get_ref_from_row::<EmptyEntity>(relation, &native_row)?;
    let factory = SqliteVistaFactory::new(self.table.data_source().clone());
    factory.from_table(target)
}
}

get_ref has a twin: get_ref_target. Where get_ref resolves a relation for a known parent row (join condition applied), get_ref_target builds the bare target — the same table with no condition. It has the same three-line shape, but calls the typed table’s get_ref_target::<EmptyEntity>(relation) instead of get_ref_from_row:

#![allow(unused)]
fn main() {
fn get_ref_target(&self, relation: &str) -> Result<Vista> {
    let target = self.table.get_ref_target::<EmptyEntity>(relation)?;
    let factory = SqliteVistaFactory::new(self.table.data_source().clone());
    factory.from_table(target)
}
}

If your get_ref threads a resolver or converts the row into a native type, mirror that here (minus the row) — the wrap is identical. Override both or neither: a shell that forwards get_ref but leaves get_ref_target on the default lets read-side traversal work while every edit-form reference dropdown — which calls Dio::get_ref_target(relation) to list the eligible rows to pick from — silently fails with Unimplemented. That split is exactly the bug the SQL and Mongo drivers avoid by shipping both from day one.

Two notes worth dwelling on:

  • The result is another Vista, not the inner table. Consumers stay on the universal surface; each hop fetches a row and traverses from it without falling out of Vista.
  • Traversal here is strictly same-persistence — a Vista describes one backend. Cross-backend relations live one layer up in vantage-vista-factory’s VistaCatalog, which resolves the target by name in its driver and narrows it with universal eq-conditions. Your shell doesn’t need any code for that to work.

Native conditions from outside: add_raw_condition

Universal eq-conditions cover the CBOR vocabulary, but some callers hold a condition already in your backend’s native type — the Rhai layer is the in-tree case: a modify: or traversal script builds a vendor Expression that no (field, value) pair can carry. TableShell::add_raw_condition is the hatch for exactly that: it receives a Box<dyn Any>, downcasts to the driver’s native condition type, and pushes it onto the wrapped table — SurrealDB’s shell is the worked example (it rejects anything that isn’t an Expression<AnySurrealType>, with a clear message). If your driver has no scripted-condition story, leave the default in place; the advertise-what-you-mean rule applies here the same as everywhere else.

What if my backend doesn’t traverse?

LogWriter is the worked example: an insert-only sink has no references, doesn’t read its own writes, and forwarding get_ref makes no sense. Leave the default. The error message includes the driver type name and the relation name, so the caller sees “get_ref not implemented for LogWriterTableShell” — accurate, unsurprising, consistent with everything else returning Unimplemented from this trait.

Nested fields: the column_paths pattern

The MongoDB rollout surfaced a problem that any document-shaped backend will hit eventually: how do you let a column called city in the spec map to address.city in the underlying document?

The answer is column_paths: IndexMap<String, Vec<String>> — a per-source map from spec column name to BSON path segments. The source uses it three ways:

  • On read, walk the path through the raw document and project the value out under the spec name.
  • On write, rebuild intermediate sub-documents so { "address.city", "address.zip" } lands as one address: { city, zip } BSON entry.
  • On filter, join the path with . so Mongo can use the index server-side.

The path map is computed once at construction. Typed-table sources read column aliases (single-level renames, since with_alias doesn’t carry dotted paths). YAML-driven sources read each column’s mongo: { nested_path: "address.city" } block. Both feed the same wrap helper, so reads, writes, and filters all see the same translation.

This is the second piece of accumulated wisdom from the rollout (alongside the eq-condition delegation). Document-shaped backends — Surreal nested objects, REST JSON paths, AWS attribute maps — should reuse the pattern. SQL backends won’t need it: their column-to-field mapping is already flat, and aliases ride on with_alias.

YAML extras: three associated types, one deny_unknown_fields

Driver-specific YAML lives under three associated types on VistaFactory:

#![allow(unused)]
fn main() {
pub trait VistaFactory: Send + Sync + 'static {
    type TableExtras: Serialize + DeserializeOwned + Default + Send + Sync + 'static;
    type ColumnExtras: Serialize + DeserializeOwned + Default + Send + Sync + 'static;
    type ReferenceExtras: Serialize + DeserializeOwned + Default + Send + Sync + 'static;
    /* ... */
}
}

Each defaults to NoExtras for drivers with no driver-specific blocks. The convention is a top-level key named after the driver — csv:, mongo:, surreal: — and the same key inside each column entry:

name: client
columns:
  _id:
    type: object_id
    flags: [id]
  full_name:
    type: string
    flags: [title]
    mongo:
      field: fullName        # column-level extras under `mongo:`
  city:
    type: string
    mongo:
      nested_path: address.city
mongo:
  collection: clients         # table-level extras under `mongo:`

Set #[serde(deny_unknown_fields)] on every extras struct. The outer VistaSpec can’t (it uses #[serde(flatten)] to merge the driver block in), but each driver-owned block must reject typos — otherwise mongo: { collctiom: clients } silently falls back to defaults and you’re debugging a missing collection at runtime instead of at parse time.

Make every field inside the block Option<T> with #[serde(default)], so the entire block can be omitted when the spec name is enough. CSV’s csv: { path } is mandatory (no path means no file); Mongo’s mongo: { collection } is optional and falls back to spec.name. Pick the convention that fits your backend, but lean omit over required — YAML authors hate writing the same name twice.

Treat YAML errors as parse errors, not runtime errors. Validate paths, reject empty segments, reject mutually-exclusive options up-front in build_from_spec and friends. The Mongo driver’s MongoColumnBlock::resolved_path is a worked example — it errors on empty nested_path, on a..b style paths, and on empty field — woven with the column name so the YAML author can find the bad entry.

Tests

Vista tests are gated on feature = "vista" and run against the real backend, not a mock — same as the TableSource tests in earlier steps. Use Result<(), Box<dyn Error>> so ? covers both your driver’s native error type and vantage_core::Error uniformly:

#![allow(unused)]
#![cfg(feature = "vista")]

fn main() {
type TestResult = std::result::Result<(), Box<dyn Error>>;

#[tokio::test]
async fn vista_lists_typed_as_cbor() -> TestResult {
    let (db, name) = setup().await;          // fresh randomised database
    let table = product_table(db.clone());
    /* seed a row */
    let vista = db.vista_factory().from_table(table)?;

    let rows = vista.list_values().await?;
    assert_eq!(rows.len(), 1);
    /* assert CborValue shapes */

    teardown(&db, &name).await;
    Ok(())
}
}

Ship two layers. Cheap unit tests (no backend needed) cover:

  • The CBOR bridge — round-trip every native variant through CBOR and back. Scalars, nested maps, nested arrays. Lossy variants assert the documented lossy form rather than equality.
  • YAML parsing — a minimal spec parses, an unknown field in the driver block errors loudly, optional blocks can be omitted.

Gated integration tests against the real backend then cover:

  • Typed from_table round-trip — list, get-by-id, count match the seeded data.
  • YAML from_yaml round-trip — same, plus the spec name overrides the underlying table name.
  • add_condition_eq push-down — the count and list both honour the filter, and a second condition stacks via AND.
  • Capability advertisement — the booleans match what the driver actually overrode. Read-only drivers must assert can_insert: false etc.
  • Read-only error kinds — for an unsupported op, assert ErrorKind::Unsupported and that the message mentions the capability name. This is what catches the “advertised but unimplemented” drift.
  • Write round-trip via CBOR (writeable drivers only) — insert, get, delete via the spec column names; verify the raw underlying document has the native shape (e.g. nested sub-doc rather than flattened keys).
  • Nested-path read/write/filter (drivers using column_paths) — the most subtle of the lot, since it’s where read, write, and filter must agree on the path map.

Sharp edges

A few things bite every driver. They’re worth flagging up front rather than discovering during review.

Id translation is the most common bug. Round-trip your native id through String and back. Assert that vista.get_value(&id) finds what vista.list_values() returned the id for — using the same string, not a re-parsed one. Mongo’s MongoId::from_str falling back to String for non-hex inputs is the kind of asymmetry that hides until production.

Aliases at the table level may not survive. Column::with_alias is honoured by some TableSource impls and ignored by others when materialising records — Mongo’s doc_to_record ignores them, which is why MongoDB’s vista layer routes single-level renames through column_paths instead. Audit your read path before relying on aliases for column renames; if the table layer doesn’t honour them, do the renaming in the vista source.

get_ref and get_ref_target are the easiest methods to forget — and forgetting just one is worse than forgetting both. The defaults return Unimplemented even though the typed Table<T, E> you wrapped has full with_one/with_many support sitting right there. Forwarding each is three lines (see “References delegate too” above), but a test file that never traverses won’t catch a missing override — and these are exactly the entry points YAML-driven UIs and CLIs reach for first (get_ref for drill-down, get_ref_target for the edit-form pick-a-related-record dropdown). Add a smoke test that exercises both to every driver’s tests/N_vista.rs.

Cursor-only backends should not advertise offset. PaginateKind is a UI hint as much as a declaration; getting it wrong means the UI offers an offset slider that never works. If the backend is genuinely cursor-only (DynamoDB, many REST APIs), say so, and let consumers reject the pagination shape they can’t render.

Conditions stay driver-typed for now. Universal/portable conditions are a later stage. Until then, only translate eq, and reject the rest at construction with a clear message — “operator Lt not yet supported on <DriverTableShell>” beats a silent fall-back to in-memory filtering every time.

Capability flags are cheap to flip later. Start narrow. A driver that ships with can_subscribe: false and turns it on the day LIVE-query lands is a healthier state than one that flagged it true in week one and has been shipping Unimplemented errors ever since.

Step 7 conclusion

At this point your backend should have:

  1. A vista cargo feature gating the bridge so non-Vista users don’t pull in vantage-vista.

  2. <Driver>::vista_factory() — inherent method on the data source returning a <Driver>VistaFactory.

  3. <Driver>VistaFactory with two entry points and a trait impl:

    • from_table<E>(Table<YourDB, E>) -> Result<Vista> (inherent, typed path).
    • impl VistaFactory with build_from_spec (YAML path).
    • Both routing through one wrap helper that calls Vista::new exactly once.
  4. <Driver>TableShell implementing TableShell:

    • Read methods translate native ids → String and native values → CborValue at the boundary.
    • Write methods (where supported) translate the other way.
    • add_eq_condition pushes a native condition onto the wrapped Table.
    • get_ref and get_ref_target forward reference traversal through the wrapped Table and re-wrap the result as a fresh Vistaget_ref with the parent-row join condition, get_ref_target as the bare eligible-rows target the edit-form dropdown lists. Ship both or neither. add_raw_condition is also overridden if the driver participates in cross-backend YAML references.
    • driver_name returns a stable short label; get_vista_count and stream_vista_values are overridden where the backend has a native fast path.
    • capabilities() returns a VistaCapabilities whose true flags exactly match the methods you actually overrode.
  5. Live subscription (optional) — watch_vista plus can_subscribe, if the backend can push changes at all. Four promises bind every implementation:

    • The stream may be coarser than the vista. Subscribing table-wide and letting the consumer discard the rest is a legitimate v1; callers always pass the full vista and never depend on how narrowly you filter, so you can tighten scope later without breaking them.
    • Rows you push may fall outside the vista’s conditions, precisely because of the above. Either reconcile in the driver — SurrealDB re-reads each notified id through the conditions, so a row that no longer matches surfaces as Deleted — or leave it to the consumer, but say which in your docs.
    • VistaChange::Invalidated means “re-read everything”. Emit it when you have a signal but no payload; Postgres LISTEN/NOTIFY has nothing else to offer.
    • Ending the stream is normal. Consumers resubscribe with backoff and reconcile the gap, so you need no internal reconnect loop.

    Advertise can_subscribe only when the feed works with no further setup, or when the application has explicitly declared that setup exists. Postgres is the cautionary case: LISTEN succeeds on a channel no trigger ever feeds, so its capability is opt-in (PostgresVistaFactory::with_notify) rather than inferred from the table being writable. A flag left true over a permanently silent stream is worse than one left false — consumers cannot tell it apart from an idle table.

  6. YAML extras under spec.rs:

    • <Driver>TableExtras and <Driver>ColumnExtras — both deny_unknown_fields.
    • <Driver>VistaSpec type alias resolving the three associated types.
    • Up-front validation of paths, mutual-exclusion rules, etc., as part of spec lowering.
  7. Tests in tests/<n>_vista.rs — gated on feature = "vista", run against a real backend, covering typed/YAML construction, read, write (where supported), add_condition_eq push-down, capability advertisement, and the Unsupported vs Unimplemented error-kind boundary.

Once Vista’s wired up, the same generic CLI, admin UI, or scripting layer that already drives CSV and MongoDB drives your backend too — without recompiling, without an entity import, without a single backend-specific line of code on the consumer side. That’s what the six previous steps were clearing the runway for.

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, you want to list them, add to them, edit one — but they’re physically 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 via 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 exactly like any other relation — get_ref("lines"), then list_values / insert / patch / delete.

This step is optional. Skip it unless your backend stores embedded objects or arrays that users should be able to edit as records.

What the framework gives you

Almost all of it is backend-agnostic and already done:

  • Declaration is on the typed Table, mirroring with_one:

    #![allow(unused)]
    fn main() {
    Table::new("order", db)
        .with_id_column("id")
        .with_column_of::<…>("lines")          // declare the host column (see Sharp edges)
        .with_contained_many("lines", "lines", |db| {
            Table::new("lines", db)
                .with_column_of::<i64>("quantity")
                .with_one("product", "product", Product::table)  // a line can traverse out
        }, None)
    }

    The closure builds the contained record’s schema — same shape as with_one’s build_target, same type system, evaluated lazily. vista_contained() surfaces these as ContainedSpecs.

  • The sub-Vista is vantage_vista::build_contained_vista. It materializes the column’s records into an in-memory ImTable, serves reads from it, and on every write re-serializes the whole collection and calls a writeback closure. That writeback — patch the parent row’s host column — is the only persistence-specific part you supply.

So your job is one TableShell method.

TableShell::get_contained_ref

#![allow(unused)]
fn main() {
fn contained(&self) -> &IndexMap<String, ContainedSpec> {
    &self.metadata.contained
}

fn get_contained_ref(&self, relation: &str, row: &Record<CborValue>) -> Result<Vista> {
    let rel = self.table.contained_relation(relation)?;          // host column, kind, build_target
    let host_value = /* the embedded collection as a CBOR map/array — see below */;
    let parent_id  = /* this row's id, as your native id */;

    // Columns for the sub-Vista's schema, harvested from the closure-built table.
    let columns = metadata_from_table(&rel.build_target(self.db())).columns;
    let spec    = ContainedSpec::new(rel.name(), rel.host_column(), rel.kind()).with_columns(columns);

    // Eager writeback: re-serialize → patch the host column on the parent row.
    let writeback = Arc::new(move |collection: CborValue| { /* patch parent[host] = collection */ });

    // Traverse-out: resolve the contained record's own relations (line → product).
    let ref_resolver = Arc::new(move |rel, child_row| { /* get_ref_from_row on the contained table */ });

    build_contained_vista(&spec, host_value.as_ref(), writeback, Some(ref_resolver))
}
}

And in the factory’s metadata_from_table, copy the specs so the relation surfaces:

#![allow(unused)]
fn main() {
for spec in table.vista_contained() {
    metadata = metadata.with_contained(spec);
}
}

That’s the whole integration. The two driver-specific decisions are how the host value crosses the boundary, and what the writeback does — and those split cleanly along one line.

Native vs JSON-blob

Native backends (SurrealDB, MongoDB) store the host column as a real nested object/array. The value arrives as CborValue::Map/Array already, and the writeback patches it back as-is — SurrealDB UPDATE … MERGE, MongoDB $set. No serialization on either side:

#![allow(unused)]
fn main() {
let host_value = row.get(rel.host_column()).cloned();       // already a Map/Array
// writeback: patch { host: AnyNativeType::from(collection) }
}

JSON-blob backends (SQL with no native nesting — the SQLite path) store the collection as a JSON string in a TEXT column. Parse on read, serialize on write, using the shared json_to_cbor / cbor_to_json bridge:

#![allow(unused)]
fn main() {
let host_value = row.get(rel.host_column()).and_then(parse_json_host);   // Text(json) → Map/Array
// writeback: patch { host: Text(cbor_to_json(collection).to_string()) }
}

parse_json_host also passes a Map/Array straight through, so the same code copes with a backend that does parse JSON columns natively (Postgres jsonb, MySQL json) — there, declaring the host column as TEXT keeps the round-trip a plain string and avoids the write-side bind for nested values. Postgres and MySQL share the SQLite implementation verbatim for exactly this reason.

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 writing a join that the storage engine can’t honour. Contained relations are the natural model: traversal is a column projection, not a query.

Eager writeback

Every mutation on the sub-Vista patches the parent row immediately — there’s no flush. This keeps the sub-Vista and the parent row coherent at all times, and it’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 you mutate 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 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 declared id column, contained-many records are keyed by index ("0", "1", …). Deleting element 0 renumbers the rest. Give the contained schema an id column (with_contained_many(…, Some("line_id"))) when callers hold onto ids across mutations.

Contained-one uses a fixed id. A single embedded object is addressed as "0"; there’s no ambiguity to resolve.

The writeback is not atomic with the contained record’s own children. A contained record can traverse out (line → product) or even nest further, but each backend write is its own statement. That’s the same best-effort contract as nested insert (Step 6’s neighbour) — fine for these shapes, not a transaction.

From YAML

Contained relations are declarable in a YAML vista spec too, via a contained: section that mirrors columns:/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
    id_column: line_id             # optional; omit for positional ids
    columns:
      product: { type: string }
      quantity: { type: int }

The loader lowers this through one generic helper — Table::with_contained_specs — which calls your driver’s existing build_column on each contained column, so the YAML and code-first paths converge on the same registration. Wiring it is one line per driver in table_from_spec:

#![allow(unused)]
fn main() {
table = table.with_contained_specs(&spec.contained, build_column)?;
}

Limitation: YAML-declared contained records carry columns only — no nested relations, so traverse-out (line.product) isn’t expressible from YAML yet (the code-first closure still supports it, since it can add with_one to the contained table). Lifting this means letting the contained columns carry references: sugar plus a resolver, the same machinery YAML foreign-key references would need.

Step 9 checklist

A backend supports contained relations once it has:

  1. TableShell::contained() returning &self.metadata.contained.

  2. TableShell::get_contained_ref — a thin shim that extracts the parent row’s id (in the driver’s native id type) and forwards to the shared Table::get_contained_ref, passing three things only the driver knows: the wrap closure (target TableVista via its factory), and the host decode/encode codec (native passthrough, or JSON parse/serialize). The generic helper seeds the records, harvests the contained schema, wires the eager writeback, and resolves traverse-out.

  3. metadata_from_table copying table.vista_contained() into VistaMetadata.

  4. YAML — one line in table_from_spec: table = table.with_contained_specs(&spec.contained, build_column)?;

  5. Tests (gated on feature = "vista", against a real backend): declare a host column holding a collection (code-first and via from_yaml), traverse it, insert/patch through the sub-Vista, and re-read the parent row to prove the writeback landed.

Native and JSON-blob backends differ only in two closures — how the host value enters and how the writeback leaves. 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 the VistaCatalog, 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 (can_fetch_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

On the Lens, supply the catalog and one or more augmentations:

#![allow(unused)]
fn main() {
let lens = Lens::new()
    .cache_at(cache_path)
    .catalog(catalog)                       // resolves `table:` names
    .augment(vec![augmentation])            // ≥1 engages two-pass
    .build()?;
}

Each Augmentation has four parts:

#![allow(unused)]
fn main() {
Augmentation {
    table:  "tfstate_detail".into(),  // catalog name of the detail Vista
    source: Source::Column { from: "key".into(), to: None },
    fetch:  Fetch::PerRow,
    merge:  MergeRule { columns: vec!["resources".into(), "serial".into()] },
}
}

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 with vantage_vista::augment_source_closure (vista’s rhai feature) and hands the result to Source::Build:

self.add_condition_eq("key", row.key)

This is the same machinery a reference build-script uses, pointed at a possibly different persistence. The diorama core never sees a script string; all engine assembly stays in vantage-vista.

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 — Rhai fetch verbs choosing how to pull.

Checklist

To augment a master with a second source:

  1. Register the detail model in the VistaCatalog by name.
  2. Build the Augmentation — pick a Source (id, column, or a closure) and a Fetch (PerRow for now), and list the merge columns.
  3. Wire the Lens.catalog(...) then .augment(vec![...]); building it engages the two-pass passes automatically.
  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 traitSqliteType, 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"

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

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 typesNaiveDate, NaiveTime, NaiveDateTime, DateTime<Utc>, DateTime<FixedOffset>
  • Numeric typesDecimal, 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::*;
}

Primitives are not part of the prelude — import them explicitly when needed.

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::*;

// 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() {
// (price > 100 AND in_stock = 1) OR (featured = 1)
let cond = or_(
    and_(ident("price").gt(100), ident("in_stock").eq(true)),
    ident("featured").eq(true),
);
}

Both return Expression<T>, so they plug directly into .with_condition().

ident() — Identifiers

Creates a quoted column or table name. Quoting adapts per backend (" for SQLite/Postgres, ` for MySQL).

#![allow(unused)]
fn main() {
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(). Reserved words and names with spaces are quoted automatically.

Each backend also has a typed identifier — sqlite_ident(), pg_ident(), mysql_ident() — returning a backend-pinned wrapper so operations like .eq() and .gt() resolve without ambiguity. The generic ident() works when the backend can be inferred from context (inside sqlite_expr!(), or passed where a specific Expressive<T> is expected); reach for the typed variant when calling operations directly.

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() {
fx!("count", sqlite_expr!("*"))
// => COUNT(*)

fx!("avg", ident("price"))
// => AVG("price")

// Multiple arguments
fx!("coalesce", ident("nickname"), "anonymous")
// => COALESCE("nickname", 'anonymous')

// Nested
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() {
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() {
let expr = ternary(
    ident("stock").gt(0),
    "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() {
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() {
concat_!(ident("first_name"), " ", ident("last_name"))
}

Use .ws() for a separator — it accepts any Expressive<T>, including string literals:

#![allow(unused)]
fn main() {
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() {
let i = Interval::days(30);
// SQLite:   30  (used with date functions)
// MySQL:    INTERVAL 30 DAY
// Postgres: INTERVAL '30 days'
}

See Interval for available constructors (days, hours, months, etc.).

date_format() — Date Formatting

Portable strftime-style formatting. Translates format tokens per backend:

#![allow(unused)]
fn main() {
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(). Use .raw_format() to skip token translation and pass a native format string.

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
  • 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.

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 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 (avgAVG 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.

Escape hatches

expr("…") and fx("name", […]) drop to raw SurrealQL when no primitive fits — expr("tags"), fx("crypto::md5", [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")
coalesce(ident("nickname"), "anonymous")      // → nickname ?? "anonymous"
round(avg(ident("price")), 2)                  // → math::fixed(math::mean(price), 2)
cast(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:

case_when()
    .when(avg(ident("price")) >= 250, "premium")
    .when(avg(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 inline their second argument as a single-quoted literal (the search term, the bucket unit) to match how SurrealQL writes those fixed tokens — distinct from a scalar string operand, which renders double-quoted (coalesce(…, "n/a")?? "n/a").

Field and element access

Dotted paths and indexing use the […] indexer rather than a separate primitive:

ident("department")["name"]      // → department.name
ident("nutrition")["sugar"]      // → nutrition.sugar
some_subquery[0]                 // → (…)[0]   (integer index)

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 (me, the current record, or a nested graph(…)), and its position sets direction:

graph(me, "placed", "order")            // → ->placed->order        (anchor left = outward)
graph("reports_to", me)                 // → <-reports_to           (anchor right = inward)
graph(me, "reports_to", "employee")["name"]   // → ->reports_to->employee.name

Mixed direction comes from nesting — each graph() appends one directed hop:

graph("client", "placed", graph(me, "placed", "order"))
// → ->placed->order<-placed<-client

recurse(path, min, max) wraps a path in ranged recursion:

recurse(graph("employee", "reports_to", 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:

select().value().expression(max(ident("total"))).from("order").group_all()
// → SELECT VALUE math::max(total) FROM order GROUP ALL

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():

select().value().expression(max(ident("total")))
    .from("order").where(ident("client") == 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.

param("auth")["id"]        // → $auth.id
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.

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) })

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.
  • Use the vantage-sql name when the concept overlaps (count, avg, round, coalesce, case_when, date_format) — scripts stay portable across backends.
  • Build field paths with the ["col"] indexer, graph paths with graph() + 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 expr("…") / fx(…) only when no primitive fits.