Skip to content

Repo and executor

Repo is the only DB gateway a service uses; the ambient executor is what makes transactions and row-level filtering transparent.

Two pieces carry the data layer’s transparency: the ambient Executor installed per request (or per job), and the Repo<E> handle every service uses to reach it. They are built on SeaORM — Executor implements SeaORM’s ConnectionTrait, so any SeaORM query runs through it unchanged — and on the seam from nest-rs-database, which is what keeps the same seam open to a non-SeaORM driver.

The invariant: every data access goes through a service, and a service reaches the DB only through Repo. That is the single audited choke point per entity.

From a handler’s point of view there is nothing to set up: you call the service, the service calls Repo, and Repo picks up the ambient executor the framework installed before the handler ran — the pool on a safe route, a transaction on a mutation. No connection parameter, no transaction object to thread through the call stack.

Repo<E> is generic over the entity. Every method runs against the ambient executor, every read carries the ambient ability filter:

nest_rs::seaorm::Repo
Repo::<Posts>::conn()?; // raw handle for a custom query
Repo::<Posts>::all().await?; // every readable row
Repo::<Posts>::find_by_id(id).await?; // None if absent OR out of scope
Repo::<Posts>::scoped(Action::Read); // Select<E> pre-filtered, chain freely
Repo::<Posts>::update(active).await?; // DbErr::RecordNotUpdated if out of scope
Repo::<Posts>::delete(model).await?; // 0 rows if out of scope
Repo::<Posts>::page(first, after, Condition::all()).await?; // keyset page — see Pagination

A by-id write (update, delete) ANDs condition_for(Update|Delete) with the primary key, so a caller cannot mutate a row outside its scope even with a leaked id. The macro-generated CrudService::access(action, id) loads unscoped so a denied-but-existing row surfaces as Access::Denied (distinct from Access::Missing) — the gateway route-model binding goes through.

Executor is a SeaORM connection — the pool or the request’s transaction — installed in a tokio::task_local! for the duration of the handler. Three variants:

nest_rs::seaorm::Executor
pub enum Executor {
Pool(DatabaseConnection),
Txn(Arc<DatabaseTransaction>),
Lazy(Arc<LazyTransaction>),
}

Lazy is the one the framework installs wherever work may write: it defers BEGIN to the first data-layer touch, so a request a guard denies — or one that never queries — costs no round-trip at all. Txn is the eager handle, for a transaction something already opened.

Repo::<E>::conn() reads the task-local back. Outside the scope it returns an error naming the DbContext interceptor — a service called with no ambient executor is a wiring bug, not a silent miss.

crates/features/src/posts/service.rs
let conn = Repo::<Posts>::conn()?;
let row = Posts::find().filter(Column::Title.eq(name)).one(&conn).await?;

Three scopes coexist:

ScopeInstalled byWhat it carries
RequestDbContext interceptorPool on safe routes, txn on mutations
JobWorkerDbContext (workers)One transaction per attempt (or pool)
UnscopednothingRepo::conn() errors

current_executor_scope() distinguishes Request from Job — Repo uses it to fail closed when a request runs with no ambient ability, while a worker runs unscoped (no caller ⇒ no scope to apply). See Security for the ability half.

An anonymous caller is a request, not a job: on a #[public] route the ability guard still installs one, built from define_visitor, and Repo filters by it like any other. Nothing here treats “no login” as “no filter” — see Public reads.

DbContext is auto-registered when you import SeaOrmDatabaseModule. It wraps every HTTP request outside the route’s guards so guards and handlers resolve the same ambient executor through Repo:

HTTP request
↓
DbContext ──→ installs Executor::Pool (GET/HEAD/OPTIONS/TRACE)
──→ installs Executor::Lazy (POST/PUT/PATCH/DELETE)
↓
guards (AuthnGuard, AbilityGuard, ...)
↓
handler
↓
DbContext (response) ──→ commit on 2xx/3xx, rollback otherwise

A safe method gets the pool, no transaction overhead. A mutating method opens a DatabaseTransaction, runs the handler against it, and commits or rolls back based on the response status:

OutcomeDecision
Ok(2xx) or Ok(3xx)commit
Ok(4xx) or Ok(5xx)rollback
Err(_) from the handlerrollback

