One derive, four databases, rollbacks you don’t write.

A model is a struct with #[derive(Entity)]. The same entity runs on SQLite, MySQL/MariaDB, Postgres and Cloudflare D1 — and on wasm, where CI checks the ORM builds.

What you get

  • Drivers: MySQL/MariaDB, Postgres and SQLite through sea-orm-executor; Cloudflare D1 (d1-http) and libSQL/Turso (libsql-http) over HTTP.
  • Read/write split with sticky reads, and sharding.
  • Step::create_table::<Post> builds the table from the model; Step::table derives its own rollback.
  • Relationships load for a whole slice in one WHERE … IN — N+1 is unrepresentable.
  • Soft deletes, route keys, pagination, model hooks on the event bus (a -ing hook can veto).

In the docs

src/app/models/post.rs
use rainier_framework::prelude::*;
use serde::Serialize;

#[derive(Entity, Clone, Debug, Serialize)]
#[orm(table = "posts")]
pub struct Post {
    #[orm(pk, auto_increment)]
    pub id: u64,
    #[orm(unique)]
    pub slug: String,
    pub title: String,
    pub published: bool,
    #[orm(index, references = "users(id)", on_delete = "cascade")]
    pub author_id: u64,
    #[orm(soft_delete)]
    pub deleted_at: Option<DateTime<Utc>>,
}

impl Post {
    pub fn author() -> BelongsTo<Post, User> {
        BelongsTo::new().foreign_key("author_id")
    }
}
src/database/migrations/m0008_posts_add_excerpt.rs
use rainier_framework::database::Step;

pub fn migration() -> Step {
    // No `down` to write: adding a column reverses to dropping it,
    // an index to dropping the index — in reverse order.
    Step::table("0008_posts_add_excerpt", "posts", |table| {
        table.string_len("excerpt", 500).nullable();
        table.index(["author_id", "created_at"]);
    })
}
src/app/http/controllers/post_controller.rs
pub async fn index(Validated(query): Validated<ListPostsRequest>) -> Result<Response> {
    let posts = resolve::<PostRepository>()?;
    let users = resolve::<UserRepository>()?;

    let page = posts
        .paginate_matching(Criteria::new().where_eq("published", true), query.page, 20)
        .await?;

    // One `WHERE id IN (…)` for every author on the page. `load` takes the
    // slice, not a post — there is no per-row call to put in a loop.
    let authors = Post::author().load(&page.data, &**users).await?;

    Ok(Response::json(&page))
}

Up close

Select an image to see it full size.

Rollbacks are derived from each step; a step that cannot be undone says so when it runs and refuses rollback with its reason, instead of silently doing nothing.
One #[derive(Entity)] declares the table, indexes, foreign key and soft-delete column, and the same struct runs on SQLite, MySQL, Postgres and D1.
An irreversible migration has to say why, and that sentence is what migrate:rollback prints.
Every query the ORM generated, with timings; across the whole run of the sample app none was slow and none was part of an N+1.
All features

Type to search 2.0.0 docs.