modo-db
Database integration for the modo framework. Provides SeaORM-backed connection pooling, automatic schema synchronisation, versioned migrations, and a compile-time entity/migration registration system built on inventory.
Features
sqlite(default) — enables SQLite viasqlx-sqlite. WAL mode, busy-timeout, and foreign keys are applied automatically.postgres— enables PostgreSQL viasqlx-postgres.
Usage
Configuration
DatabaseConfig is deserialized from your app's YAML config. The backend is auto-detected from the URL scheme.
use AppConfig;
use DatabaseConfig;
use Deserialize;
Example config.yaml:
database:
url: "sqlite://data/main.db?mode=rwc"
max_connections: 5
min_connections: 1
Defaults: sqlite://data/main.db?mode=rwc, max_connections: 5, min_connections: 1.
Connecting and migrating
async
sync_and_migrate runs in two phases:
- Schema sync — creates or adds columns for all registered entities (addition-only).
- Migration runner — executes pending versioned migrations tracked in
_modo_migrations.
Group-scoped sync
Use sync_and_migrate_group to sync only entities and migrations belonging to a named group. This is useful when entities in a group live in a separate database (e.g. a dedicated jobs database):
let jobs_db = connect.await?;
sync_and_migrate_group.await?; // syncs only "jobs" group
sync_and_migrate.await?; // syncs all entities to main DB
Defining entities
Apply #[modo_db::entity(table = "...")] to a plain struct. The macro preserves your struct as a first-class domain type and generates a SeaORM entity module alongside it.
The struct receives Clone, Debug, Serialize, Default, and From<Model> automatically. You never need to work with the SeaORM Model directly.
Optionally assign an entity to a named group with group = "<name>" (defaults to "default"). Entities in a group can be synced to a separate database via sync_and_migrate_group.
// adds created_at, updated_at
The macro creates:
- Your struct
TodowithClone, Debug, Serialize, Default, From<todo::Model> - A submodule
todocontainingModel,ActiveModel,Entity,Column, andRelation - An
impl Record for Todowith CRUD operations and query builders
Because Default is generated, you can use struct-update syntax to set only the fields you care about:
let todo = Todo ;
Field attributes
| Attribute | Effect |
|---|---|
primary_key |
Marks the primary key column |
auto_increment = false |
Disables auto-increment (required for composite PKs) |
auto = "ulid" / auto = "nanoid" |
Auto-generates the PK before insert (primary key only) |
unique |
Adds a unique constraint |
indexed |
Adds a single-column index |
column_type = "Text" |
Overrides the SeaORM column type |
default_value = <lit> |
Sets a column default |
default_expr = "<sql>" |
Sets a SQL expression default |
belongs_to = "OtherEntity" |
Defines a FK relation; combine with on_delete/on_update |
has_many / has_one |
Declares an inverse relation (no DB column) |
via = "JunctionEntity" |
Many-to-many through a junction table |
renamed_from = "old_name" |
Records rename as a column comment |
nullable |
Accepted (no-op; Option<T> already implies nullable) |
Struct attributes
| Attribute | Effect |
|---|---|
#[entity(timestamps)] |
Appends created_at and updated_at (DateTime<Utc>) |
#[entity(soft_delete)] |
Appends deleted_at (Option<DateTime<Utc>>) and generates soft-delete methods on the struct |
#[entity(index(columns = ["a", "b"]))] |
Generates a composite index via CREATE INDEX IF NOT EXISTS |
#[entity(index(columns = ["slug"], unique))] |
Generates a composite unique index |
CRUD operations
The Record trait is implemented for every entity struct. All CRUD methods are inherent methods on your struct, so you call them directly without importing extra traits.
Insert
use ;
async
Find by ID
find_by_id returns the record or a 404 Not Found error automatically:
let todo = find_by_id.await?;
Find all
let todos = find_all.await?;
Update
update mutates the struct in-place and refreshes all fields from the database:
let mut todo = find_by_id.await?;
todo.completed = true;
todo.update.await?;
Delete
delete_by_id.await?;
// or, if you already have the record:
todo.delete.await?;
Filtered queries
Use Todo::query() to build chainable queries. Results are automatically converted to the domain type.
// All incomplete todos, newest first
let todos: = query
.filter
.order_by_desc
.all
.await?;
// At most one result
let maybe: = query
.filter
.one
.await?;
// Count matching rows
let n: u64 = query
.filter
.count
.await?;
limit and offset are also available for manual slicing:
let page: = query
.order_by_asc
.limit
.offset
.all
.await?;
Pagination
Offset-based
use QueryReq;
use ;
async
Cursor-based
use ;
async
per_page defaults to 20 and is clamped to [1, 100]. Paginate forward with ?after=<cursor> and backward with ?before=<cursor>.
Bulk operations
Bulk update
use Expr;
let affected = update_many
.filter
.col_expr
.exec
.await?;
Bulk delete
let deleted = delete_many
.filter
.exec
.await?;
Both return the number of rows affected as u64.
Transactions
Pass the transaction handle the same way as &db:
let txn = db.begin.await.map_err?;
let todo = Todo .insert.await?;
txn.commit.await.map_err?;
Lifecycle hooks
Define inherent methods on your struct to hook into save and delete operations. No attributes or trait imports are required — Rust's inherent-method priority means your methods automatically take precedence over the no-op defaults provided by DefaultHooks.
The three hook signatures are:
| Hook | Signature | When called |
|---|---|---|
before_save |
fn(&mut self) -> Result<(), modo::Error> |
Before insert and update |
after_save |
fn(&self) -> Result<(), modo::Error> |
After successful insert / update |
before_delete |
fn(&self) -> Result<(), modo::Error> |
Before delete |
Relations
Declare relations with field attributes and the macro generates async accessor methods on your struct.
Generated accessors:
// belongs_to: field `user_id` -> method `user()`
let author: = post.user.await?;
// has_many: field `posts` -> method `posts()`
let posts: = user.posts.await?;
has_one works the same way but returns Option<T> instead of Vec<T>.
Soft delete
Add #[entity(soft_delete)] to inject a deleted_at column and enable soft-delete semantics. Standard query() and find_all() automatically exclude soft-deleted records.
Generated methods:
// Soft-delete a single record (sets deleted_at = now, does not remove the row)
item.delete.await?;
// Soft-delete by ID
delete_by_id.await?;
// Restore a soft-deleted record (clears deleted_at)
item.restore.await?;
// Hard-delete a single record
item.force_delete.await?;
// Hard-delete by ID
force_delete_by_id.await?;
// Query including soft-deleted records
let all: = with_deleted.all.await?;
// Query only soft-deleted records
let trash: = only_deleted.all.await?;
// Bulk soft-delete (UPDATE SET deleted_at = now() WHERE ...)
let n = delete_many
.filter
.exec
.await?;
// Bulk hard-delete (bypasses soft-delete)
let n = force_delete_many
.filter
.exec
.await?;
Partial updates
When you need to update only specific fields without fetching the full record first, use into_active_model and the raw SeaORM API:
use ;
use Record;
let mut am = todo.into_active_model;
am.completed = Set;
am.update.await.map_err?;
Escape hatch
EntityQuery wraps a SeaORM Select<E>. Unwrap it at any point with into_select() for advanced queries:
use QuerySelect;
let select = query
.filter
.into_select;
// Use raw SeaORM from here
let models = select
.columns
.all
.await?;
You can also use the SeaORM Entity directly at any time:
use EntityTrait;
let models = find.all.await?;
let todos: = models.into_iter.map.collect;
Versioned migrations
Use #[modo_db::migration] for changes that schema sync cannot express (e.g. data seeding, backfills, renaming columns).
The db parameter is a &sea_orm::DatabaseConnection, so you can use the full SeaORM typed API:
async
Raw SQL is also available for DDL operations that SeaORM cannot express:
async
Migrations are executed in ascending version order. Each version is recorded in _modo_migrations and runs exactly once. Duplicate version numbers are detected at startup and cause an error.
Migrations can also be assigned to a group with group = "<name>" so they only run when sync_and_migrate_group is called with the matching group.
ID generation
let ulid_id = generate_ulid; // 26-char Crockford Base32
let nano_id = generate_nanoid; // 21-char NanoID
Key Types
| Type | Purpose |
|---|---|
DatabaseConfig |
Connection URL + pool size, deserialised from YAML |
DbPool |
Newtype over sea_orm::DatabaseConnection; implements GracefulShutdown |
Db |
Axum extractor that pulls DbPool from app state |
Record |
Trait implemented by every entity struct; provides CRUD and query builder methods |
DefaultHooks |
Blanket trait providing no-op before_save, after_save, before_delete |
EntityQuery<T, E> |
Chainable query builder with automatic domain-type conversion |
EntityUpdateMany<E> |
Chainable bulk UPDATE builder |
EntityDeleteMany<E> |
Chainable bulk DELETE builder |
EntityRegistration |
Compile-time entity registry entry (produced by #[entity] macro) |
MigrationRegistration |
Compile-time migration registry entry (produced by #[migration] macro) |
PageParams / PageResult<T> |
Offset pagination request + response |
CursorParams<V> / CursorResult<T> |
Cursor pagination request + response |