A failed mutation never half-persists. See Transactions for the conflict-retry knobs and where each boundary sits.

Workers — same Repo, one transaction per attempt

Section titled “Workers — same Repo, one transaction per attempt”

Queue processors and cron jobs run outside any HTTP request — no method to classify, no response to inspect. SeaOrmDatabaseModule binds a WorkerDbContext to dyn JobContext, which the worker transport (#[scheduled], #[processor]) runs around every job:

job
↓
WorkerDbContext ──→ installs Executor::Lazy, settled on the job's Ok/Err
↓
processor body

A job attempt is atomic by default. The transaction is lazy — a job that never touches the database never opens one — and it commits when the job returns Ok, rolls back otherwise. That is what makes a retry safe: a job that failed halfway left nothing behind for the second attempt to write again.

Atomic per attempt is not the same as idempotent per job, and the difference is the queue’s, not the transaction’s. A backend that delivers at least once — which is what a durable queue is — can redeliver a job whose attempt committed, if the worker died between the commit and the acknowledgement. The transaction guarantees you never see half an attempt’s writes; it cannot promise the body runs once. A job whose re-execution would be harmful wants an idempotency key regardless of this setting.

And a retry only happens where it could help. A job whose body succeeded but whose transaction could not be settled is a failed attempt — but whether to re-attempt it is read from what the database said, never assumed — and where it said it. A statement inside the attempt that failed on a serialization conflict or a deadlock (40001, 40P01), on a pool that never handed out a connection, or on one the server closed (08…, 57P0…) is retryable: nothing is durable before COMMIT, so the framework knows the attempt wrote nothing. At the commit the reading flips: a constraint checked at COMMIT fails identically every time, and a commit whose outcome is unknown — the connection lost mid-COMMIT — may have landed. Neither of the last two is replayed: a retry re-runs the whole body, including every side effect that is not the database’s, and the framework only promises “nothing left to repeat” for a transaction it knows rolled back. Both abort straight to the dead-letter queue, where the failure is visible, instead of burning the budget in silence.

A schedule has no retry budget and no dead-letter, so #[every] / #[cron] / #[after] report the failure at error on nest_rs::schedule and fire again at the next occurrence — the classification is logged rather than acted on, because there is nothing there for it to decide.

No caller means no ambient ability, which means Repo reads and writes are unscoped — correct for system work with no principal to scope to. A job that needs scoping wraps its body in with_ability(...) explicitly.

transactional = false runs the job on the pool, where every statement commits on its own:

crates/features/src/audio/queue/processor.rs
#[process(queue = AudioQueue, retries = 3, transactional = false)]
async fn transcode(&self, job: TranscodeCommand) -> anyhow::Result<()> { /* ... */ }

The key is the same word on all four job decorators — #[process], #[every], #[cron], #[after].

Reach for it when the job brackets long work that is not the database’s: read a row, spend minutes transcoding, write the result. The default would hold a pooled connection across the middle. Such a job owns its own consistency, and an idempotency key is what makes its retry safe — the transaction never was.

A job that wants a narrower unit than the attempt has one sanctioned answer today: transactional = false, plus its own idempotency. The ambient executor is not a TransactionTrait, so there is no supported way to nest a second unit inside the attempt — and retry_on_conflict around the ambient executor would replay into the transaction the conflict already aborted. Both are for a programmatic boundary in a service, not for a job body (see Transactions).

A truly contextless path — a shutdown hook that runs after every transport has closed, a one-shot CLI command bootstrapped outside App::run — has no task-local to read. That is the one documented Repo bypass: inject an Arc<DatabaseConnection> and call SeaORM directly.

crates/features/src/users/service.rs
#[hooks]
impl UsersService {
#[on_application_shutdown]
async fn report(&self) -> anyhow::Result<()> {
let count = Users::find().count(self.db.as_ref()).await?;
tracing::info!(count, "users present at shutdown");
Ok(())
}
}

self.db is an Arc<DatabaseConnection> injected on the service — unscoped, no ability filter applies, no transaction. This is the only documented bypass; if you find yourself reaching for it in a request handler, something else is wrong.