Docs
Databases
Connect SQLx, Diesel, or Toasty to a Proa DataLoader and render typed rows as components.
Proa is database and ORM agnostic. The framework crates ship no driver, no query builder, and no opinion about your data layer. The CLI offers a starting point you can keep or replace: proa add database wires SQLite through SQLx and proa db runs migrations.
DataLoader is the seam. It is a trait you implement, not a driver Proa ships, and its one required method returns a future, so anything you can await composes behind one key: a pool checkout, an ORM query, an HTTP call, a cache read, or all four.
This page connects SQLx, Diesel, and Toasty to that trait. Everything above the loader is the same in all three, which is the point; only the module that defines Product differs (src/data/loader.rs for SQLx, src/data/schema.rs for Diesel, src/data/model.rs for Toasty), so adjust that one import. Installing and configuring each library is its own documentation; this page starts once you have a pool.
The part that does not change
A component asks for what it needs by key. It never learns which library answers:
use std::future::Future;
use std::sync::Arc;
use proa_core::{DataLoader, RenderOutcome, WebContext, WebRender, WriteError};
use proa_macros::{html, text};
use crate::data::loader::{product_key, Product};
pub struct ProductPage {
pub id: i64,
}
impl<L: DataLoader> WebRender<L> for ProductPage {
fn render(
self,
cx: &mut WebContext<L>,
) -> RenderOutcome<impl Future<Output = Result<(), WriteError>>> {
RenderOutcome::pending(async move {
let product: Arc<Product> = cx.load_typed(product_key(self.id)).await?;
html! {
<main aria-label="Product details">
<article>
<h1>{product.name.as_str()}</h1>
<p>{text!("${}.{:02}", product.price_cents / 100, product.price_cents % 100)}</p>
</article>
</main>
}
.render(cx)
.resolve()
.await
})
}
}
load_typed pairs with LoadValue::typed in the loader, so a database row moves from the query to the component as itself. Nothing is serialized to JSON on the way.
Two details decide whether a loader can answer a key at all:
LoadKey::named("product", id)renders asproduct:42, which a loader can parse.LoadKey::typedhashes its input intoproduct#1e9f73…, so use the named form whenever the loader needs the id back.- Request-local dedupe is keyed on that string. Two components asking for
product:42in one request produce one query.
Application-wide handles are shared the same way regardless of library. FrameworkBuilder is intentionally not generic over an application state type, so the loader goes through the response:
use std::sync::Arc;
use axum::{extract::Extension, routing::get};
use proa_framework_axum::{router_from_spec, FrameworkBuilder, Route};
use crate::{data::loader::AppLoader, db::connect_database};
let db = connect_database().await?;
let loader = Arc::new(AppLoader { db });
let (router, metadata) = router_from_spec(vec![
Route::page("/products/:id", "Product", get(product)),
]);
let app = FrameworkBuilder::new(manifest)
.with_dynamic_routes_and_metadata(router, metadata)
.build()
.layer(Extension(loader));
See Route handlers for the broader state pattern, and Data loading and streaming for cache hints, dedupe, and streaming boundaries.
Everything below differs only in how the pool is built and what the loader runs.
SQLx
SQLx is an asynchronous SQL toolkit rather than an ORM: connection pooling, migrations, typed row decoding, and optional compile-time query checking for SQLite, PostgreSQL, and MySQL. You write SQL.
use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, SqlitePool};
static MIGRATOR: Migrator = sqlx::migrate!();
pub async fn connect_database() -> anyhow::Result<SqlitePool> {
let database_url = std::env::var("DATABASE_URL")?;
let db = SqlitePoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await?;
MIGRATOR.run(&db).await?;
Ok(db)
}
use std::future::Future;
use proa_core::{DataLoader, LoadError, LoadKey, LoadRequest, LoadValue};
use sqlx::{FromRow, SqlitePool};
#[derive(Debug, FromRow)]
pub struct Product {
pub name: String,
pub price_cents: i64,
}
pub fn product_key(id: i64) -> LoadKey {
LoadKey::named("product", id.to_string())
}
pub struct AppLoader {
pub db: SqlitePool,
}
impl DataLoader for AppLoader {
fn load(
&self,
request: &LoadRequest,
) -> impl Future<Output = Result<LoadValue, LoadError>> + Send {
let db = self.db.clone();
let key = request.key().to_string();
async move {
let Some(id) = key.strip_prefix("product:") else {
return Err(LoadError::custom(format!("unknown load key: {key}")));
};
let row = sqlx::query_as::<_, Product>(
"SELECT name, price_cents FROM products WHERE id = ?",
)
.bind(id.parse::<i64>().map_err(|error| LoadError::custom(error.to_string()))?)
.fetch_optional(&db)
.await
.map_err(|error| LoadError::custom(error.to_string()))?;
match row {
Some(product) => Ok(LoadValue::typed(product)),
None => Err(LoadError::custom("product not found")),
}
}
}
}
use std::sync::Arc;
use axum::extract::{Extension, Path};
use axum::response::IntoResponse;
use proa_framework_axum::RouteResponse;
use proa_stream::DataResolver;
use crate::data::loader::AppLoader;
pub async fn product(
Extension(loader): Extension<Arc<AppLoader>>,
Path(id): Path<i64>,
) -> impl IntoResponse {
let loader = DataResolver::request_scope(loader);
RouteResponse::ssr_async_with_loader(ProductPage { id }, loader)
}
use sqlx::SqlitePool;
pub async fn update_price(db: &SqlitePool, id: i64, price_cents: i64) -> sqlx::Result<()> {
let mut transaction = db.begin().await?;
sqlx::query("UPDATE products SET price_cents = ? WHERE id = ?")
.bind(price_cents)
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
src/db.rs — A SQLx pool is cheap to clone and every clone points at the same shared pool, so the loader holds one by value. Running migrations at startup suits a single instance; in a horizontally scaled deployment run them once as a release command instead of having every replica race.
src/data/loader.rs — .bind(...) sends the id as a query parameter rather than interpolating it into the SQL. fetch_optional keeps a missing row distinct from a database failure.
src/routes/products.rs — The handler wraps the shared loader in a request-scoped DataResolver, hands it to the response, and returns. It runs no query itself: the component asks for what it needs while rendering, and two components asking for the same key share one query because the resolver deduplicates within the request. Passing the bare Arc<AppLoader> also works, but then each component that asks runs its own query.
src/routes/update_price.rs — Writes own their transaction and commit before a response is constructed.
Diesel
Diesel is a query builder and ORM with a typed schema, so a mismatch between schema and query is a compile error rather than a runtime one. Diesel itself is synchronous; diesel-async supplies async connections and pooling for PostgreSQL and MySQL.
use diesel_async::{
pooled_connection::{bb8, AsyncDieselConnectionManager},
AsyncPgConnection,
};
pub type Pool = bb8::Pool<AsyncPgConnection>;
pub async fn connect_database() -> anyhow::Result<Pool> {
let database_url = std::env::var("DATABASE_URL")?;
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_url);
Ok(bb8::Pool::builder().build(config).await?)
}
use diesel::prelude::*;
diesel::table! {
products (id) {
id -> BigInt,
name -> Text,
price_cents -> BigInt,
}
}
#[derive(Debug, HasQuery)]
pub struct Product {
pub name: String,
pub price_cents: i64,
}
use std::future::Future;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use proa_core::{DataLoader, LoadError, LoadKey, LoadRequest, LoadValue};
use crate::data::schema::{products, Product};
use crate::db::Pool;
pub fn product_key(id: i64) -> LoadKey {
LoadKey::named("product", id.to_string())
}
pub struct AppLoader {
pub db: Pool,
}
impl DataLoader for AppLoader {
fn load(
&self,
request: &LoadRequest,
) -> impl Future<Output = Result<LoadValue, LoadError>> + Send {
let db = self.db.clone();
let key = request.key().to_string();
async move {
let Some(id) = key.strip_prefix("product:") else {
return Err(LoadError::custom(format!("unknown load key: {key}")));
};
let id: i64 = id.parse().map_err(|error| LoadError::custom(error.to_string()))?;
let mut conn = db.get().await.map_err(|error| LoadError::custom(error.to_string()))?;
let row = Product::query()
.filter(products::id.eq(id))
.first(&mut conn)
.await
.optional()
.map_err(|error| LoadError::custom(error.to_string()))?;
match row {
Some(product) => Ok(LoadValue::typed(product)),
None => Err(LoadError::custom("product not found")),
}
}
}
}
use diesel::prelude::*;
use diesel::result::Error;
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::data::schema::products;
use crate::db::Pool;
pub async fn update_price(db: &Pool, id: i64, price_cents: i64) -> anyhow::Result<()> {
let mut conn = db.get().await?;
conn.transaction::<_, Error, _>(async |conn| {
diesel::update(products::table.filter(products::id.eq(id)))
.set(products::price_cents.eq(price_cents))
.execute(conn)
.await?;
Ok(())
})
.await?;
Ok(())
}
src/db.rs — diesel-async re-exports its pool backends, so bb8 comes from diesel_async::pooled_connection::bb8 rather than a direct dependency.
src/data/schema.rs — diesel_cli normally generates this from your migrations; it is inline here so the example stands alone. #[derive(HasQuery)] gives you Product::query() and proves the result can be loaded into this type.
src/data/loader.rs — .filter(products::id.eq(id)) is a bound parameter, not string interpolation. .optional() comes from OptionalExtension in diesel::prelude and turns a "no rows" error into Ok(None), which keeps a missing row distinct from a failed query.
src/routes/update_price.rs — diesel-async takes an async closure and commits when it returns Ok.
Toasty
Toasty is the Tokio project's async ORM. You define models as plain Rust structs and it infers the schema, generating the query, create, and update builders so you write no SQL. It targets SQLite, PostgreSQL, MySQL, Turso, and DynamoDB from one model definition.
Toasty is pre-1.0 and moving quickly. Pin a version, and expect API changes between minor releases in a way you would not expect from SQLx or Diesel.
#[derive(Debug, toasty::Model)]
pub struct Product {
#[key]
#[auto]
pub id: uuid::Uuid,
pub name: String,
pub price_cents: i64,
}
pub async fn connect_database() -> toasty::Result<toasty::Db> {
let url = std::env::var("TOASTY_CONNECTION_URL")
.unwrap_or_else(|_| "sqlite::memory:".to_string());
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect(&url)
.await?;
Ok(db)
}
use std::future::Future;
use proa_core::{DataLoader, LoadError, LoadKey, LoadRequest, LoadValue};
use crate::data::model::Product;
pub fn product_key(id: uuid::Uuid) -> LoadKey {
LoadKey::named("product", id.to_string())
}
pub struct AppLoader {
pub db: toasty::Db,
}
impl DataLoader for AppLoader {
fn load(
&self,
request: &LoadRequest,
) -> impl Future<Output = Result<LoadValue, LoadError>> + Send {
// `Db` is cheap to clone and shares its pool, but the executor methods
// take `&mut self`, so each load works on its own handle.
let mut db = self.db.clone();
let key = request.key().to_string();
async move {
let Some(id) = key.strip_prefix("product:") else {
return Err(LoadError::custom(format!("unknown load key: {key}")));
};
let id: uuid::Uuid = id.parse().map_err(|error| LoadError::custom(error.to_string()))?;
let row = Product::filter_by_id(id)
.first()
.exec(&mut db)
.await
.map_err(|error| LoadError::custom(error.to_string()))?;
match row {
Some(product) => Ok(LoadValue::typed(product)),
None => Err(LoadError::custom("product not found")),
}
}
}
}
use crate::data::model::Product;
pub async fn update_price(
db: &toasty::Db,
id: uuid::Uuid,
new_price: i64,
) -> toasty::Result<()> {
let mut tx = db.transaction().await?;
let mut product = Product::get_by_id(&mut tx, &id).await?;
toasty::update!(product { price_cents: new_price }).exec(&mut tx).await?;
tx.commit().await?;
Ok(())
}
src/data/model.rs — #[key] marks the primary key and #[auto] fills it on insert, which for a Uuid means a time-ordered UUID v7. The attributes drive the generated API: #[unique] on a field is what makes Product::get_by_<field> exist at all.
src/db.rs — toasty::models! discovers every #[derive(Model)] in the crate, so models are not listed by hand. A fresh database has no tables; db.push_schema().await? creates them straight from the models, which is the quick path for demos and tests. Use migrations for anything with data you care about.
src/data/loader.rs — filter_by_* builds a lazy query and the terminal decides the result shape: .first().exec(&mut db) yields Option, .exec(&mut db) on the unfiltered query yields Vec, and .get(&mut db) expects exactly one row and errors otherwise.
src/routes/update_price.rs — A Toasty transaction is a value you pass where you would pass the Db. Dropping it rolls back, so an early return needs no explicit rollback.
Choosing
| SQLx | Diesel | Toasty | |
|---|---|---|---|
| You write | SQL | Typed query builder | Model methods |
| Query checking | Optional, via query! and offline metadata | Always, by the Rust type system | Generated from the model |
| Async | Native | Via diesel-async | Native |
| Backends | SQLite, PostgreSQL, MySQL | SQLite, PostgreSQL, MySQL | SQLite, PostgreSQL, MySQL, Turso, DynamoDB |
| Maturity | Stable, widely deployed | Stable, widely deployed | Pre-1.0, released April 2026 |
None of this changes anything on the Proa side. Pick on the merits of the data layer.
Do not hold a transaction across a render
This applies to every library above. Keep a transaction in the handler that owns the mutation, and commit it before constructing the response. Do not place a live transaction inside a component or in shared application state. Its lifetime should be limited to one request operation, and no HTML should be emitted until the write either commits or rolls back.
When to use DataLoader
Do not wrap your database library in a DataLoader solely because Proa has one. A handler query followed by a sync component is the shortest and most explicit path for a page that needs one result.
Use a loader when independent components initiate reads, several components may request the same key, or a slow section should participate in Suspense. Because DataLoader::load is just an async method you write, the loader can own whatever handle your library uses: a cloned SqlitePool or PgPool, a bb8::Pool<AsyncPgConnection>, a toasty::Db, or several of them at once behind different key namespaces. Return LoadValue::typed(record) for a Rust value or LoadValue::bytes(..) for serialized JSON.
See Data loading and streaming for that pattern, and Caching before caching database results across requests.
Next steps
- Data loading and streaming
- Implement a
DataLoader, key requests, and stream slow sections withSuspense.
- Implement a
- Route handlers
- Build JSON, form, and webhook endpoints, and share application state.
- Forms and actions
- Accept a write, validate it on the server, and re-render.
- Caching
- Decide what is safe to cache across requests.