Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Polyglot Persistence

Important

This page describes the v13 prerelease source, and its dependency examples pin the published 13.0.0-alpha.4 exactly: 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. Commit Cargo.lock for 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.