Windows
macOS and Linux today. A signed Windows build brings the same app to the laptops most of your colleagues actually use — no VM, no remote desktop.
Vantage UI
Use Vantage to build your own data-heavy applications. Vantage stores its project files in a folder, constantly monitoring them for changes — so the app can be easily modified from the outside.
Menu items, page layout and widget types are described in YAML. Custom logic and behaviour live in Rhai. The project folder also ships .skills that teach your AI coding agent to write both.
# table/clients.yaml
title: Clients
source: main_db
columns: [name, email, balance]
has_many:
- orders
context_menu:
- title: View orders
open_page: orders
model: model.get_ref("orders", row.id)
// a computed label for each row
let label = row.name;
if row.balance < 0 {
label += " • owes " + row.balance;
}
label
// drill two hops, in one expression
model.get_ref("clients", row.id)
.get_ref("orders", client_id)
When a file changes on disk, Vantage re-reads it and the UI updates live.
Vantage needs a DataSource to read data. Each DataSource has a unique set of capabilities. Once a connection is established, Vantage reads tables to learn about columns, record types and relations. Enterprise builds can also define strongly typed tables in Rust.
SurrealDB open_in_new Vantage driver open_in_new SurrealQL open_in_new
Full querying, aggregation, pagination, related columns and dataset-level traversal, with relations you can define in Rhai. Tables update themselves when data changes, through LIVE queries — nothing to set up.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
check_circle |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
check_circle |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
check_circle |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
check_circle |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
check_circle |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | check_circle |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
check_circle |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
check_circle |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
check_circle |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
check_circle |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
check_circle |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | check_circle |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | check_circle |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | check_circle |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
check_circle |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
check_circle |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
check_circle |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
check_circle |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
check_circle |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
check_circle |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
check_circle |
SQLite open_in_new Vantage driver open_in_new SQLite SQL open_in_new
Full SQL driver via sqlx — sort, search, aggregation, pagination, subquery traversal and columns from related tables.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
check_circle |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
check_circle |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
check_circle |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
check_circle |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
check_circle |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | check_circle |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
check_circle |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
~Partly supported From Rust (Table::with_expression), or as a column of a read-only rhai: / base: table. There is no expr: column in SQL YAML yet.
|
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
~Partly supported From Rust (Table::with_expression), or as a column of a read-only rhai: / base: table. There is no expr: column in SQL YAML yet.
|
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
check_circle |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
check_circle |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
check_circle |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
check_circle |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
check_circle |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
check_circle |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
check_circle |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
check_circle |
What stands out
client.name sorts and filters like any column.base:, taking arguments from the page.PostgreSQL open_in_new Vantage driver open_in_new PostgreSQL SQL open_in_new
Full SQL driver via sqlx — sort, search, aggregation, pagination, subquery traversal and columns from related tables. The framework can also read live changes from a NOTIFY trigger you install; turning that on from the app is next.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
check_circle |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
check_circle |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
check_circle |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
check_circle |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
check_circle |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | check_circle |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
check_circle |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
~Partly supported From Rust (Table::with_expression), or as a column of a read-only rhai: / base: table. There is no expr: column in SQL YAML yet.
|
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
~Partly supported From Rust (Table::with_expression), or as a column of a read-only rhai: / base: table. There is no expr: column in SQL YAML yet.
|
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
check_circle |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | ~Partly supported Needs a LISTEN/NOTIFY trigger you install, and the app doesn't switch it on yet. Changes arrive as "re-read the table", without the changed row. |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
check_circle |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
check_circle |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
check_circle |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
check_circle |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
check_circle |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
check_circle |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
check_circle |
What stands out
client.name sorts and filters like any column.base:, taking arguments from the page.LISTEN/NOTIFY trigger you install.MariaDB open_in_new Vantage driver open_in_new MariaDB SQL open_in_new
Full SQL driver via sqlx — sort, search, aggregation, pagination, subquery traversal, columns from related tables, transactional imports and relations scripted in Rhai. Tested on MariaDB 11; the same driver runs MySQL 8.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
check_circle |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
check_circle |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
check_circle |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
check_circle |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
check_circle |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | check_circle |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
check_circle |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
~Partly supported From Rust (Table::with_expression), or as a column of a read-only rhai: / base: table. There is no expr: column in SQL YAML yet.
|
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
~Partly supported From Rust (Table::with_expression), or as a column of a read-only rhai: / base: table. There is no expr: column in SQL YAML yet.
|
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
check_circle |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
check_circle |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
check_circle |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
check_circle |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
check_circle |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
check_circle |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
check_circle |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
check_circle |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
check_circle |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
check_circle |
What stands out
client.name sorts and filters like any column.base:, taking arguments from the page.modify: scripts add SQL conditions that YAML can't express: self.with_condition(ident("total") > 100).MongoDB open_in_new Vantage driver open_in_new MongoDB query operators open_in_new
Filters, sort and search push down to the server, including nested fields. Related columns run as $lookup joins, read-only views come from aggregation pipelines in YAML, and edits keep ObjectId, date and decimal types. On a replica set, tables update live through change streams and bulk imports run in one transaction.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
check_circle |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
check_circle |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
check_circle |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
check_circle |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
check_circle |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | check_circle |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
~Partly supported Matches with in against the related ids, fetched first — not as a subquery. |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
check_circle |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
check_circle |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | ~Partly supported Needs a replica set: change streams don't run on a standalone server. |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | ~Partly supported Needs a replica set: change streams don't run on a standalone server. |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | ~Partly supported Needs a replica set: change streams don't run on a standalone server. |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
check_circle |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
check_circle |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
check_circle |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
~Partly supported Needs a replica set: the import runs in a transaction. |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
check_circle |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
check_circle |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
check_circle |
What stands out
client.name columns joined server-side with $lookup, several hops deep.nested_path: address.city — searchable and filterable like any other.datetime and decimal columns check what you type.self.with_condition(#{price: #{"$gt": 100}}).DynamoDB open_in_new Vantage driver open_in_new DynamoDB Query open_in_new
Key and scan-filter queries with cursor pagination. Often paired as a read source with writes routed elsewhere.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
remove |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
remove |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
remove |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
remove |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
remove |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | check_circle |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
remove |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
remove |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
remove |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
remove |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
remove |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
remove |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
remove |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
remove |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
remove |
GraphQL open_in_new Vantage driver open_in_new GraphQL queries open_in_new
Generic and Hasura dialects. Read rows, filter, and drill into related records. With Hasura, sort, search and operator filters run on the server.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
~Partly supported Hasura dialect only. |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
~Partly supported Hasura dialect only. |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
~Partly supported Hasura dialect only. |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
remove |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
~Partly supported limit / offset are rendered when the table sets paginate: true, but the grid doesn't page with them yet.
|
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | remove |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
remove |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
remove |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
remove |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
remove |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
remove |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
remove |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
remove |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
remove |
REST endpoints as tables. Filters, sort and paging map onto the URL as configured per API, and work when the API honours them.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
~Partly supported Equality only, as a URL path segment or ?field=value; the API has to honour it. Can be set to filter in the app instead.
|
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
remove |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
~Partly supported When the API has a sort parameter (OrderingParams); one column at a time.
|
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
remove |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
~Partly supported Exact when the API reports a total (total_key); otherwise the rows are fetched and counted.
|
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
remove |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
~Partly supported When page/limit or skip/limit parameters are configured and the API supports them. |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | remove |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
remove |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
remove |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
remove |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
remove |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
remove |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
remove |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
remove |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
remove |
AWS open_in_new Vantage driver open_in_new
Infrastructure (CloudWatch, IAM, S3 and more) browsed as tables, with equality filters pushed down to the API.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
remove |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
remove |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
remove |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
remove |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
remove |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | remove |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
remove |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
remove |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
remove |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
remove |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
remove |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
remove |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
remove |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
remove |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
remove |
Wrap a command (aws, kubectl, gh…) and read the JSON it prints as rows.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
remove |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
remove |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
remove |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
remove |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
remove |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
remove |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | remove |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
remove |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
remove |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
remove |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
remove |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
remove |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
remove |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
remove |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
remove |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
remove |
Vantage driver open_in_new CSV format (RFC 4180) open_in_new
Local files as tables, with in-memory filtering and record-level traversal.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
remove |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
remove |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
remove |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
remove |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
remove |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | remove |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
remove |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
remove |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
remove |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
remove |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
remove |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
remove |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
remove |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
remove |
Vantage driver open_in_new JSON Lines format open_in_new
Append-only JSONL log files — write structured entries that the log viewer picks up on its next refresh.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
remove |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
remove |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
remove |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
remove |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
remove |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
remove |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
remove |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | remove |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
remove |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
remove |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
remove |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | remove |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | remove |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | remove |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
check_circle |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
remove |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
remove |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
remove |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
remove |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
remove |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
remove |
Tables held in the app's own memory — what Faker simulations and tests run on. Every operator, sort, search, paging, atomic import and live updates, with no server.
Retrieving data
Filter by conditionsFilter by conditions Narrow the available table rows by applying one or more conditions.table("order").where("status", "open") |
check_circle |
Conditional operatorsConditional operators Non-exact matching with basic operators such as >, < or !=. Full support also needs matching against a set of values (in) and fuzzy text matching (contains).table("order").where("total", ">=", 100) |
check_circle |
Server-side sortServer-side sort The server re-arranges the results, respecting each column's data type, ascending or descending, on several columns in a set priority.table("order").sort("total", "desc") |
check_circle |
Quick searchQuick search A text search across several columns, performed on the server, so it can cover the entire table.table("client").search("smith") |
check_circle |
CountCount The server can say exactly how many rows remain in the set once the conditions are applied.table("order").where("status", "due").count() |
check_circle |
Column aggregatesColumn aggregates Aggregate functions applied to a column of a filtered set. The backend supports at least some of them, such as max, avg, median or a string concatenation (group_concat).select().from("order")
.expression(avg(ident("total")).alias("avg_total"))
.expression(max(ident("total")).alias("max_total")) |
check_circle |
Random-access pagesRandom-access pages Skip any number of rows in a filtered set without a performance penalty, and request any number of records up to a maximum.select().from("order")
.order_by(ident("id"), "asc")
.limit(50, 1000) |
check_circle |
| Progressive (cursor) loadProgressive load A fall-back way to paginate: pages are sequential, and the next page can be fetched only after the previous one. | remove |
Relations
One-to-many relationsOne-to-many relations Only needs a filter by condition whose value comes from a record loaded from another table — in the same data source or a different one.table("client").where("id", "c1").ref("orders") |
check_circle |
Many-to-many relationsMany-to-many relations A condition can be an expression over another set, such as a subquery or a join. Partial support when the in operator can match against a set of values.table("client").where("country", "UK").ref("orders") |
~Partly supported Matches with in against the related ids, fetched first — not as a subquery. |
Relation aggregationRelation aggregation A column can be defined as an aggregate of a field or expression in a related table, calculated on the server. The classic example is account.balance as the sum of transaction.amount.// invoice.paid: the sum of its payments
coalesce(
select().value().from("payment")
.expression(sum(ident("amount")))
.where(ident("invoice") == parent("id"))
.group_all().subquery()[0],
0) |
remove |
Arbitrary expressionsArbitrary expressions Per-row operations on the server, over stored values or relation aggregates — for example invoice_line.total as qty * item.price.mul(ident("qty"), ident("price")).alias("total") |
remove |
Column from related tableColumn from related table Import a field through a one-to-one relation, such as country_id.name, as a read-only column.columns:
client.name: { type: string } |
remove |
Contained record expressionsContained record expressions A condition can iterate over sub-records stored inside a record — invoices whose JSON lines field contains item 123.self.with_condition(#{"lines.item_id": 123}) |
remove |
Relations defined in RhaiRelations defined in Rhai Executes arbitrary code after a row is retrieved from the table, in the context of the entire row — a Rhai lazy: script in YAML, or a Rust closure that can also do I/O.badge:
lazy: |
if row.vip { "★ " + row.name } else { row.name } |
check_circle |
Live updates
| Record changedRecord changed An open record updates in place when someone else changes it — the source pushes the change, no polling. | check_circle |
| Record set changedRecord set changed A filtered grid updates when rows matching its condition change, and a row that stops matching leaves the grid. | check_circle |
| Table changedTable changed Any insert, update or delete in the table reaches open grids as it happens. | check_circle |
Related record changedRelated record changed A change in a related table — an invoice_line — reaches the parent's view, such as invoice.total. |
remove |
Editing data
Add recordsAdd records Create rows from a form dialog. In a filtered view, new rows are filled in to belong to it.table("order")
.insert(#{ id: "o10", client: "c3", total: 55 }) |
check_circle |
Update recordsUpdate records Edit rows in a form dialog. A write through a filtered view can never change a row outside it.table("order").patch("o1", #{ status: "paid" }) |
check_circle |
Delete recordsDelete records Remove rows, after a confirmation step, from a grid or a record view.table("order").where("status", "paid").delete("o2") |
check_circle |
Atomic importAtomic import A CSV or wizard import lands in one transaction: either every new row is stored or none is, and existing rows are left untouched.let paid = table("order").where("status", "paid");
table("archive").import_from(paid) |
check_circle |
| Bulk modifyBulk modify Change every row of a filtered set in one operation — mark all open orders as shipped — without loading them first. | remove |
Custom tables
Tables from custom queriesTables from custom queries A table can be defined by a query instead of a stored table — a Rhai script or a native query — and is read-only.select().from("order")
.inner_join("client", "c", expr("c.id = order.client_id")) |
remove |
Table aggregateTable aggregate A table defined through another table — sales grouped by month, built on orders — inheriting its columns and relations.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")) |
remove |
| Server-side actionsServer-side actions An action runs inside the data source, such as a stored procedure or function, instead of in the app. | remove |
Misc
Auto-IDAuto-ID When the id column is flagged auto, an insert without an id asks the data source to assign one — auto-increment, a generated record id, an ObjectId — and returns it. Without the flag the app mints a UUIDv7 instead, so retries stay idempotent.# order.yaml
columns:
id: { type: int, flags: [id, auto] }
// Rhai — no id, so the data source makes one
let id = table("order")
.insert(#{ client: "c2", total: 30 }); |
check_circle |
IdempotencyIdempotency By default, every write can be retried safely: a repeated insert or delete changes nothing, and writes through a filtered view stay inside it. An insert that relies on Auto-ID is the exception — retrying it adds a second row.// run twice: same result
table("order")
.upsert("o10", #{ client: "c3", total: 60 }) |
check_circle |
What stands out
Every GUI element adapts to the table it's bound to — buttons and features follow the backend's capabilities.
In Vantage, UI elements are reactive. YAML controls layout and visibility, while Rhai drives behaviour. The data grid is a typical example — YAML sets column order, types, widths and captions, plus the buttons above the grid; Rhai scripts what a button press does.
As an enterprise client, you get a custom release pipeline with your own configuration of Vantage. Supply your own DataSources, add table definitions in native Rust, and bundle your own actions.
| Capability | Free download One binary, everyone | Enterprise build Assembled for you |
|---|---|---|
| Build your app | ||
| Datasources out of the box | 12 | 12 + your own |
| Colour themes | 36 | 36 + one that's yours |
| Native types mapped per source | up to 24 | + types you define |
| Grids past a million rows | check_circle | check_circle |
| Charts, KPI cards, dashboards | check_circle | check_circle |
| Multi-step wizards & CSV import | check_circle | check_circle |
| Custom form layouts | check_circle | check_circle |
| Auto-refresh in the background | check_circle | check_circle |
| Push-style instant refresh | SurrealDB, MongoDB | + CDC: Kafka, Debezium, custom listeners |
| Your AI agent builds it over MCP | check_circle | check_circle |
| MCP debugging — query preview, error check | check_circle | + BDD suite, run at build |
| Custom tables | ||
| Custom queries & aggregates | Rhai | Rhai + Rust |
| Actions | Rhai | Rhai + Rust |
| Internal API | Rhai | Rhai + Rust |
| Custom datasources | OSS, community | yours or ours |
| UI widgets | 21 built in | + your own in Rust |
| Third-party WASM plugins | remove | check_circle |
| Internal distribution control | ||
| Application distribution | vantage-ui.com, or clone by hand | internal catalog, apps auto-update |
| Platforms | macOS, Linux | + API/MCP |
| Verifiable builds, on demand | remove | your components in the binary |
| Your name, icon and About box | remove | check_circle |
| Turn off what you don't need | remove | check_circle |
| Your own release channel | remove | signed & notarised |
| vantage:// install allowlist | remove | your apps only |
| Central credentials & SSO sign-in | remove | check_circle |
| Crash telemetry | optional | your own Sentry account |
| What you're standing on | ||
| MIT framework underneath | forever | forever |
| Architecture partner & training | remove | check_circle |
| Support | community | Slack Connect |
| Price for personal and commercial use | $0 forever | talk to us |
Where Vantage is heading. Everything we ship reaches every build — free and enterprise alike. Tap the deck to flip through.
macOS and Linux today. A signed Windows build brings the same app to the laptops most of your colleagues actually use — no VM, no remote desktop.
The same YAML and Rhai, rendered in a browser. Hand someone a link instead of an install, and put a screen in front of people who will never open a terminal.
Turn the same config — data sources plus Rhai logic — into real backend APIs your own frontend or mobile app can call. Start in the app; graduate to code.
No lock-in: export your app to a code repository or container image you own and run anywhere. Start in low-code, leave with real code.
touch_app Click to deal the next card