Relations and Dio
Everything in this guide so far has executed at or below the Vista: typed relations on the Table,
traversal narrowing, subquery expressions, implicit references. This final chapter climbs one layer
higher. A Dio keeps a live local copy of a data segment (a cache), fills it through a Lens,
announces changes on an event bus, and serves reactive views (Scenery). You built one in the intro
paths (Dio over SQL, Scenery).
Two questions matter here: what happens to the relation machinery you already know once a Dio sits on top — and what relation shape exists only at the Dio layer, because nothing below it can express one.
Same-persistence relations resolve beneath the Dio
Traversal, subquery expressions, and implicit references all happen in the Table or Vista under
the Dio. The Dio caches the resulting rows like any others. If your table declares
with_active_columns(&["id", "client.name"]), the client.name column is already part of each row
when it enters the cache — nothing at the Dio layer knows or cares that it was traversed. From the
cache’s point of view, a traversed column and a plain column are indistinguishable.
The practical consequence is the master/detail pattern in a UI: traverse at the Vista, cache at
the Dio. Take a row from the cached master and call Dio::get_ref(relation, &row). It runs the
erased Vista::get_ref from the Vistas chapter on the Dio’s master and builds a new
Dio over the narrowed target, on the same Lens:
use ciborium::Value as CborValue;
use vantage_dataset::ReadableValueSet;
use vantage_diorama::{Cache, Lens, Loader};
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, JoinKey, Reference, Vista, VistaMetadata};
fn row(pairs: &[(&str, &str)]) -> Record<CborValue> {
pairs.iter().map(|(k, v)| (k.to_string(), CborValue::Text(v.to_string()))).collect()
}
fn clients() -> Vista {
let orders = MockShell::new()
.with_metadata(
VistaMetadata::new()
.with_column(Column::new("id", "String").with_flag("id"))
.with_column(Column::new("client_id", "String"))
.with_id_column("id"),
)
.with_record("o1", row(&[("id", "o1"), ("client_id", "marty")]))
.with_record("o2", row(&[("id", "o2"), ("client_id", "marty")]))
.with_record("o3", row(&[("id", "o3"), ("client_id", "doc")]));
let shell = MockShell::new()
.with_metadata(
VistaMetadata::new()
.with_column(Column::new("id", "String").with_flag("id"))
.with_id_column("id")
.with_reference(Reference::has_many("orders", "order", JoinKey::new("id", "client_id"))),
)
.with_record("marty", row(&[("id", "marty")]))
.with_record("doc", row(&[("id", "doc")]))
.with_ref_target("orders", orders);
Vista::new("client", shell)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let lens = Lens::builder()
.cache(Cache::memory())
.loader(Loader::eager(|cx| async move {
let rows = cx.master().list_values().await?;
cx.reconcile(rows).await
}))
.build()?;
let clients = lens.dio(clients()).build().await?;
let marty = clients.cached().get_value("marty").await?.expect("cached");
// A Dio of its own over marty's orders, with its own cache table.
let orders = clients.get_ref("orders", &marty).await?;
assert_eq!(orders.cached().list_values().await?.len(), 2);
Ok(())
}
Each narrowed detail set gets its own cache table, keyed by the narrowed target’s query, so
switching between master rows switches between already-cached detail sets rather than re-narrowing
one shared cache. The detail Dio inherits the master’s live setting, but not its augmentations,
which describe the master’s table. Dio::get_ref_target(relation) is the same call without a row:
a Dio over every eligible target row, which is what a reference picker in an edit form lists.
When the detail lives in another datasource, build its Vista through a
Catalog and narrow it with Reference::narrow, as the
Vistas chapter shows, then put a Dio in front of it
with lens.dio(narrowed).build().
A narrower view of the same rows is a different operation: no relation, just more conditions.
dio.derive() collects them and builds a new Dio over the narrowed set:
use ciborium::Value as CborValue;
use vantage_dataset::ReadableValueSet;
use vantage_diorama::{Cache, Lens, Loader};
use vantage_types::Record;
use vantage_vista::mocks::MockShell;
use vantage_vista::{Column, Condition, FilterOp, Vista, VistaMetadata};
fn row(pairs: &[(&str, &str)]) -> Record<CborValue> {
pairs.iter().map(|(k, v)| (k.to_string(), CborValue::Text(v.to_string()))).collect()
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let orders = Vista::new(
"order",
MockShell::new()
.with_metadata(
VistaMetadata::new()
.with_column(Column::new("id", "String").with_flag("id"))
.with_column(Column::new("client_id", "String"))
.with_id_column("id"),
)
.with_record("o1", row(&[("id", "o1"), ("client_id", "marty")]))
.with_record("o2", row(&[("id", "o2"), ("client_id", "doc")])),
);
let lens = Lens::builder()
.cache(Cache::memory())
.loader(Loader::eager(|cx| async move {
let rows = cx.master().list_values().await?;
cx.reconcile(rows).await
}))
.build()?;
let dio = lens.dio(orders).build().await?;
let martys = dio
.derive()
.with_condition(Condition::new("client_id", FilterOp::Eq, "marty"))
.build()
.await?;
assert_eq!(martys.cached().list_values().await?.len(), 1);
Ok(())
}
martys keeps a cache table and an event bus of its own, but it isn’t cut off from dio. Every
Dio over the order table on this Lens joins the table’s hub. What one of them settles (a
confirmed flash, an apply_change, what a reconcile changed, a change its live follow received)
reaches the others, and each judges it against its own conditions. Move o1 to doc through dio
and it leaves martys. Insert an order for marty and it shows up in martys. A row that only left
a narrowed Dio’s set is never taken as a delete: the others re-read it through their own masters.
The hub is keyed by the master’s driver and name. A layer that renames vistas, one name per
session, passes the backend table to DioBuilder::hub(key) so its Dios still hear each other. An
explicit key also names the cache table, so Dios keyed apart never share cached rows. A master
narrowed by a rule its query() doesn’t record (a table hook’s) is built with
DioBuilder::recheck_facts(), which re-reads each reported row through that master rather than
judging it in memory.
recheck_facts keeps nothing apart. Dios are told apart by vista name, recorded query and hub key
only, so a hidden per-session rule needs its own vista name, a vista condition, or its own hub key.
Without one, two sessions share a cache table and each can list the other’s rows.
The join the layers below cannot express
A Catalog can already navigate across
datasources — hand a Reference a parent row and the catalog’s target, and it returns the related
set from another backend. What nothing below the Dio can do is merge: show master rows whose
columns come from two sources at once. That is a join, and joins are governed by the capability
contract — a SQL Vista can join its own tables, a REST Vista cannot, and two different backends can
never push a join down to either engine. A Vista also has nowhere to put stitched rows: no cache,
no viewport, no notion of “only visible rows pay”.
Where the backend can’t, the layer above fills the gap — that is the whole job of the Dio.
Augmentation wires two Vistas into one Dio: a master that is listed, and a detail source
loaded one row at a time and merged on top. The detail is resolved by name through a
VistaResolver such as a Catalog, so it is
persistence-agnostic — a REST master enriched by a cmd detail, or a SQL master enriched from
another database.
It runs in two passes:
- List pass — the master is listed cheaply; each row enters the cache marked
Incomplete. - Detail pass — viewport-driven: for each visible incomplete row, the augmentation resolves
its detail Vista, fetches the matching record, merges the chosen columns, and the row flips to
Fresh.
Rows off-screen are never fetched; hydrated rows are never re-fetched. A failed detail fetch marks only that row.
Declaring an augmentation
Augmentations are a property of the Dio, fixed when it is built: DioBuilder::augment takes the
list, and supplying at least one is what engages the two-pass behaviour. Each augmentation answers
four questions — which detail Vista, how a master row selects its detail, how it is fetched, and
which columns to lift:
use std::sync::Arc;
use vantage_diorama::{Augmentation, Cache, Detail, Fetch, Lens, MergeRule, Source};
use vantage_vista::mocks::MockShell;
use vantage_vista::{Catalog, Column, Vista, VistaMetadata};
fn meta(columns: &[&str]) -> VistaMetadata {
columns
.iter()
.fold(VistaMetadata::new(), |m, c| m.with_column(Column::new(*c, "String")))
.with_id_column("id")
}
fn tfstate_detail() -> Vista {
Vista::new("tfstate_detail", MockShell::new().with_metadata(meta(&["id", "resources", "serial"])))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let buckets = Vista::new("buckets", MockShell::new().with_metadata(meta(&["id", "key", "size"])));
let lens = Lens::builder().cache(Cache::memory()).build()?;
let catalog = Catalog::new().with("tfstate_detail", |_: &str| Ok(tfstate_detail()));
let dio = lens
.dio(buckets)
.augment(vec![Augmentation::new(
Detail::Resolved {
resolver: Arc::new(catalog), // resolves the detail by name
name: "tfstate_detail".into(),
},
Source::Column { from: "key".into(), to: None },
Fetch::PerRow,
MergeRule::lift(["resources", "serial"]),
)])
.build()
.await?;
let _ = dio;
Ok(())
}
detailnames the detail Vista:Detail::Resolvedlooks it up by name through a resolver;Detail::Fixed(Arc<Vista>)takes one directly, for a side table no resolver knows.sourcepicks how a master row selects its detail:Source::Idmatches master.id → detail.id;Source::Column { from, to }matchesmaster[from]→detail[to], or detail.id whentoisNone;Source::Build(closure)performs arbitrary narrowing from the whole row, and is per-row only.fetchpicks how the detail is read:Fetch::PerRowreads one record per master row;Fetch::Custom(closure)is a caller-supplied async fetch.mergelists the detail columns to lift.MergeRule::lift_all()lifts all of them; on a name clash the detail wins.
Diorama has no YAML form of these types. A consumer maps its own configuration onto them:
vantage-ui reads an augment: block from a table’s configuration and builds the same
Augmentation:
augment:
- table: tfstate_detail
source: { kind: column, from: key }
fetch: { kind: per_row }
merge: [resources, serial, outputs]
A scripted source is a Build closure the consumer writes; diorama never sees a script string.
See Augmentation.
To see the two passes run, try the example — two in-memory Vistas, list pass then detail pass:
cargo run -p vantage-diorama --example augmentation
The full treatment of augmentation — merge rules, capability reasoning, the planned batched fetch and keyed caching — is in the Augmentation chapter; the tutorial form is intro step 6.
Why not model the detail as a foreign-key relation?
A relation resolves within one persistence and can be pushed down as a join. The detail here may be a different backend entirely; there is nothing to join on and no engine that could honour it. Augmentation is the cross-Vista form, stitched by the Dio.
Choosing between implicit references and augmentation
Both produce the same user-visible result: a row enriched with related fields. Which one to use depends on where the data lives and who should pay for the fetch.
Path A: implicit references (same datasource, in-query)
One query; the backend does the work. The value is present on every row, including off-screen ones, it is validated when the table is constructed, and it carries read-only column semantics. Choose this when both tables live in one datasource that supports traversal (SQL, SurrealDB, MongoDB) — see Implicit References.
Path B: augmentation (any two datasources, per-row client-side join)
Two passes; expensive work follows the user’s attention — only viewport rows pay, and only once. It works across arbitrary backends, and failed details degrade per-row, not per-page. Choose this when the sources differ, or when the detail is expensive and you want viewport-driven hydration even within one backend.
The key difference: implicit references push the join down into the engine; augmentation lifts it up into the cache. Same user-visible result — a row enriched with related fields — chosen by where the data lives and who should pay for the fetch.
Guide conclusion
This closes the Relations & Traversal guide. At this point you should be able to:
- 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.
- Build master/detail —
dio.get_ref(relation, &row)for a Dio over the related rows, each detail set in its own cache table;dio.get_ref_target(relation)for every eligible row. - Narrow a Dio —
dio.derive().with_condition(..).build().await?for a narrower view of the same rows. - Declare an augmentation —
Detail::ResolvedorFixed, aSource, aFetchand aMergeRule, handed tolens.dio(master).augment(..)beforebuild(). - 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.