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

39. Scout Search Providers

Important

Dependency examples pin the v13 prerelease =13.0.0-alpha.4 exactly: 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 i32 document 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.