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
ferrafacade absorbsferra-forge(the#[derive(FerraModel)]macro) andferra-http(the CRUD handler layer). Consumers writeuse ferra::*;and no longer need a second direct dependency onferra-forge— the 003 two-line pattern collapses to one line. Seeferra-http.mdfor 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
| Method | Signature summary | Error outcomes |
|---|---|---|
find_by_id | async fn find_by_id(&self, id: PkValue<M>) -> Result<M, FerraDbError> | NotFound · Db |
find_page | async fn find_page(&self, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError> | Db (never NotFound) |
insert | async fn insert(&self, data: M) -> Result<M, FerraDbError> | Conflict · Db |
update | async fn update(&self, id: PkValue<M>, data: M) -> Result<M, FerraDbError> | NotFound · Conflict · Db |
delete | async fn delete(&self, id: PkValue<M>) -> Result<(), FerraDbError> | NotFound · Db |
PkValue<M>is the Sea-ORM primary-key value type forM— for most entities this is a plaini32/i64/Uuid. The exact type alias ispub 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 throughimplblocks automatically.find_pageis 1-indexed (page = 1is the first page). A request past the end returnsOk(Page { items: vec![], total, .. })— neverNotFound.updateaccepts(id, data)wheredata: Mcarries the new values. At 0.3.0 the caller is responsible for ensuringdata’s primary key matchesid; 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);
}
connis asea_orm::DatabaseConnection(re-exported fromferra-dbso you do not need to addsea-ormas a direct dependency —ferrare-exports the name underferra::DatabaseConnection).- The struct derives
Cloneunconditionally. Sea-ORM’sDatabaseConnectionwraps its pool in anArc, sorepo.clone()is cheap — passPgRepository<M>by value or by clone rather than wrapping inArc<PgRepository<M>>. - The constructor performs no validation. Callers with a non-PostgreSQL
DatabaseConnectioncompile but lose the SQLSTATE23505→Conflictmapping (those failures fall through to theDb(_)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.
| Variant | When | Display template | 0.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::DbErr | whatever the wrapped DbErr renders as | 500 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()→ whateverdb_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
pageby framework convention.page = 0is treated identically topage = 1(the repository appliessaturating_sub(1)internally to hand Sea-ORM its 0-indexed offset). total: u64fits any Postgresbigintin the non-negative half — the full-table row count observed by the count query.items.len() <= per_pagealways. An over-scrolled request isOk(Page { items: vec![], total, .. }), neverNotFound.- Concurrent-insert drift.
items.len()andtotalmay disagree by ±1 under concurrent inserts — both queries run serially on the same connection (FR-015), not in parallel. Callers that need strict consistency usefind_page_in_txinside aSERIALIZABLEtransaction. - Serialize, not Deserialize.
Page<M>: serde::Serialize(inherited fromM: FerraModel: Serialize). NoDeserialize— 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 viaPage::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
| Item | 0.3.0 commitment | Change requires |
|---|---|---|
FerraDbError variant list | Three variants — NotFound, Conflict, Db | ADR (locked through 0.6.0 Welding) |
FerraDbError::Display wording | Stable templates per §Error taxonomy | ADR + user-guide update |
FerraRepository<M> method set | Ten methods — five ambient + five _in_tx | ADR to remove / rename; additions are additive |
PgRepository::new(conn) | (conn: DatabaseConnection) -> Self | ADR |
Page<M> field list | Four named fields (items, total, page, per_page) | #[non_exhaustive] allows additions; removals require ADR |
DatabaseConnection / DatabaseTransaction re-exports | Stable names, re-exported from Sea-ORM | ADR |
| Forbidden raw-SQL fragments | Statement / execute_unprepared / raw_sql do not resolve | ADR |
MSRV: rust-version = "1.88" — inherited from the workspace. Edition 2024 native async-in-trait requires it.