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.
Repo’s surface
Section titled “Repo’s surface”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:
Repo::<Posts>::conn()?; // raw handle for a custom queryRepo::<Posts>::all().await?; // every readable rowRepo::<Posts>::find_by_id(id).await?; // None if absent OR out of scopeRepo::<Posts>::scoped(Action::Read); // Select<E> pre-filtered, chain freelyRepo::<Posts>::update(active).await?; // DbErr::RecordNotUpdated if out of scopeRepo::<Posts>::delete(model).await?; // 0 rows if out of scopeRepo::<Posts>::page(first, after, Condition::all()).await?; // keyset page — see PaginationA 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.
Under the hood: the ambient executor
Section titled “Under the hood: the ambient executor”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:
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.
let conn = Repo::<Posts>::conn()?;let row = Posts::find().filter(Column::Title.eq(name)).one(&conn).await?;Three scopes coexist:
| Scope | Installed by | What it carries |
|---|---|---|
| Request | DbContext interceptor | Pool on safe routes, txn on mutations |
| Job | WorkerDbContext (workers) | One transaction per attempt (or pool) |
| Unscoped | nothing | Repo::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.
The DbContext interceptor
Section titled “The DbContext interceptor”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 otherwiseA 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:
| Outcome | Decision |
|---|---|
Ok(2xx) or Ok(3xx) | commit |
Ok(4xx) or Ok(5xx) | rollback |
Err(_) from the handler | rollback |
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 bodyA 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.
The opt-out, and when it is right
Section titled “The opt-out, and when it is right”transactional = false runs the job on the pool, where every statement
commits on its own:
#[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).
The contextless-path exception
Section titled “The contextless-path exception”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.
#[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.
Going further
Section titled “Going further”- Transactions — auto-commit, conflict observation, the retry primitive.
- Row-level filtering —
the ability condition
Repo::scopedjoins in. - Writing a driver — implementing
Executorfor a non-SeaORM store on the same seam.