Persistence
Version stamp
Applies to:
renvor0.0.0 · framework source 7d0816a · MSRV 1.94.0 · documentation set pre-releaseThis stamp is a single shared partial (
docs/_stamp.mdx) imported by every prose page and the API reference. It binds this documentation snapshot to the immutable framework commit it describes. The framework remains unpublished and no release compatibility promise applies.
None of the crates below are on crates.io. This page documents contracts that are implemented and tested, not software you can install.
Two models, one set of ports
renvor-database declares the ports — Database, UnitOfWork, Executor, Keyset,
MigrationPolicy, DatabaseError — and names no driver. Two adapters implement them:
| Crate | Programming model | Databases |
|---|---|---|
renvor-sqlx | Queries written as SQL, every value bound | PostgreSQL, MySQL |
renvor-seaorm | Entities and a query builder | PostgreSQL, MySQL |
Both are measured against the same contract suite. The assertions live in
renvor_testkit::persistence and are compiled once; each adapter supplies only the driver-specific
operations. "Both models pass the same contracts" is therefore a fact about the build rather than
two test files that happen to agree.
Selecting SeaORM changes the programming model, not the driver family
SeaORM is built on SQLx. A project that selects SeaORM resolves SQLx transitively, and
renvor-seaorm depends on SQLx directly. This is stated plainly rather than left to be discovered
in a lockfile.
What selecting SeaORM does change is what you write:
// renvor-seaorm
let uow = database.begin().await?;
let items = item::Entity::find().all(&uow).await?;
uow.commit().await?;
// renvor-sqlx
let mut uow = database.begin().await?;
let items = sqlx::query_as::<_, Item>(ITEMS).fetch_all(&mut **uow.inner()).await?;
uow.commit().await?;
Direct-SQLx application APIs are not part of the SeaORM surface. The two adapters are siblings;
neither depends on the other, and the framework facade depends on neither, so there is nowhere for
one to leak through the other. cargo xtask verify asserts both directions with a positive control.
Transactions and cancellation
A transaction is begun explicitly, in application code. There is no commit-on-drop path and no
implicit begin: a unit of work that goes out of scope for any reason — an early return, a ?, a
panic, a cancelled future — writes nothing.
Renvor owns the pooled connection in both adapters rather than wrapping the driver's own transaction type. For SeaORM that is a deliberate departure from the obvious implementation, and it is what lets Renvor promise that cancellation never costs the pool its configured capacity for longer than a bound you set. The reasoning, and the measurements behind it, are in ADR-0021.
What is not promised: a cancelled client cannot make the server abandon a statement already running. A connection cancelled mid-statement stays pinned server-side until that statement finishes. Renvor's guarantee is about its own pool accounting.
sea_orm::TransactionTrait, savepoints, and isolation-level configuration are not exposed.
Migrations
Migrations are SQL files, ordered by version, checksummed, and run by SQLx's engine — for both persistence models. A project therefore has exactly one migration history whichever ORM it selected, and switching between them is not a re-migration.
sea-orm-migration is deliberately not used. Its bookkeeping table has two columns, version and
applied_at, and no checksum, so a migration edited after it was applied would be undetectable.
See
ADR-0022.
The cost is stated rather than absorbed: you do not get SeaORM's Rust-authored MigrationTrait
migrations.
| Property | Behaviour |
|---|---|
| Ordering | By version, independent of directory enumeration order |
| Tamper | A changed already-applied migration is refused before any schema modification |
| Default policy | Never. Schema change on boot requires two separate acts |
OnBoot | Applies before readiness is reported; a failure publishes no database |
| Concurrent startup | Exactly once, under a lock Renvor owns with a deadline you set |
| Rollback | Declared per migration; an unsupported rollback fails before changing data |
Atomicity differs between the engines, and that is not hidden
PostgreSQL runs DDL inside a transaction, so a migration that fails part-way leaves nothing behind.
MySQL does not: most DDL statements commit implicitly, so a migration that fails after its third
statement leaves the first two applied. SQLx records that version in _sqlx_migrations with
success = FALSE; every later migration command refuses it as dirty before sending another
statement. Recovery is manual: inspect and back up the schema, bring it to a fully applied or fully
undone state, then update or remove the ledger row to match. The exact recovery sequence is in
Data types and migration portability.
Prefer one schema change per MySQL migration so that partial state is smaller and easier to repair.
The escape hatch, in order
- Entity and SeaQuery APIs. Everything the generated repository uses.
Statement::from_sql_and_values, when a query cannot be expressed above — and every value is still bound.- A database-specific adapter module, when the two engines genuinely differ.
execute_unprepared is not a rung: it binds nothing. Renvor's implementation routes it through
SQLx's AssertSqlSafe, which SQLx 0.9 requires for any SQL that is not &'static str — so
caller-supplied SQL has to be marked as such in your own source.
Sorting is an allowlist, never interpolation. A column name cannot be a bound parameter, so the only safe construction is one where every possible value is written in the source. An unknown sort field is refused rather than silently replaced with a default.
Generated projects
renvor new --orm seaorm --database postgres generates src/entity.rs and src/repository.rs in
full, in SeaORM 2.0 dense style, plus the same migrations the direct-SQLx path gets.
The generated Cargo.toml declares no dependencies, and the two modules are not declared in
src/main.rs. sea-orm is published and could be declared — but generation runs the staged
project's own cargo fmt, clippy, build, test and run before placing it, so a real
dependency would make renvor new need the registry. Offline generation is a guarantee Renvor keeps.
Cargo.toml names the lines to add and the declarations to make.
Both databases, one schema
All four rows are first-class, which puts the burden of agreement on your schema rather than on Renvor. Two pages cover what that takes:
- Data types and migration portability — column types, identifier rules, upserts, pagination order, and why a migration should make one change. Backed by contract C-16, and by assertions that run against both engines on every verification.
- Testing backup and restore — which tools are actually in the pinned images, how to invoke them without leaking a password, and what a restore test has to assert.
The differences that cannot be removed are named rather than hidden. MySQL's TIMESTAMP ends in
2038; no adapter can supply the missing years, so the guide tells you to use DATETIME(6) instead.
Not yet
- No generic resource generator.
renvor generate resourcedoes not exist. Generation of arbitrary CRUD resources into an existing project is Phase 011. - No cache, jobs, mail, or storage capability. A generated container profile may include a Valkey service; that is local development infrastructure. Renvor's cache port and adapter arrive in Phase 010.