39. Scout Search Providers
Important
Dependency examples pin the v13 prerelease
=13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and the v12 line reached end of life on 16 October 2026 (see the release record). Commit Cargo.lock for reproducible builds. Use path dependencies only when intentionally testing checkout-local changes. The result ordering, SQL fallback, indexed-field,restore()/force_delete()projection and hit-cap rules below describe the v13 prerelease source (13.0.0-alpha.4).
Scout connects a #[orm(searchable)] SQLx model to one search backend. The
model database remains authoritative; the search index is a projection updated
only after a successful managed commit.
Enable the provider adapters
With the umbrella crate, add orm-scout next to the application’s primary
database feature (SQLite here):
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["orm-scout", "strict-sqlite"] }
Or enable scout-http directly on rullst-orm. The feature supplies bounded
HTTP adapters for Meilisearch, Elasticsearch and Algolia. The in-memory
MockSearchEngine is available without the HTTP feature.
The examples use rullst_orm::… paths: #[derive(Orm)], the model’s
FromRow included, expands to rullst_orm::… paths, so a crate that derives
models depends on rullst-orm directly. Projects generated by
cargo rullst new already list it.
Configure one engine
Configuration is process-wide and fail-closed if code tries to replace an already installed engine:
#![allow(unused)]
fn main() {
use rullst_orm::{MeilisearchEngine, set_search_engine};
fn configure() -> Result<(), rullst_orm::Error> {
let engine = MeilisearchEngine::new(
std::env::var("MEILI_URL").unwrap_or_default(),
std::env::var("MEILI_API_KEY").unwrap_or_default(),
)?;
set_search_engine(engine)?;
Ok(())
}
}
Empty or mock_* credentials deliberately select the deterministic offline
store. That makes local/test behavior explicit and prevents an accidental live
call with missing secrets. A keyless Meilisearch or Elasticsearch development
server must be selected with MeilisearchEngine::local(...) or
ElasticsearchEngine::local(...); those constructors accept only loopback
HTTP. Every endpoint must be an origin URL (no credentials, path, query or
fragment) using HTTPS, or HTTP on a loopback host, and redirects are disabled.
Algolia normally derives its official API origin from the application ID:
#![allow(unused)]
fn main() {
use rullst_orm::AlgoliaEngine;
fn engine() -> Result<AlgoliaEngine, rullst_orm::Error> {
AlgoliaEngine::new("APPLICATION_ID", "ADMIN_API_KEY")
}
}
AlgoliaEngine::with_endpoint exists for an explicitly configured compatible
proxy and applies the same HTTPS/loopback URL policy. Never expose an indexing
or admin key to browser code.
Mark and query the model
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Clone, Debug, Orm)]
#[orm(table = "articles", searchable)]
struct Article {
id: i32,
title: String,
body: String,
}
async fn find() -> Result<Vec<Article>, rullst_orm::Error> {
let results = Article::search("transactional outbox").await.get().await?;
Ok(results)
}
}
With an engine, the results follow the provider’s ranking (best hit first,
also for first() and paginate()) unless you add order_by(...).
Without a configured engine, Article::search(...) falls back to a SQL
LIKE over the model’s persisted columns, excluding #[orm(hidden)],
#[orm(encrypted)], #[orm(masked)] and SecretString fields. % and _
in the query match literally rather than as wildcards.
Generated save/update/delete operations, restore() (re-index) and
force_delete() (removal) project only after the relational commit. The
indexed document omits #[orm(hidden)], #[orm(encrypted)] and
#[orm(masked)] fields, so the provider never receives their values; documents
indexed by earlier versions should be reindexed. Rollback produces no search
write. Provider or search errors remain typed errors; they are not silently
converted into an empty result.
The shared adapter boundary enforces:
- index names (the model’s table) of at most 128 bytes that start with a
lowercase ASCII letter and contain only lowercase letters, digits,
-or_; - positive
i32document IDs; - object-shaped JSON documents no larger than one MiB;
- queries no larger than 1,024 bytes and without control characters;
- at most 1,000 parsed hits and a four-MiB response body;
- five-second connect and twenty-second request deadlines;
- disabled redirects and no secret-bearing error bodies.
The hit cap (rullst_orm::scout::MAX_SEARCH_HITS) applies to the whole shared
index, before generated tenant, model-wide and soft-delete scopes. For a model
with such a scope, an answer of 1,000 IDs is treated as truncated and
search() answers from the SQL fallback instead.
Meilisearch and Algolia asynchronous indexing tasks are awaited with a bounded
poll loop. Elasticsearch uses refresh=wait_for for the adapter operations.
Choose the durability level
The generated Scout hook is process-local after commit. A provider outage is
reported as PostCommit: the model row is already durable and must not be
blindly inserted again. This path is suitable when the application can rebuild
the index or explicitly retry.
If every projection must survive a process crash, write a versioned search
event through Outbox::enqueue in the domain transaction and run the selected
engine from an idempotent outbox worker. Rullst does not invent a stable event
key or serialize every model hook automatically. See
Transactional Outbox & Durable Effects.
Evidence boundaries
The repository runs a real update/search/delete lifecycle against a digest-pinned Meilisearch container. Elasticsearch and Algolia run deterministic offline tests plus local HTTP protocol fixtures that verify paths, headers, payloads, response bounds and ID parsing. Those fixtures do not prove a hosted Algolia account, every Elasticsearch version, cluster failover, ranking quality or production capacity.