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.
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.
By the end of this page you’ll be able to:
- Wrap a typed
Tableinto a Vista - Read schema metadata (columns, references, id column) from a Vista
- Add conditions, search, and ordering through the Vista API
- Fetch paginated results with
fetch_pageandfetch_next - Traverse relationships and cross-backend references
- 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.
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:
| Flag | Meaning |
|---|---|
"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.
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:
| Operation | Semantics |
|---|---|
add_condition_eq, with_id | Accrete — each call narrows further; none can be removed |
add_search, add_order | Replace — calling again swaps the previous one out |
| Widening | Clone 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).
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.
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:
- Contained relations — records embedded in a column of the parent row (an order’s
linesarray) resolve first, as an editable sub-Vista. - Foreign-key references — the shell forwards to the
with_one/with_manyrelationships 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).
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
| Concept | What it does |
|---|---|
Vista | Universal schema-bearing data handle, wraps any Table |
TableShell | Per-driver executor that Vista delegates to |
VistaCapabilities | Explicit contract of what the driver supports |
Column | Column metadata with name, type, and flags |
add_condition_eq | Narrow results to field == value |
with_id | Convenience: narrow by primary key |
add_search / add_order | Quicksearch and sorting (replace semantics) |
fetch_page / fetch_next | Offset and cursor pagination |
get_ref | Traverse a relationship from a parent row |
VistaCatalog | Cross-persistence traversal, one layer above Vista |
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).