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

Surfaces

The data vocabulary is the same everywhere. What changes from one place to the next is the host: which tables table(name) reaches, which terminal verbs exist, and what the script is given in scope. This chapter goes through each surface with a tested example.


Overview

Rows marked “(app)” are hosts an application builds for itself; the values shown are Vantage UI’s. The rest come with vantage.

Surfacetable(name) resolvesTerminalsLimitsIn scope
run_script (agent tools)the caller’s resolverReadWrite, list() capped at 1 to 50 rowsBackground
preview_scriptthe caller’s resolverDescribeBackground
YAML modify:the backend’s spec resolverDescribeBackgroundself
reference build scriptthe backend’s spec resolverDescribeBackgroundrow
lazy: computed columnnonenone: no data vocabularyBackgroundrow
form options: (app)the app’s tablesReadWrite, writes denied, cappedform values
action predicates, when: (app)noneReadWrite, writes deniedUirow
action bodies (app)the app’s tables, writes through DioReadWriteUirow, actions
form on_submit, wizard forms (app)the app’s tables, writes through DioReadWriteBackgroundform (a Servo), wizard
wizard worker: (app)the app’s tablesReadWrite, 50 rowsBackgroundwizard, state
faker simsthe sim’s memory storeReadWritethe sim’s own budgetargs

Agent scripts: run_script

run_script evaluates a script with every read and write verb and returns its last value as JSON. It runs under spawn_blocking for you:

let due = table("order").where("status", "due");
#{ count: due.count(), rows: due.sort("total", "desc").list() }
use std::sync::Arc;
use ciborium::Value as CborValue;
use vantage_memory::{MemoryCatalog, MemoryStore};
use vantage_vista::rhai::{Writes, run_script};
use vantage_vista::{Column, ColumnFlag, VistaMetadata, VistaResolver};
const AGENT_SCRIPT: &str = r#"
let due = table("order").where("status", "due");
#{ count: due.count(), rows: due.sort("total", "desc").list() }
"#;
fn resolver(store: &MemoryStore) -> Arc<dyn VistaResolver> {
    let catalog = MemoryCatalog::new(store.clone());
    catalog.register(
        "order",
        VistaMetadata::new()
            .with_column(Column::new("id", "String").with_flag(ColumnFlag::Id))
            .with_column(Column::new("total", "int").with_flag(ColumnFlag::Orderable))
            .with_column(Column::new("status", "String"))
            .with_id_column("id"),
    );
    Arc::new(catalog)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let store = MemoryStore::new();
for (id, total, status) in [("o1", 120, "paid"), ("o2", 40, "due"), ("o4", 15, "due")] {
    let row = [
        ("total".to_string(), CborValue::Integer(total.into())),
        ("status".to_string(), CborValue::Text(status.into())),
    ]
    .into_iter()
    .collect();
    store.table("order").insert_as(id, row)?;
}
let out = run_script(
    AGENT_SCRIPT.to_string(),
    resolver(&store),
    1,
    Writes::Denied("writing data is turned off".into()),
)
.await
.unwrap();
assert_eq!(out["count"], 2);
assert_eq!(out["rows"].as_array().unwrap().len(), 1);
Ok(())
}

The limit is clamped to MIN_LIMIT..=MAX_LIMIT (1 to 50; DEFAULT_LIMIT is 5) and caps every list(), so rows above holds one row while count is 2. This is an inspection surface, not a bulk reader. Compile, runtime and backend errors all come back as the error string.

Vantage UI’s MCP run_data_script tool is this function. Its writes stay Denied unless the user turns on “Allow MCP agents to write data”, a setting separate from the one that lets agents read.

Query preview: preview_script

preview_script runs a script on a Describe host, so it can build a handle but never read rows. The script must end on the handle. The resolved Vista renders its query through Vista::preview:

table("order").where("status", "paid").sort("total", "desc")

The result is JSON with the Vista’s driver and the query it would run, plus a note naming anything the query leaves out. Here the memory store reports driver: "memory" and query: "scan order WHERE status = \"paid\" ORDER BY total DESC". One exception to “never reads”: a ref step still reads the rows it starts from, because the target’s condition depends on them. When it finds none, the preview reports "query": null with a note, because nothing would be queried.

modify: scripts

A YAML spec’s modify: script runs on the finished Vista, exposed as self. A Describe host evaluates it with eval_modify_script:

self.where("vip", true).sort("name", "desc")

The script’s value is its last statement, and it must be a table handle: that handle is resolved and becomes the table. A script that ends on anything else is an error, “The script must end on a table handle”. modify: narrows a real table, so the Vista stays writable. Reference build scripts finish the same way.

Backend extensions

A backend can add its own verbs to handles through TableShell::register_rhai_extensions, which registers its functions and constants in one call. SurrealDB adds its whole expression vocabulary, plus with_condition(expr), which narrows by a native SurrealQL expression that where can’t spell:

