Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ferra-db — the repository layer

The 0.3.0 Casting deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a CRUD-capable repository against a live PostgreSQL, with zero hand-written SQL.

This page is the authoritative user-guide for ferra-db. A reader with only this page plus standard Rust knowledge can produce a compiling CRUD round-trip on the first attempt — no ADR, no constitution, and no framework source is required.

At 0.4.0 Refining the ferra facade absorbs ferra-forge (the #[derive(FerraModel)] macro) and ferra-http (the CRUD handler layer). Consumers write use ferra::*; and no longer need a second direct dependency on ferra-forge — the 003 two-line pattern collapses to one line. See ferra-http.md for the HTTP surface that composes on top of this page’s repository layer.


Zero-to-CRUD in four lines

#![allow(unused)]
fn main() {
use ferra::{DatabaseConnection, FerraRepository, PgRepository};

let conn: DatabaseConnection = sea_orm::Database::connect(&database_url).await?;
let repo = PgRepository::<Film>::new(conn);
let film = repo.find_by_id(42).await?;
}

(Film is the canonical sibling-derive entity shape from ferra-forge — see the worked example below. database_url is read from the DATABASE_URL environment variable in the usual way.)


The FerraRepository<M> surface

FerraRepository<M> is an async trait with ten methods, split into two symmetric groups of five. The ambient-connection quintet runs against the pool supplied at construction time; the _in_tx quintet takes a caller-owned transaction handle.

Ambient-connection methods

MethodSignature summaryError outcomes
find_by_idasync fn find_by_id(&self, id: PkValue<M>) -> Result<M, FerraDbError>NotFound · Db
find_pageasync fn find_page(&self, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError>Db (never NotFound)
insertasync fn insert(&self, data: M) -> Result<M, FerraDbError>Conflict · Db
updateasync fn update(&self, id: PkValue<M>, data: M) -> Result<M, FerraDbError>NotFound · Conflict · Db
deleteasync fn delete(&self, id: PkValue<M>) -> Result<(), FerraDbError>NotFound · Db
  • PkValue<M> is the Sea-ORM primary-key value type for M — for most entities this is a plain i32 / i64 / Uuid. The exact type alias is pub type PkValue<M> = <<<M as sea_orm::ModelTrait>::Entity as sea_orm::EntityTrait>::PrimaryKey as sea_orm::PrimaryKeyTrait>::ValueType; — consumers rarely spell it out; it propagates through impl blocks automatically.
  • find_page is 1-indexed (page = 1 is the first page). A request past the end returns Ok(Page { items: vec![], total, .. }) — never NotFound.
  • update accepts (id, data) where data: M carries the new values. At 0.3.0 the caller is responsible for ensuring data’s primary key matches id; the 0.4.0 DTO layer will enforce that invariant upstream.

Transaction-scoped methods

Each ambient method has a paired _in_tx variant with the same name + suffix, taking &DatabaseTransaction as its first argument after &self:

#![allow(unused)]
fn main() {
async fn find_by_id_in_tx(&self, tx: &DatabaseTransaction, id: PkValue<M>) -> Result<M, FerraDbError>;
async fn find_page_in_tx (&self, tx: &DatabaseTransaction, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError>;
async fn insert_in_tx    (&self, tx: &DatabaseTransaction, data: M) -> Result<M, FerraDbError>;
async fn update_in_tx    (&self, tx: &DatabaseTransaction, id: PkValue<M>, data: M) -> Result<M, FerraDbError>;
async fn delete_in_tx    (&self, tx: &DatabaseTransaction, id: PkValue<M>) -> Result<(), FerraDbError>;
}

Semantics are identical to the ambient counterpart modulo the transaction handle. A bug fixed in one path is fixed in both — the two families share their inner implementation via a ConnectionTrait-generic helper.

Object-safety note

FerraRepository<M> is NOT object-safe — edition-2024 native async-in-trait does not support dyn FerraRepository<M>. Compose against it via generics: fn handler<R: FerraRepository<Film>>(repo: R, ...). A runtime-heterogeneous-backend story is deferred to the post-v1 ferra-db-* sibling crates.


The PgRepository<M> constructor

#![allow(unused)]
fn main() {
let repo: PgRepository<Film> = PgRepository::new(conn);
}
  • conn is a sea_orm::DatabaseConnection (re-exported from ferra-db so you do not need to add sea-orm as a direct dependency — ferra re-exports the name under ferra::DatabaseConnection).
  • The struct derives Clone unconditionally. Sea-ORM’s DatabaseConnection wraps its pool in an Arc, so repo.clone() is cheap — pass PgRepository<M> by value or by clone rather than wrapping in Arc<PgRepository<M>>.
  • The constructor performs no validation. Callers with a non-PostgreSQL DatabaseConnection compile but lose the SQLSTATE 23505Conflict mapping (those failures fall through to the Db(_) variant).

Bounds on M

PgRepository<M> works for any M that satisfies the 0.2.0 sibling-derive shape — i.e., the derive combination #[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]. Concretely, M must implement FerraModel (from ferra-core) plus the Sea-ORM trait set (ModelTrait, FromQueryResult, IntoActiveModel<ActiveModel>, with Entity: EntityTrait<Model = M> and the ActiveModel: ActiveModelTrait + ActiveModelBehavior + Send). The 0.2.0 canonical shape satisfies every bound automatically.

If you see error[E0277]: the trait bound <M>: sea_orm::ModelTrait is not satisfied at a PgRepository::<M>::new(conn) call site, you forgot the DeriveEntityModel derive on the same struct. Add it per the 0.2.0 canonical shape.


Error taxonomy

FerraDbError has exactly three variants. The set is locked from 0.3.0 through 0.6.0 Welding — adding a variant requires an ADR.

VariantWhenDisplay template0.4.0 HTTP mapping
NotFound { resource: &'static str, id: String }find_by_id / update / delete observed zero matching rows"{resource}/{id} not found"404 Not Found
Conflict(String)insert / update hit a Postgres SQLSTATE 23505 unique-constraint violation"unique constraint \"{constraint}\" violated on {target}"409 Conflict
Db(sea_orm::DbErr)Every other sea_orm::DbErrwhatever the wrapped DbErr renders as500 Internal Server Error (no internal fragment leaks into the response body)

Display guarantees

  • NotFound { resource: "films", id: "42".into() }.to_string()"films/42 not found".
  • Conflict("unique constraint \"films_title_key\" violated on column \"title\"".into()).to_string()"unique constraint \"films_title_key\" violated on column \"title\"".
  • Db(db_err).to_string() → whatever db_err.to_string() renders (e.g., "Record not found", "Execution Error: ...").

Every variant’s Display is stable through 0.6.0 Welding — the 0.4.0 RFC 7807 layer serializes these templates verbatim into the problem+json response. No Display output contains any internal Rust path fragment (no sea_orm::..., no sqlx::..., no rustc error codes).

source() + downcasting

FerraDbError::Db(_).source() returns Some(&dyn Error) pointing at the wrapped sea_orm::DbErr. You can downcast to inspect specific Sea-ORM failure modes:

#![allow(unused)]
fn main() {
use std::error::Error as _;

if let FerraDbError::Db(_) = &err {
    if let Some(db_err) = err.source().and_then(|s| s.downcast_ref::<sea_orm::DbErr>()) {
        // inspect db_err
    }
}
}

source() on NotFound and Conflict returns None — these variants carry their context in their fields.

Construction rules

External consumers cannot directly construct NotFound or Conflict — those variants are produced exclusively by the repository itself at the single from_sea_orm mapping site. A raw sea_orm::DbErr that propagates through ? becomes FerraDbError::Db(_) via the From<sea_orm::DbErr> derive; that is the only construction path available to user code.


The Page<M> shape

#![allow(unused)]
fn main() {
#[non_exhaustive]
#[derive(Debug)]
pub struct Page<M> {
    pub items: Vec<M>,
    pub total: u64,
    pub page: u32,
    pub per_page: u32,
}
}
  • 1-indexed page by framework convention. page = 0 is treated identically to page = 1 (the repository applies saturating_sub(1) internally to hand Sea-ORM its 0-indexed offset).
  • total: u64 fits any Postgres bigint in the non-negative half — the full-table row count observed by the count query.
  • items.len() <= per_page always. An over-scrolled request is Ok(Page { items: vec![], total, .. }), never NotFound.
  • Concurrent-insert drift. items.len() and total may disagree by ±1 under concurrent inserts — both queries run serially on the same connection (FR-015), not in parallel. Callers that need strict consistency use find_page_in_tx inside a SERIALIZABLE transaction.
  • Serialize, not Deserialize. Page<M>: serde::Serialize (inherited from M: FerraModel: Serialize). No Deserialize — pages are server-constructed, never parsed from client input.
  • #[non_exhaustive] — future phases may add fields (e.g., cursor: Option<Cursor> at 0.7.5 Enameling). Construct via Page::new(items, total, page, per_page), not a struct literal.

Transactions

ferra-db re-exports DatabaseConnection and DatabaseTransaction from Sea-ORM under their original names, so you can type both handles without a direct sea-orm dependency. Obtaining a transaction requires importing Sea-ORM’s TransactionTrait:

#![allow(unused)]
fn main() {
use ferra::{DatabaseConnection, DatabaseTransaction, FerraRepository, PgRepository};
use sea_orm::TransactionTrait; // import needed for .begin()

let tx: DatabaseTransaction = conn.begin().await?;
repo.insert_in_tx(&tx, film_1).await?;
repo.insert_in_tx(&tx, film_2).await?;
tx.commit().await?; // both rows persist atomically
}

Rollback on drop

Dropping a DatabaseTransaction without calling .commit() rolls it back. No cleanup call is required on the failure path:

#![allow(unused)]
fn main() {
let tx = conn.begin().await?;
repo.insert_in_tx(&tx, film_1).await?;
if let Err(e) = repo.insert_in_tx(&tx, film_2).await {
    // tx goes out of scope here → Sea-ORM rolls it back. film_1 is NOT persisted.
    return Err(e.into());
}
tx.commit().await?;
}

The TransactionTrait import

ferra-db deliberately does not re-export sea_orm::TransactionTrait under a Ferra-prefixed alias. Callers import it explicitly: use sea_orm::TransactionTrait;. This is a one-line acknowledgment that Sea-ORM owns the transaction mechanism; it is not a barrier to use, just intentional transparency.


No raw-SQL entry point

ferra-db publishes NO entry point that accepts a pre-formed SQL string. The following paths do NOT resolve and WILL NOT resolve in 0.3.0 — each is asserted by a trybuild compile-fail fixture (FR-029 is a release blocker):

ferra_db::Statement            // not reachable
ferra_db::execute_unprepared   // not reachable
ferra_db::raw_sql              // not reachable

The same three fragments are also blocked under ferra:: via the companion fixture at crates/ferra/tests/ui/no_raw_sql.rs.

The escape hatch

If you genuinely need raw SQL (a hand-written migration, a complex analytics query that defeats Sea-ORM’s builder), add sea-orm as a direct dependency of your consumer crate:

[dependencies]
ferra   = "0.3"
sea-orm = { version = "=2.0.0-rc.38", features = ["runtime-tokio-rustls", "sqlx-postgres", "macros"] }

Then use Sea-ORM’s own raw-SQL entry points (Statement::from_string, execute_unprepared, etc.) directly. This is a deliberate, transparent act — your code reads as “I am opting out of Ferra’s SQL-injection-by-construction contract for this specific query” rather than reaching through a convenience hole in the framework.


The ferra facade

The ferra crate is the single user-facing dependency. At 0.3.0 it re-exports the public surface of ferra-core and ferra-db:

#![allow(unused)]
fn main() {
use ferra::{
    FerraDbError, FerraRepository, PgRepository, Page,
    DatabaseConnection, DatabaseTransaction,
    Id, FerraModel, ModelMeta, FieldMeta, FieldType,
};
}

#[derive(FerraModel)] is NOT re-exported at 0.3.0. Consumers who want the derive at 0.3.0 add ferra-forge as a second direct dependency:

[dependencies]
ferra       = "0.3"
ferra-forge = "0.2"   # for #[derive(FerraModel)] — re-export arrives at 0.4.0

At 0.4.0 Refining, ferra will gain pub use ferra_forge::FerraModel; and the two-line pattern collapses to one. The 0.3.0 split is deliberate: re-exporting the derive at 0.3.0 would force every ferra-depending consumer to link the proc-macro toolchain even when only the repository layer is needed.


Worked example

A complete compiling example. Drop this into a downstream crate that depends on ferra, ferra-forge, sea-orm, serde, and the Sea-ORM migration crate. The DATABASE_URL environment variable must point at a reachable PostgreSQL 16.

1 — the migration

-- migrations/20260417000000_create_films.sql
CREATE TABLE films (
    id           SERIAL  PRIMARY KEY,
    title        TEXT    NOT NULL UNIQUE,
    release_year INTEGER,
    archived     BOOLEAN NOT NULL DEFAULT FALSE
);

2 — the entity (sibling-derive shape)

#![allow(unused)]
fn main() {
// src/film.rs
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[sea_orm(unique)]
    pub title: String,
    pub release_year: Option<i32>,
    pub archived: bool,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

pub use Model as Film;
}

3 — the CRUD round-trip

// src/main.rs
use ferra::{FerraDbError, FerraRepository, PgRepository};
use sea_orm::TransactionTrait;

mod film;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let db_url = std::env::var("DATABASE_URL")?;
    let conn = sea_orm::Database::connect(&db_url).await?;
    let repo = PgRepository::<film::Film>::new(conn.clone());

    // Insert
    let blade_runner = repo.insert(film::Model {
        id: 1,
        title: "Blade Runner".to_owned(),
        release_year: Some(1982),
        archived: false,
    }).await?;
    println!("inserted: {blade_runner:?}");

    // Read
    let one = repo.find_by_id(1).await?;
    assert_eq!(one.title, "Blade Runner");

    // Update
    let mut tweaked = one.clone();
    tweaked.archived = true;
    let updated = repo.update(1, tweaked).await?;
    assert!(updated.archived);

    // Paginate
    let page = repo.find_page(1, 10).await?;
    println!("page 1/10: {} items, {} total", page.items.len(), page.total);

    // Compound write: two inserts in a single transaction
    let tx = conn.begin().await?;
    repo.insert_in_tx(&tx, film::Model {
        id: 2, title: "Alien".into(), release_year: Some(1979), archived: false,
    }).await?;
    repo.insert_in_tx(&tx, film::Model {
        id: 3, title: "2001: A Space Odyssey".into(), release_year: Some(1968), archived: false,
    }).await?;
    tx.commit().await?;

    // Delete
    repo.delete(1).await?;
    assert!(matches!(repo.find_by_id(1).await, Err(FerraDbError::NotFound { .. })));

    Ok(())
}

Troubleshooting

"films/42 not found"

You observed FerraDbError::NotFound { resource: "films", id: "42" } — the find_by_id / update / delete call targeted a primary key that has no row. At the HTTP layer (0.4.0) this maps to 404. Check that the id value you passed corresponds to an extant row.

"unique constraint \"films_title_key\" violated on column \"title\""

You observed FerraDbError::Conflict(_) — Postgres returned SQLSTATE 23505. The inner message names the constraint and, when available, the offending column. At the HTTP layer (0.4.0) this maps to 409. Resolve by either changing the colliding value or deleting the existing row first.

A DbErr::Custom("...") in logs

You observed FerraDbError::Db(_) — any sea_orm::DbErr that is not a recognized NotFound / NotUpdated / unique-violation shape falls through here. The wrapped DbErr is reachable via err.source().and_then(|s| s.downcast_ref::<sea_orm::DbErr>()). At the HTTP layer (0.4.0) this maps to 500 without leaking the internal detail into the response body.

error[E0277]: the trait bound <M>: sea_orm::ModelTrait is not satisfied

You forgot the DeriveEntityModel derive on the struct. The 0.2.0 sibling-derive canonical shape requires BOTH FerraModel AND DeriveEntityModel on the same #[derive(...)] list. Add DeriveEntityModel back and recompile.

error[E0425]: cannot find value Statement in crate ferra_db

Expected — ferra-db publishes no raw-SQL entry point (FR-007). To reach Sea-ORM’s raw-SQL surface, add sea-orm as a direct dependency of your consumer crate and call through it explicitly.


Stability commitment

Item0.3.0 commitmentChange requires
FerraDbError variant listThree variants — NotFound, Conflict, DbADR (locked through 0.6.0 Welding)
FerraDbError::Display wordingStable templates per §Error taxonomyADR + user-guide update
FerraRepository<M> method setTen methods — five ambient + five _in_txADR to remove / rename; additions are additive
PgRepository::new(conn)(conn: DatabaseConnection) -> SelfADR
Page<M> field listFour named fields (items, total, page, per_page)#[non_exhaustive] allows additions; removals require ADR
DatabaseConnection / DatabaseTransaction re-exportsStable names, re-exported from Sea-ORMADR
Forbidden raw-SQL fragmentsStatement / execute_unprepared / raw_sql do not resolveADR

MSRV: rust-version = "1.88" — inherited from the workspace. Edition 2024 native async-in-trait requires it.