Suprnova's database layer wraps SeaORM with a Laravel-shaped DB facade:
raw query escapes, a model-less query builder, transactions with
savepoints and retry-on-deadlock, connection registry for read replicas
and shards, and a full observability surface that mirrors Laravel 13's
DB::listen / QueryExecuted / query log API.
The Eloquent ORM (use suprnova::eloquent::*) builds on top of this
layer and lives in eloquent.md. When you want a typed
model, go there; when you want a raw query against an unmodeled table
or want to observe every query the framework runs, this is the page.
Configuration
use ;
// In bootstrap.rs
register;
DBinit.await.expect;
DatabaseConfig::from_env reads DATABASE_URL and (optionally) the
pool tunables DB_MAX_CONNECTIONS, DB_MIN_CONNECTIONS,
DB_CONNECT_TIMEOUT, DB_LOGGING. When DATABASE_URL is unset the
config falls back to sqlite://./database.db - convenient for
zero-setup development; production boots refuse the fallback via
validate_for_environment so you can't accidentally ship a SQLite
file in APP_ENV=production.
URL → driver detection:
postgres://user:pass@host/db → DatabaseType::Postgres
postgresql://user:pass@host/db → DatabaseType::Postgres
mysql://user:pass@host/db → DatabaseType::Mysql
mariadb://user:pass@host/db → DatabaseType::Mysql
sqlite://./file.db → DatabaseType::Sqlite
sqlite::memory: → DatabaseType::Sqlite
A mariadb:// URL selects the MySQL driver, because MariaDB speaks the
MySQL protocol. Wherever a URL reaches the driver, the framework hands it
over with the mariadb scheme written as mysql: the primary connection,
each named connection and read replica, the migrator, and
MariaDbVectorDriver::from_url. Only the scheme changes. DB::driver_title()
answers MariaDB for a mariadb:// URL and MySQL for a mysql:// one,
so a MariaDB server reached through mysql:// reports as MySQL.
Pool liveness
A NAT gateway, a load balancer, or a firewall will silently drop a TCP connection that has been idle too long. The pool does not find out. The next query on that connection fails, and it fails on a request that had nothing to do with the outage.
Laravel answers this with libpq's keepalives, keepalives_idle,
keepalives_interval and keepalives_count DSN options, which keep the
socket warm. Those are not reachable from Suprnova. sqlx 0.9 parses
only sslmode, application_name, options and the statement-cache
size out of a Postgres URL, and carries no TCP keepalive setter at any
layer, so there is nowhere to forward them to.
What Suprnova gives you instead is the pool-side answer: stop trusting old connections.
# Close a connection that has been idle for two minutes.
DB_IDLE_TIMEOUT=120
# Recycle every connection after fifteen minutes regardless.
DB_MAX_LIFETIME=900
# Ping a connection before handing it out, but only once it has been
# idle for thirty seconds. Hot connections skip the round trip.
DB_PING_AFTER_IDLE=30
Or programmatically:
register;
Every knob is unset by default, which means the pool keeps sqlx's own
defaults: connections close after 600 idle seconds, recycle after 1800
seconds, and are pinged before every checkout. Set
DB_IDLE_TIMEOUT=0 or DB_MAX_LIFETIME=0 to turn off that form of
reaping entirely.
DB_PING_AFTER_IDLE and DB_TEST_BEFORE_ACQUIRE are alternatives, not
a pair: setting a threshold turns the per-checkout ping off, because
running both would ping on every acquire and make the threshold
meaningless.
Why Suprnova diverges
Keepalives and pool recycling solve the same failure from opposite ends. Keepalives keep a middlebox from expiring the connection; recycling accepts that it will and makes sure the pool never hands out a connection old enough to have been expired. The second is what the driver stack exposes, and it also covers failures keepalives do not - a failed-over replica, a rotated credential, a server-side idle disconnect. If you need the libpq options specifically, that is a change to sqlx, not to Suprnova.
Raw queries
The DB facade ships the full Laravel 13 raw escape surface. Every
helper goes through the same instrumented executor - every call fires
QueryExecuted (see Observability).
Bindings are sea_orm::Value - one of the few sea_orm types the
framework intentionally does NOT re-mask, because every value that hits
the wire goes through it. Value::from(...) works for every primitive
the database understands.
use DB;
use Value;
// SELECT - all rows as DynamicRow.
let users = DBselect.await?;
// SELECT - first row only.
let alice = DBselect_one.await?;
// SELECT - first column of first row as a typed value.
let count: i64 = DBscalar.await?;
// INSERT - returns bool (true when at least one row was affected).
DBinsert.await?;
// UPDATE / DELETE - return the rows-affected count.
let updated = DBupdate.await?;
let deleted = DBdelete.await?;
// Any prepared statement with bindings.
DBstatement.await?;
// DDL with no bindings - `unprepared` mirrors Laravel's
// `DB::unprepared` for statements (CREATE INDEX, ALTER TABLE, VACUUM)
// that reject placeholder binding.
DBunprepared.await?;
// affecting_statement is the explicit form used by update/delete
// internally - drop to it directly for ops that don't fit either name
// (e.g. INSERT...ON CONFLICT DO UPDATE).
let affected = DBaffecting_statement.await?;
Placeholder syntax
? for SQLite + MySQL. $1, $2, ... for Postgres. The active
backend is auto-detected from DatabaseConfig::url.
DynamicRow
Untyped rows materialise as DynamicRow - a serde_json::Map newtype
with typed accessors:
for row in users
get_* errors when the column is absent OR null. get_optional_*
errors only when absent and returns Ok(None) for SQL NULL. The full
accessor list is get_int / get_string / get_bool / get_float /
get_value / get_as<T> plus get_optional_string /
get_optional_int; for nullable types without a dedicated
get_optional_* reach for get_value + a serde_json::Value match,
or get_as::<Option<T>>.
Model-less query builder - DB::table
For ad-hoc queries against tables you haven't bothered to model with
#[suprnova::model], DB::table(...) returns a chainable builder
shaped like the Eloquent Builder<M> but materialising rows as
DynamicRow:
use ;
let rows = DBtable
.select
.filter
.filter_op
.order_by_desc
.limit
.get
.await?;
let first = DBtable
.filter
.first
.await?;
let count = DBtable
.filter
.count
.await?;
let id = DBtable
.insert
.await?;
let updated = DBtable
.filter
.update
.await?;
let deleted = DBtable
.filter
.delete
.await?;
Trust boundary on identifiers
Table names, column names, ORDER BY directions, and SQL operators are
interpolated INTO the SQL string verbatim - they are NOT bound as
parameters (SQL doesn't allow placeholder-bound identifiers). Treat
every impl Into<String> argument as a TRUSTED literal:
// Safe - the column name is a constant.
DBtable.filter.get.await?;
// UNSAFE - never splice user input into a column name.
DBtable.filter.get.await?;
Values (the right-hand side of filter / filter_op) ARE bound as
parameters and safe for user input.
The framework enforces a strict allowlist on identifiers
([A-Za-z_][A-Za-z0-9_]* with one optional schema. prefix) and
operators (=, <>, <, <=, >, >=, LIKE, NOT LIKE,
ILIKE, NOT ILIKE, IS, IS NOT). Violations error at the I/O
boundary before the SQL string is rendered.
Transactions
Three entry points, each with the QueryExecuted /
TransactionBeginning / TransactionCommitted /
TransactionRolledBack observation hooks wired in.
Closure form
use DB;
DBtransaction.await?;
Commit on Ok(_). Rollback + propagate the error on Err(_).
An Err is not always a rollback. If an
after-commit callback fails, the commit has
already landed and is durable; DB::transaction still returns Err, and the
message reads after-commit callback failed (the transaction itself committed): <the callback's error>. The closure's return value is lost, its
writes are not, and only a deferred dispatch failed. Every registered callback
still runs and the first error is the one you get.
DB::transaction_with_attempts never retries that error, however
deadlock-shaped it reads: re-running a closure whose writes are already durable
would apply them twice.
Operations inside the closure automatically pick up the active
transaction via a tokio::task_local - you do NOT have to thread a
&tx handle through every model call. Nested DB::transaction
returns a database error; use tx.savepoint(...) for nested-rollback
behaviour.
The closure form is also the only form that can defer work to the commit. A
job whose type declares Job::after_commit() (or a dispatch made with
Queue::push_after_commit) waits inside this closure and only reaches the
queue driver once the commit succeeds; a rollback discards it. See
After-commit dispatch.
For typed aggregate or custom SQL that must execute on the same pinned connection, use the transaction handle directly:
use ;
DBtransaction.await?;
query_all emits normal QueryExecuted observations and returns typed
SeaORM QueryResult rows. Use bound Statement::from_sql_and_values for
dynamic values; do not interpolate untrusted input.
Retry on deadlock
DBtransaction_with_attempts.await?;
Manual form
use ;
let tx = DBbegin_transaction.await?;
// Per-model: the `*_with_tx` shims pin one CRUD op to the manual tx.
create_with_tx.await?;
create_with_tx.await?;
// Per-query: `Builder::with_tx(&tx)` pins a builder chain.
let stale = query
.filter
.with_tx
.get
.await?;
if some_condition else
Manual mode does NOT install the task-local - every operation that
should run inside the transaction has to opt in, either via
Builder::with_tx(&tx) on a chained query or one of the
Model::*_with_tx shims (create_with_tx, save_with_tx,
delete_with_tx, etc.). Operations that forget to opt in run against
the global pool and are NOT part of the transaction.
Holding a Transaction handle pins one pool connection for its
lifetime; pre-load any rows you need to read BEFORE the
begin_transaction() call, especially on SQLite (single shared
connection).
Because manual mode installs no task-local, it has no commit for a deferred dispatch to hang on either: an after-commit job pushed inside a manual transaction is pushed immediately. Use the closure form when a dispatch has to wait for the commit.
Savepoints
DBtransaction.await?;
All three first-class backends support SAVEPOINT / ROLLBACK TO SAVEPOINT - SQLite included.
A savepoint rollback also unwinds the
after-commit registry. A queue push deferred
to the commit inside the savepoint is discarded along with the rows it
described, and the compensation registered with it runs immediately, so a
deferred push_unique's dedupe lock goes back and a re-dispatch inside the same
transaction can win it. Anything registered before the savepoint is untouched,
and a savepoint you release or simply never roll back keeps everything
registered inside it.
Repeating a savepoint name is allowed, and the registry follows the database:
ROLLBACK TO SAVEPOINT x unwinds to the most recent x and destroys the
savepoints established after it. Manual transactions have no after-commit
registry, so their savepoints roll back rows and nothing else.
Only Transaction::savepoint marks the registry. A savepoint you create with
raw SQL is invisible to it, so rollback_to rolls those rows back, logs a
warning, and leaves every deferred dispatch registered inside it in place -
discarding one on a guess would be the worse failure. Use
Transaction::savepoint when the deferred dispatches are meant to unwind with
the rows.
Observability
Laravel 13's DB::listen / QueryExecuted / query log surface, ported
to Rust through Suprnova's event dispatcher.
DB::listen - direct callback
use ;
// In bootstrap.rs (or a service provider).
DBlisten?;
Listeners run synchronously inside the executor helper. A slow
listener slows the query - keep direct callbacks light. For anything
that can fail, prefer the EventFacade path below; it runs through
dispatch_best_effort and tolerates errors.
EventFacade dispatch path
QueryExecuted is a real suprnova::Event - listen through the
dispatcher to get queued, fakeable, fail-tolerant delivery:
use ;
use Arc;
;
// In bootstrap.rs.
.await;
Listeners on this path:
- Run through
dispatch_best_effort- a failing listener does NOT fail the query. - Are short-circuited when they themselves issue a query (re-entrancy guard).
- Can use
Event::fake()in tests to assert dispatch without actually running listeners.
In-memory query log
DBenable_query_log?;
query.filter.get.await?;
query.count.await?;
let log = DBget_query_log?;
for query in &log
DBflush_query_log?; // drop entries, keep enabled
DBdisable_query_log?; // stop capturing
let still_capturing = DBlogging;
The log is unbounded - every captured query grows it until the
process exits, flush_query_log() runs, or disable_query_log() is
called. Use it for development, not as a long-running production
profiler.
Transaction lifecycle events
TransactionBeginning, TransactionCommitted, and
TransactionRolledBack are real suprnova::Event types - listen for
them through EventFacade::listen to drive auditing, distributed
locks, or compensation logic.
.await;
.await;
All three transaction entry points
(DB::transaction / DB::transaction_with_attempts /
DB::begin_transaction + Transaction::commit/rollback) fire the
events. A leaked manual Transaction handle that gets dropped without
explicit commit/rollback emits no event - SeaORM's Drop impl is
synchronous and can't reach the async dispatcher.
Busy database - DB::monitor and db:monitor
DatabaseBusy is a real suprnova::Event. It reports that the database
server has as many connections as you allow, or more. Its fields are
connection_name and connections. Nothing dispatches it by itself.
Two calls dispatch it:
DB::monitor(max)asks the server of every connection the application has, the default one and each named one. It dispatchesDatabaseBusyfor each server withmaxconnections or more, and returns the events it dispatched.maxmust be at least 1.- The
db:monitor --max <n>console command does the same and prints the count for each connection. Without--maxit prints the counts and dispatches nothing.
The server does the counting, from every client of it, so the answer is the same from every process. That lets you run the check from the scheduler, which is a process of its own:
use Schedule;
A listener decides what a busy database means: a page, a metric, a log line.
use Arc;
use ;
;
// In bootstrap.rs.
.await;
A listener that fails does not stop the check. The count comes from the server:
| Engine | Count |
|---|---|
| PostgreSQL | The rows of pg_stat_activity, which include the server's own workers |
| MySQL and MariaDB | threads_connected |
| SQLite | None. SQLite has no server, so it is never busy. |
DB::connection_counts() returns the counts as a Vec<ConnectionCount>,
each with a connection_name and connections: Option<u32>, and
DbConnection::server_connections() returns the count of one connection.
DbConnection::connections_in_use() is a different number: the
connections of this process's own pool that are out of the pool now. It
says nothing about another process.
QueryExecuted payload
to_raw_sql() substitutes the captured bindings into the SQL for
display:
let query = /* captured from a listener */;
println!;
// SELECT * FROM users WHERE id = 42 AND active = true
The substitution is debug-format (not SQL-safe escaping) and is intended for log output only. Never feed the result back into a query.
Coverage scope
QueryExecuted fires for every query:
- Every raw helper on
DB(select/select_one/scalar/insert/update/delete/statement/affecting_statement/unprepared). - Every terminal method on
DbTableBuilder(the model-less builder). DB::transaction/DB::begin_transactionBEGIN / COMMIT / ROLLBACK fire transaction events.DbConnection::connectfiresConnectionEstablished.- Every Eloquent read and write:
Builder<M>::get/first/count/ pagination / chunking,Model::find/all, and model CRUD, including theIN (...)queries behind eager loading.
Connection metadata
let name = DBdatabase_name?; // "myapp" for postgres://.../myapp
let driver = DBdriver_name?; // "postgres" | "mysql" | "sqlite"
let title = DBdriver_title?; // "Postgres" | "MySQL" | "MariaDB" | "SQLite"
let version = DBserver_version.await?; // "15.5" | "8.0.36" | "3.42.0"
server_version issues a backend-specific introspection query
(SELECT VERSION() for Postgres + MySQL, SELECT sqlite_version()
for SQLite). Cache the result if you call it often - every call is a
round trip.
Named connections
For read replicas, sharded shards, or per-model warehouse pools:
// In bootstrap.rs
DBregister_named.await?;
DBregister_named.await?;
// Per-query routing:
let rows = query.on.get.await?;
let warehouse_rows = DBtable.on.get.await?;
let raw = DBselect_on.await?;
The __read_replica__ name is well-known: when registered, every
read-shape terminal method auto-routes through it. Writes ignore the
replica and target the primary. Use Builder::on_write_connection
(per query) or #[model(connection = "...")] (per model default) to
opt back to the primary for specific operations.
Reserved names:
__primary__- the default pool. Cannot be registered (it's the return value ofDB::connection()).__read_replica__- well-known read replica. ANY connection registered under this name takes over read routing.
See eloquent.md → Multi-connection routing for the
full precedence chain (builder tx override → ambient tx → builder
on(name) → model default → __read_replica__ → primary).
Testing
TestDatabase builds an in-memory SQLite database, registers it in
the test container so DB::connection() resolves to it, and runs your
migrations:
use TestDatabase;
use crateMigrator;
async
// `test_database!()` is the macro shortcut.
let db = test_database!;
For tests that build their own ad-hoc schema:
let db = sqlite_memory.await.unwrap;
db.execute_unprepared.await.unwrap;
When a TestDatabase is dropped, the test container is cleared and
the connection registry is wiped - no cross-test leakage. Tests that
mutate process-wide state (the registry, the listener registry, the
query log) should be annotated #[serial_test::serial] so they don't
collide.
Next
- Eloquent - the typed
#[suprnova::model]ORM that sits on top of this layer - Migrations -
Migrator,make:migration, and thedb:syncworkflow - Database Testing -
TestDatabase, fixture loading, and serial-test annotations - Events - the dispatcher behind
QueryExecuted/TransactionCommittedlisteners - Configuration - registering
DatabaseConfigalongside the rest of your typed config
Surface index
| Surface | Laravel analogue |
|---|---|
DB::init / DB::init_with / DB::connection / DB::is_connected / DB::get |
DB::connection() |
DB::table(name) → DbTableBuilder |
DB::table($name) |
DB::select / select_one / scalar / insert / update / delete / statement / affecting_statement / unprepared |
DB::select / selectOne / scalar / insert / update / delete / statement / affectingStatement / unprepared |
DB::transaction / transaction_with_attempts / begin_transaction |
DB::transaction($cb, $attempts) / DB::beginTransaction |
Transaction::commit / rollback / savepoint / rollback_to |
DB::commit / rollBack / savepoint helpers |
DB::listen(callback) |
DB::listen |
DB::monitor / connection_counts / DbConnection::server_connections / connections_in_use |
db:monitor |
DB::enable_query_log / disable_query_log / get_query_log / flush_query_log / logging |
DB::enableQueryLog / disableQueryLog / getQueryLog / flushQueryLog / logging |
DB::database_name / driver_name / driver_title / server_version |
getDatabaseName / getDriverName / getDriverTitle / getServerVersion |
DB::register_named / named / select_on / table_on / statement_on / affecting_statement_on |
multi-connection DB::connection($name) |
QueryExecuted / TransactionBeginning / TransactionCommitted / TransactionRolledBack / ConnectionEstablished / DatabaseBusy |
Illuminate\Database\Events\* |
DatabaseConfig::builder() / from_env / validate_for_environment / idle_timeout / max_lifetime / acquire_timeout / test_before_acquire / ping_after_idle |
config/database.php |
TestDatabase::fresh::<M> / sqlite_memory / execute_unprepared / fetch_one / fetch_all |
RefreshDatabase testing trait |