self.with_condition(surreal::ident("is_paying_client") == true)
   .sort("name", "asc")

and surreal::me, the current-record anchor for graph paths. MySQL adds the SQL expression vocabulary and the same with_condition(expr); MongoDB’s with_condition takes a filter written as a Rhai map, self.with_condition(#{price: #{"$gt": 100}}), with mongo::oid, mongo::date and mongo::doc for the BSON values a map can’t spell.

A backend’s free functions and constants live in its own namespace: surreal:: for SurrealDB, sql:: for every SQL database, mongo:: for MongoDB. So table(name) is always the data verb, and a backend function never shadows one. The verbs on handles, with_condition included, and the methods and operators on a backend’s own types (.from, .alias, ["col"], ==) take no prefix.

with_condition narrows a Vista in hand, not a list of steps, so it works on self only. On a handle from table(name), or after a ref step, it throws an error naming the verb. A backend implements it with Handle::with_native_condition, which hands a NativeCondition to the shell’s add_native_condition. This stand-in passes an equality as the native condition:

#![allow(unused)]
fn main() {
use vantage_rhai::rhai::{Dynamic, EvalAltResult};
use vantage_vista::rhai::{Handle, dynamic_to_cbor};
use vantage_vista::{Condition, FilterOp, NativeCondition};
fn with_condition(h: &mut Handle, col: &str, value: Dynamic) -> Result<Handle, Box<EvalAltResult>> {
    let key = format!("{col} = {value}");
    let condition = Condition::new(col, FilterOp::Eq, dynamic_to_cbor(value)?);
    h.with_native_condition("with_condition", NativeCondition::new(condition, key))
        .map_err(|e| e.to_string().into())
}
}

with_native_condition narrows a copy of the Vista and returns a new handle over it, so like every other verb it leaves the handle it was called on alone:

let all = self;
let vip = all.with_condition("vip", true);
all

still ends on every client. Verbs on self return new handles, so narrowing is a chain, whichever verbs it uses:

self.with_condition("vip", true).with_condition("name", "Cy")

leaves only Cy, and

self.with_condition("vip", true).sort("name", "desc")

leaves Cy and Ada, in that order. Chain the calls, or assign a step with let and continue from the variable. A statement that ends on a narrowing verb (where, sort, search, limit, ref, with_condition) outside the script’s last statement drops its handle, whatever the chain starts from, and is refused when the script compiles, with “this statement’s table handle is discarded; chain the calls or assign it”:

self.with_condition("vip", true);    // throws: this handle would be dropped
self.sort("name", "desc");
let all = self;
all.with_condition("vip", true);    // throws: this handle would be dropped
all

A last statement that isn’t a handle is refused too. Watch an if with no else: when its condition is false its value is (), so end it with else { self }:

let vip_only = false;
if vip_only {
    self.with_condition("vip", true)
}   // throws: a false `if` with no `else { self }` is ()

Backends without a scripting vocabulary (CSV, REST, SQLite and PostgreSQL Vistas) keep the default no-op and get the data vocabulary alone.

Reference build scripts

A reference whose join isn’t a plain foreign key can carry a rhai: build script. It runs at traversal time with the parent row as row, and must end on a handle:

table("order").where("client", row.id).where("status", "due")

In YAML:

references:
  due_orders:
    table: order
    kind: has_many
    foreign_key: client
    surreal:
      rhai: |
        table("order").where("client", row.id).where("status", "due")

eval_ref_script evaluates it.

Computed columns

A lazy: column script runs on the shared background host, with no data vocabulary at all: no table(name), no handles, only row. It computes one value from one row, every time the Vista reads that row. Computed columns covers it.

Read-only hosts

A read-only host is a Terminals::ReadWrite host with writes: Writes::Denied(message). It reads freely, and every write throws the message. Records still load, so a script can read row.status, but flashing throws:

let o = table("order").record("o1");
o.status = "void";
o.flash()   // throws: this host only reads

Table write verbs such as delete throw the same message. Vantage UI uses read-only hosts for form options: scripts, and for action predicates, which get row but no table(name).

Read-write hosts in an application

An application decides what its action scripts see; Vantage UI, for example, gives action bodies and form on_submit scripts table(name) over the app’s tables and, for row actions, row as a record:

let r = actions.cancel_order(row);
row.status = "cancelled";
row.cancellation_reason = r.reason;
row.flash();

The resolver behind table(name) sends writes through each table’s Dio, so open pages see them at once, and applies the app’s write rules (read-only tables, reference coercion, generated ids). Form and wizard scripts also see Servo drafts, which have their own vocabulary (Layers). The action and form vocabularies themselves (actions.*, notify, form) are documented in Vantage UI’s skills.

Faker sims

Sims are covered in Faker Sims: they use this vocabulary over a memory store, with table() naming the sim’s own table, plus time and random verbs.