Polyglot Persistence
Important
This page describes the v13 prerelease source, and its dependency examples pin the published
13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and a plain"13.0.0-alpha.4"would also accept later alphas (see the v13 migration guide). The v12 line (rullst@12) reached end of life on 16 October 2026; see the release record. CommitCargo.lockfor reproducible builds, and use path dependencies only for checkout-local review.
Rullst keeps explicit relational and specialized persistence contracts. SQLx Active Record supports SQLite, PostgreSQL, MySQL and MariaDB. The Blank starter can instead use Turso/libSQL as its primary database, through a separate typed adapter. MongoDB is for documents, DuckDB for embedded OLAP/analytics, SurrealDB for documents plus bounded read-only graph queries, Qdrant for dense vector search and Redis for selected native structures. No optional adapter silently changes the application’s primary database.
Only the SQLx backends work with the #[derive(Orm)] query builder, Schema,
artisan!, Server::with_db, Nexus and Studio’s database pages. The
Database Support table lists, backend by backend, what
works, which cargo rullst new starters accept it and which CI test runs it
against a real server.
Choose the smallest feature
Using the umbrella crate, next to the primary database profile:
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["strict-postgres", "orm-mongodb"] }
Available umbrella features are orm-turso, orm-mongodb, orm-duckdb,
orm-surrealdb, orm-qdrant and orm-redis. Direct rullst-orm users select
turso, mongodb, duckdb, surrealdb, qdrant or redis. There is no
feature that enables them all: 13.0 removed the polyglot and orm-polyglot
aggregates, so list the adapters the application uses.
The examples below use rullst_orm::… paths, as projects generated by
cargo rullst new do: they depend on rullst-orm directly, which
#[derive(Orm)] requires because it expands to rullst_orm::… paths. Code
that does not derive models can use the same items through the umbrella crate
as rullst::orm::….
Features are additive. Prefer one adapter unless the application genuinely uses several, and inspect the final dependency graph with:
cargo tree -e features
The project wizard first asks for a primary backend. SQLite, PostgreSQL, MySQL and MariaDB use SQLx; Turso is offered as the primary database of the Blank starter only, and its choice states what it does not integrate with. The wizard then offers the side stores, each described as one. For a hybrid SQLx application, use one or more explicit flags:
cargo rullst new edge_app --default --database mariadb --turso --mongodb \
--qdrant --skip-initial-migration
MariaDB shares the SQLx MySQL protocol implementation but has its own live
container contract. --turso is additive; --database turso is the explicit
primary selection and currently rejects the SQLx-specific SaaS starter.
Turso / libSQL as the primary ORM
Turso-primary is an alternative to SQLx, not another SQLx backend. It works
through TursoOrm, TursoModel, TursoRepository and TursoMigration, and
does not integrate with Schema, artisan!, Server::with_db, Nexus or
Studio’s database pages; #[derive(Orm)] generates the separate TursoModel
API described below.
cargo rullst new edge_app --default --api --database turso
cd edge_app
cargo rullst make:model Event --migration
cargo rullst db:migrate
The generated manifest enables orm-turso without a direct SQLx dependency,
and the environment contains TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, and
TURSO_OFFLINE_PATH rather than a fictitious DATABASE_URL. Models use the
same public derive with an explicit backend:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "events", backend = "turso")]
struct Event {
id: i64,
label: String,
active: bool,
}
}
Initialize once before using the generated inherent methods:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "events", backend = "turso")]
struct Event {
id: i64,
label: String,
active: bool,
}
async fn use_turso_model() -> Result<(), Box<dyn std::error::Error>> {
rullst_orm::polyglot::TursoOrm::init_from_env().await?;
let mut event = Event {
id: 0,
label: "started".into(),
active: true,
};
event.save().await?;
let current = Event::find(event.id).await?;
let _ = current;
Ok(())
}
}
TursoOrm::init_from_env reads TURSO_DATABASE_URL, TURSO_AUTH_TOKEN and
TURSO_OFFLINE_PATH from the process environment only. The Turso blank starter
(cargo rullst new --database turso) instead reads them with
rullst::config::project_setting, which falls back to ./.env without loading
it into the process, and passes them to TursoConfig::new and
with_offline_path; since 13.0.0-alpha.2 it no longer calls
dotenvy::dotenv() or depends on dotenvy.
The typed contract includes CRUD, equality filters, ordering, bounded
pagination/counts, app-assigned or generated primary keys, checksummed
migrations, status, and rollback. An equality filter whose value encodes as
NULL (such as Option::None) matches with IS NULL. #[orm(encrypted)]
columns store randomized ciphertext, so filtering or ordering on them returns
an error instead of silently matching nothing. Generated make:model and make:migration
commands retain the Turso backend. It does not yet provide SQLx ORM relations,
hooks, automatic timestamps, seed generation, schema auto-diff, or transparent
embedded-replica synchronization. Those limits are why only the blank/API
starter is currently advertised as Turso-primary. The derive rejects
unsupported SQLx-specific model behaviors at compile time instead of silently
ignoring their attributes.
Turso / libSQL explicit edge SQL
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
TursoConfig, TursoMigration, TursoQueryLimit, TursoStatement, TursoStore,
TursoValue,
};
async fn use_edge_sql() -> Result<(), Box<dyn std::error::Error>> {
let config = TursoConfig::new(
std::env::var("TURSO_DATABASE_URL").unwrap_or_default(),
std::env::var("TURSO_AUTH_TOKEN").unwrap_or_default(),
);
let edge = TursoStore::connect(config).await?;
let create = TursoStatement::new(
"CREATE TABLE events (id INTEGER PRIMARY KEY, label TEXT NOT NULL)",
vec![],
)?;
edge.migrate(vec![TursoMigration::new("m20260829_events", vec![create])?])
.await?;
edge.execute(TursoStatement::new(
"INSERT INTO events VALUES (?1, ?2)",
vec![TursoValue::Integer(1), TursoValue::Text("started".into())],
)?).await?;
let rows = edge.query(
TursoStatement::new(
"SELECT id, label FROM events WHERE id = ?1",
vec![TursoValue::Integer(1)],
)?,
TursoQueryLimit::new(100)?,
).await?;
let _ = rows;
Ok(())
}
}
The live path speaks the official Hrana HTTP v3 protocol directly through the
workspace’s Rustls-backed HTTP client; it does not embed the native SQLite
engine or the legacy remote SDK dependency chain. URLs must use
libsql:// or HTTPS; cleartext HTTP requires an explicit loopback-development
opt-in. Requests reject redirects and have a 30-second deadline. Tokens and
endpoint details are redacted from Debug; remote responses are capped at
16 MiB, result materialization at 1–10,000 rows, each statement at 1,024
positional parameters/8 MiB of parameter data, and each transaction at 1,024
statements/16 MiB of raw SQL plus parameters. SQL result cells can still be
large, so applications must also constrain selected columns and data at the
schema/query boundary.
An empty or mock_* endpoint selects a single-connection SQLite in-memory
fallback, persisted to a file with TursoConfig::with_offline_path
(TursoOrm::init_from_env uses TURSO_OFFLINE_PATH, default
turso-development.db). It executes real SQL and migrations deterministically
but does not simulate remote replication, latency, failover, or Turso Cloud.
The live CI contract runs the same API against the official sqld container
and proves that a failed multi-statement batch rolls back its earlier writes.
The portable document contract
MongoDB, SurrealDB, and the deterministic offline store implement the same bounded document operations:
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
CollectionName, DocumentId, DocumentPage, DocumentRepository,
};
fn bounded_document_inputs() -> Result<(), Box<dyn std::error::Error>> {
let collection = CollectionName::new("audit_events")?;
let id = DocumentId::new("event-2026-0001")?;
let page = DocumentPage::new(0, 50)?;
let _ = (collection, id, page);
Ok(())
}
}
Collection names use a portable ASCII identifier grammar, IDs are bounded to
letters, digits, _ and -, and every list requires a 1–500 row page. Models
must serialize as objects and must not own the driver ID field (_id for
MongoDB, id for SurrealDB); the portable DocumentId owns that concern.
Encrypted export and crash-resumable restore
MongoDB, SurrealDB and the deterministic store also implement
DocumentInventory<T>, which retains each validated identifier. That enables
one deliberately conservative recovery path without pretending the engines
share transactions:
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
CollectionName, DocumentRecoveryBinding, DocumentRecoveryKey,
DocumentRecoveryPolicy, export_document_snapshot,
restore_document_snapshot,
};
async fn recovery<S, D, Event>(source: &S, destination: &D) -> Result<(), Box<dyn std::error::Error>>
where
S: rullst_orm::polyglot::DocumentInventory<Event>,
D: rullst_orm::polyglot::DocumentInventory<Event>,
Event: serde::Serialize + serde::de::DeserializeOwned + Send + Sync,
{
let collection = CollectionName::new("audit_events")?;
let binding = DocumentRecoveryBinding::try_new(
"my_application.production",
collection,
)?;
let key = DocumentRecoveryKey::try_new(
"recovery-2026-09",
[42_u8; 32], // load 32 random bytes from a secret manager
)?;
let policy = DocumentRecoveryPolicy::try_new(100, 10_000, 16 * 1024 * 1024)?;
// Persist this opaque envelope in application-owned durable storage.
let snapshot = export_document_snapshot(source, &binding, &key, policy).await?;
let report = restore_document_snapshot(
destination,
&snapshot,
&binding,
&key,
policy,
).await?;
assert_eq!(report.verified(), report.inserted() + report.replayed());
Ok(())
}
}
The key uses AES-256-GCM and a fresh nonce; its authenticated data binds the
rotation ID, trusted application namespace and exact collection. Key material
passes through a zeroizing temporary and key/snapshot Debug output is
redacted. The policy permits pages of 1–500, 1–100,000 documents and a 1 KiB–64
MiB plaintext ceiling. Models in this portability path must be JSON objects
without either id or _id.
Export scans twice and refuses to seal unequal observations. This detects ordinary concurrent changes, but it is not a database snapshot isolation primitive: pause writers or use a source-side transaction/export facility. Restore accepts only an empty destination or a matching subset left by an earlier attempt. It inserts without replacement, rejects different or extra rows before mutation and verifies an exact final inventory. A failed attempt can retain earlier successful inserts, so retry the same authenticated snapshot. Keep unrelated destination writers paused until verification. The destination database, namespace and collection/table must already be provisioned where the engine requires them; schema or permission failures stay visible and are never reclassified as an empty destination.
The live release matrix exercises MongoDB → SurrealDB → MongoDB. It proves the bounded adapter contract on that runner, not backup retention, key custody, replication, point-in-time recovery, topology failover or vendor operations.
MongoDB
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
CollectionName, DocumentId, DocumentRepository, MongoDbStore,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct AuditEvent {
action: String,
}
async fn store_audit_event() -> Result<(), Box<dyn std::error::Error>> {
let store = MongoDbStore::<AuditEvent>::connect_or_mock(
std::env::var("MONGODB_URL").unwrap_or_default(),
"my_application",
).await?;
store.create(
&CollectionName::new("audit_events")?,
&DocumentId::new("event-1")?,
&AuditEvent { action: "login".into() },
).await?;
Ok(())
}
}
An empty URL or one beginning with mock_/mock:// selects an in-process,
deterministic BTreeMap store. A non-mock URL uses the official MongoDB Rust
driver. The release contract includes an optional Testcontainers CRUD suite;
availability, backups, indexes, topology and authorization remain deployment
responsibilities.
SurrealDB documents and graph reads
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
GraphQuery, GraphRepository, SurrealAuth, SurrealConfig, SurrealDbStore,
};
async fn read_graph() -> Result<(), Box<dyn std::error::Error>> {
let config = SurrealConfig::new(
std::env::var("SURREALDB_URL").unwrap_or_default(),
"main",
"application",
SurrealAuth::bearer(
std::env::var("SURREALDB_TOKEN").unwrap_or_default(),
),
);
let store = SurrealDbStore::<serde_json::Value>::connect_or_mock(config)?;
let query = GraphQuery::read_only(
"MATCH (person:person)-[knows:knows]->(friend:person) RETURN friend",
100,
)?;
let rows = store.query_graph(&query).await?;
let _ = rows;
Ok(())
}
}
The adapter uses SurrealDB’s documented HTTP /key, /sql, /rpc and /gql
protocol instead of embedding its BSL-licensed SDK. replace runs a typed
UPDATE type::record($table, $id) through /rpc rather than PUT /key,
which upserts: replacing a missing (for example concurrently deleted) document
returns PolyglotError::NotFound instead of recreating it, as on MongoDB. This
statement uses SurrealDB 3 function naming. It disables redirects,
requires HTTPS outside loopback unless cleartext is explicitly enabled, redacts
authentication in Debug, streams through a configurable 1 KiB–8 MiB memory
ceiling, and sends namespace/database headers on every scoped request.
Every DocumentId is stored as a string record key, including all-digit IDs
such as 9 (sent as a quoted key; SurrealDB would otherwise parse them as
numbers). Inventory pages therefore follow the portable string order, 10
before 9, on SurrealDB as on MongoDB. Records written by earlier versions
under an ID that SurrealDB parsed as a number, NULL or an exponent (for
example 9, null or 1e5) keep that non-string key and are not found by
these string IDs; rewrite them under their string key when upgrading.
Graph queries must start with MATCH; semicolons, caller-provided LIMIT, and
the INSERT, SET, REMOVE, and DELETE tokens are rejected. The adapter
then appends its own 1–1,000 row limit. This is a conservative read boundary,
not arbitrary SurrealQL execution or a graph mutation API. The /gql endpoint
requires SurrealDB 3.2 or newer and explicit experimental enablement on the
3.2 release line; the live contract pins 3.2.4 and enables that capability.
DuckDB analytics
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
AnalyticsRepository, AnalyticsValue, DuckDbStore, QueryLimit,
};
async fn analyze_events() -> Result<(), Box<dyn std::error::Error>> {
let analytics = DuckDbStore::in_memory().await?;
analytics.execute(
"CREATE TABLE events (sequence BIGINT, label VARCHAR)",
vec![],
).await?;
analytics.execute(
"INSERT INTO events VALUES (?, ?)",
vec![
AnalyticsValue::Signed(1),
AnalyticsValue::Text("started".into()),
],
).await?;
let rows = analytics.query(
"SELECT sequence, label FROM events WHERE sequence >= ?",
vec![AnalyticsValue::Signed(1)],
QueryLimit::new(500)?,
).await?;
let _ = rows;
Ok(())
}
}
DuckDB is bundled for a predictable optional build. Its connection is guarded and every native operation runs through Tokio’s blocking worker pool. Dynamic values are prepared parameters; SQL text remains trusted application structure. Results must declare a 1–10,000 row materialization limit. Scalar, decimal, temporal, interval, geometry and binary values are preserved; unsupported complex result types fail with a typed error instead of being guessed.
Specialized Qdrant and Redis stores
Qdrant has a separate dense-vector contract rather than a document or Active
Record facade. See RAG Systems & Vector Search
for its bounded collection, upsert, delete and cosine-query API. The project
wizard and deterministic CLI expose it as the additive --qdrant choice.
Redis native structures are also explicit and namespaced:
#![allow(unused)]
fn main() {
use rullst_orm::{
RedisDataConfig, RedisDataKey, RedisDataStore, RedisField, RedisMember,
RedisScanLimit, RedisStructuresRepository, RedisValue,
};
async fn redis_example() -> Result<(), Box<dyn std::error::Error>> {
let config = RedisDataConfig::new(
std::env::var("REDIS_URL").unwrap_or_default(),
"my-application",
std::env::var("REDIS_USERNAME").unwrap_or_default(),
std::env::var("REDIS_PASSWORD").unwrap_or_default(),
);
let data = RedisDataStore::connect_or_mock(config).await?;
let account = RedisDataKey::new("account:42")?;
data.hash_set(
&account,
&RedisField::new("display_name")?,
&RedisValue::new("Ada")?,
).await?;
data.set_add(&account, &RedisMember::new("reader")?).await?;
let roles = data
.set_scan(&account, RedisScanLimit::new(100)?)
.await?;
let _ = roles;
Ok(())
}
}
The adapter also supplies atomic signed hash increments, exact membership,
Sorted Set add/top ranking and exact per-structure deletion. Empty or mock_*
endpoint/ACL credentials choose the deterministic fallback. Live remote Redis
requires rediss://; intentionally unauthenticated development uses the
loopback-only constructor. Hash values are capped at 1 MiB, members at 4 KiB,
and scans/ranges at 1–1,000 accepted rows. Redis may still allocate a protocol
value before client-side validation, so untrusted writers require server ACLs,
quotas and isolation. Lists, Streams, cluster/failover, eviction and durable
Pub/Sub are not part of this datastore contract.
What this feature does not promise
- no cross-backend transaction, replication, migration, or consistency layer;
- no claim that document databases implement SQL joins or Active Record;
- no automatic choice of a datastore based on a model;
- no managed backups, production credentials, provider homologation, indexes, cluster failover, or performance guarantee;
- no universal “support for every database”.
Run the application’s exact feature set in addition to the full workspace gates. Remote production readiness still requires a disposable live environment, negative authorization tests, backup/restore rehearsal, and operational review.