Skip to content
Tutorials

Production access patterns

A document list shows titles and comment counts. Opening that screen shouldn't load every document body or ask the database for each comment count separately.

Build the response you need with projections and one grouped count, then keep edit commands in short transactions. This follows the enterprise document example.

Read the response shape

A document card needs an ID, title and comment count. Its large body and settings need not be fetched:

let rows = client.document()
    .id_starts_with("production-")
    .id_gt(after)
    .order_by(Document::id)
    .limit(limit)
    .select((Document::id, Document::title))
    .all().await?;

Here client is a ScopedClient supplied by the authenticated request boundary. Projection preserves its tenant and read restrictions, so a smaller response shape doesn't expose private documents or matching IDs from another tenant.

Use the last returned ID as after for this forward-only, ID-ordered list. A general feed with configurable ordering should use the generated keyset/page APIs described in querying collections. Apply filters and limits in the database instead of collecting an unbounded list and slicing it afterwards.

Aggregate once for the page

Return early for an empty page. Otherwise fetch comment counts for all returned IDs together:

let counts: Vec<CommentCount> = client.comment()
    .document_id_in(rows.iter().map(|(id, _)| id.clone()))
    .group_by(Comment::document_id)
    .count("comments")
    .all().await?;

CommentCount is a small Deserialize struct with document_id: String and comments: u64. Missing groups mean zero readable comments. Both queries retain the caller's scope. One grouped count avoids a separate count call for every card. An empty page needs no count query.

Generated with_* and with_*_count methods are alternatives when the response needs hydrated relationships. Choose the response shape first, rather than invoking a full configuration/runtime loader to retrieve one name. Decrypt credentials only where their use requires plaintext.

Pass the transaction into helpers

A rename command can receive a scoped client, update the document, and append its mandatory history in one short application transaction. Its audit adapter holds explicit root authority and joins that same transaction through system_in. The handler does not receive that authority.

If history fails, the title update rolls back. Keep external delivery or model work outside this database transaction so a slow external request doesn't hold its connection.

Bound worker reads

For queue-like claiming, the existing row-lock API selects a bounded batch inside a transaction:

let rows = tx.document()
    .order_by(Document::id)
    .limit(2)
    .for_update()
    .skip_locked()
    .all().await?;

Use a serializable scoped transaction on PostgreSQL for this permission-controlled locking pattern. Other backends have different locking support. See transactions. A worker must persist its claim or state change before releasing the transaction. SKIP LOCKED is not a consistent general-purpose listing strategy.

Reuse facts without retaining authority indefinitely

Resolve stable request identity/configuration facts once and pass them through. Batch equivalent team and resource lookups within a read operation. Keep permission decisions scoped to the tenant, effective caller, resource and action. Later mutations and tool calls still need their required fresh authorization.

Use runtime tracing to observe database work. Logical query budgets catch accidental per-item loaders. They do not measure every SQL statement or network round trip.

Complete example

Download this project, or copy the files below into an empty directory.

Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.

Use a separate local database for this example:

export DATABASE_URL='sqlite://lesson.db?mode=rwc'
rent generate
rent migrate dev --name initial
cargo run

Run the included test with cargo nextest run. It creates an isolated temporary database.

Cargo.toml
[package]
name = "rent-lesson-42-production-access"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-42-production-access"
path = "main.rs"

[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.7.2", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
serde = { version = "1.0.229", features = ["derive", "rc"] }
tempfile = "3.27.0"

# Used by the generated client after `rent generate`.
[package.metadata.cargo-machete]
ignored = ["rent"]
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "sqlite"
backends = ["sqlite"]
schema.rsl
datasource db {
  provider = "sqlite"
}

model Post {
  id       BigInt     @id  @default(autoincrement())
  title    String
  body     String
  comments Comment[]
}

model Comment {
  id      BigInt  @id  @default(autoincrement())
  body    String
  post_id BigInt
  post    Post    @relation(fields: [post_id], references: [id], onDelete: Cascade)
}
main.rs
use anyhow::Result;
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
use generated::client::Client;
use generated::{comment::Comment, post::Post};
#[derive(serde::Deserialize)]
struct CommentCount {
    post_id: i64,
    comments: i64,
}
async fn example(url: &str) -> Result<()> {
    let client = Client::connect(url).await?;
    client
        .transaction(async |tx| {
            let post = tx
                .create_post()
                .title("Launch notes")
                .body("A long body that a card does not need")
                .save()
                .await?;
            tx.create_comment().post_id(post.id).body("Reviewed").save().await?;
            Ok(())
        })
        .await?;
    let rows = client
        .post()
        .order_by(Post::id)
        .limit(25)
        .select((Post::id, Post::title))
        .all()
        .await?;
    if rows.is_empty() {
        return Ok(());
    }
    let counts: Vec<CommentCount> = client
        .comment()
        .post_id_in(rows.iter().map(|(id, _)| *id))
        .group_by(Comment::post_id)
        .count("comments")
        .all()
        .await?;
    for (id, title) in rows {
        let count = counts.iter().find(|row| row.post_id == id).map_or(0, |row| row.comments);
        println!("{title}: {count} comments");
    }
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    example(&std::env::var("DATABASE_URL")?).await
}
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn example_runs() -> anyhow::Result<()> {
        let (_directory, url) = super::test_support::database()?;
        super::example(&url).await
    }
}
test_support.rs
use anyhow::{Context, Result, ensure};
use std::process::Command;

pub fn database() -> Result<(tempfile::TempDir, String)> {
    let directory = tempfile::tempdir()?;
    std::fs::write(directory.path().join("schema.rsl"), include_str!("schema.rsl"))?;
    std::fs::write(directory.path().join("rent.toml"), r#"schema = "schema.rsl""#)?;
    let url = format!("sqlite://{}?mode=rwc", directory.path().join("app.db").display());
    let executable = std::env::var("RENT_CLI").unwrap_or_else(|_| "rent".into());
    let output = Command::new(executable)
        .arg("migrate")
        .arg("dev")
        .arg("--root")
        .arg(directory.path())
        .arg("--to")
        .arg("schema.rsl")
        .arg("--database-url")
        .arg(&url)
        .output()
        .context("run the Rent CLI")?;
    ensure!(
        output.status.success(),
        "migration failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    Ok((directory, url))
}