Search Client

The Search Client capability answers one question on every call: which documents in this named index match this query? A SearchClientPlugin is a search backend — hand it an index name and a query, it returns ranked hits. The trait says nothing about how the ranking was computed, where the inverted index lives, or whether there even is an inverted index: it might come from a dedicated engine like Meilisearch, Elasticsearch, or Typesense, or from to_tsvector running against a plain Postgres table. All of them sit behind the same shape so call sites never branch on backend.

This page covers the trait, the deliberately small SearchQuery shape, how JSON-string payloads keep the ABI flat, how the typed SearchError classifies failure modes, and how the two reference plugins — @bext/search-meili and @bext/search-pg — map onto the trait.

The trait

Every Search Client plugin implements one trait, SearchClientPlugin, defined in bext-plugin-api::search:

pub trait SearchClientPlugin: Send + Sync {
    fn name(&self) -> &str;

    fn search(&self, index: &str, query: &SearchQuery) -> Result<SearchResults, SearchError>;

    fn index(&self, index: &str, docs: Vec) -> Result<(), SearchError>;

    fn delete(&self, index: &str, ids: Vec) -> Result<(), SearchError>;

    fn is_healthy(&self) -> bool { true }
}

Three methods — read, write, delete. Every method takes an explicit index name so a single backend can host many logical collections; this matches Meili's "indexes", Elastic's "indices", Typesense's "collections", and the pg-FTS convention of "one index == one table". The trait is sync to match the rest of bext-plugin-api, so the shape travels cleanly across the WASM, QuickJS, and nsjail sandboxes. Backends that speak native async (the Meilisearch SDK, the Elasticsearch Rust client) either use their blocking sibling or own a small tokio runtime and call block_on internally — the same pattern @bext/auth-jwt's JWKS fetcher and @bext/flags-openfeature's provider calls use. Async transport lives inside the plugin; the host-facing shape stays sync.

delete is idempotent — deleting an id that does not exist is not an error, matching Redis DEL, S3 DeleteObject, and every other idempotent delete in the plugin API. This lets the caller run "delete then insert" to re-seed an index without branching on whether each target was already present.

SearchQuery — deliberately minimal

pub struct SearchQuery {
    pub text: String,
    pub filters: Vec<(String, String)>,
    pub limit: u32,
    pub offset: u32,
}

Four fields: a free-form text string, a flat list of attribute equality filters, a limit, and an offset. This is the smallest shape that covers the 80% case — autocomplete, search within category, keyword + facet — without leaking any vendor's query DSL into the trait. Every backend in the plan fits inside it cleanly: Meili translates filters into its field = "value" AND ... filter language, pg translates them into WHERE data ->> 'field' = $N, Elastic into a bool.must of term clauses, Typesense into its filter_by string.

Growing a richer shared query DSL — boolean trees, geo filters, range queries, facet aggregations — is the trap the ecosystem architecture doc calls out: vendor-coupled shapes end up looking like whichever backend shipped first and never fit the next one cleanly. Instead the trait leaves two escape hatches for richer needs:

* A backend that wants a raw JSON query can accept it in text and document the shape — the trait does not parse text.

* A backend can expose its own richer API behind SearchClientPlugin at construction time, then narrow down to the trait when called from capability-dispatching code.

limit = 0 means "use the backend's default", which is the same convention @bext/session-redis uses for TTLs and the Meilisearch SDK uses when no limit is provided. Callers that want a cap should set an explicit value; callers that want "all of them" should page through the offset instead of asking the backend to return unbounded results.

Document and hit payloads are JSON strings

pub struct Document {
    pub id: String,
    pub fields_json: String,
}

pub struct SearchHit {
    pub id: String,
    pub score: f32,
    pub source_json: String,
}

Document::fields_json and SearchHit::source_json are plain JSON strings, not serde_json::Values. This matches the Session capability carrying session data, the Lifecycle capability carrying event payloads, and the Feature Flag capability carrying structured flag values. The reason is the same every time: the WASM / QuickJS / nsjail sandbox ABI is flatter when it only has to transport bytes, and the host-facing code pays one serde_json::from_str at the edge instead of shoving a fully-typed value across the boundary.

id is the stable external identifier — backends use it to deduplicate on re-index (so you can call index twice with the same id and get an upsert, not a duplicate) and as the target for delete. score is a backend-defined relevance number, comparable within a single result set but not across backends or across queries. Callers that rank across multiple providers should do their own re-ranking on the source JSON.

Errors classify into four recoverable categories

pub enum SearchError {
    IndexNotFound(String),
    AccessDenied(String),
    BadQuery(String),
    Backend(String),
}

A flat typed enum, not Result<_, String>, because classification matters at the dispatch layer. The four variants map onto the four things a caller might want to do differently:

* IndexNotFound — the index itself is missing. Not the same as "empty index", which is Ok(SearchResults { hits: vec![], .. }). Sites that bootstrap indices on first use catch this variant and run the create-index flow.

* AccessDenied — authentication or authorisation failed. Wrong API key, wrong role, wrong network. Distinct from Backend so the dispatcher can escalate credentials issues without paging on transport flakes.

* BadQuery — the query itself was malformed. Syntax error, unsupported filter shape, out-of-range offset. Caller error, not backend fault; recoverable by fixing the input.

* Backend — everything else: network failure, 5xx, driver panic, timeout. Not classified further because the caller cannot recover from any of them except by retrying or alerting.

Every variant carries an operator-facing message string — match on the variant for control flow, inspect the string for logs. This is the same flat-enum pattern SessionError, MailerError, StorageError, and WebhookError use.

Reference plugins

@bext/search-meili

Lives at crates/bext-impls/bext-search-meili. Wraps meilisearch-sdk behind the sync SearchClientPlugin by owning a 1-worker multi-thread tokio runtime and block_on-ing the SDK's async calls. The adapter's filter translator turns Vec<(field, value)> into Meili's filter DSL string — category = "shoes" AND color = "red" — quoting every value so raw user input cannot break out into new clauses. Single quotes and backslashes inside values are escaped; field names are passed through as-is because Meili's convention is that filterable attributes are declared at index-config time by the operator, not the caller, so there is no user-provided identifier to sanitize.

Document writes parse each caller's fields_json as a JSON object, inject the Document::id under the id key (the explicit id wins if the caller already put one inside fields_json), and push the whole object to the SDK via add_documents with "id" as the primary key. Reads map each Meili hit back to SearchHit { id, score, source_json } by pulling id out of the JSON result and re-serialising the full result object as the source. Errors come out of the SDK's MeiliError::Meilisearch(m) variant and get classified by m.error_code: IndexNotFound, InvalidApiKey | MissingAuthorizationHeaderAccessDenied, InvalidSearchQ | InvalidSearchFilter | InvalidSearchLimit | InvalidSearchOffsetBadQuery, everything else → Backend.

The Meili backend is the right choice when the site already wants typo tolerance, faceted search, and hit highlighting; it is not the right choice when you want to avoid running an extra service alongside Postgres.

@bext/search-pg

Lives at crates/bext-impls/bext-search-pg. For sites that already run Postgres and do not want a dedicated search engine. Uses to_tsvector on a configurable text column plus plainto_tsquery on the caller's query string, with optional filter clauses translated to WHERE data ->> 'field' = $N:

SELECT id, data, ts_rank(to_tsvector('english', body), plainto_tsquery('english', $1)) AS rank
FROM products
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', $1)
  AND data ->> 'category' = $2
ORDER BY rank DESC
LIMIT 20;

The expected schema is three columns: id text PRIMARY KEY, data jsonb NOT NULL (the JSON payload returned as source_json), and body text NOT NULL (the column fed to to_tsvector). A gin index on to_tsvector('english', body) keeps the WHERE clause sublinear. The data and text column names are configurable; the id column name is not. Sites that want multi-column search configure a generated column (GENERATED ALWAYS AS (title || ' ' || body) STORED) and point the plugin at it.

Injection defense is strict. Every caller-supplied identifier — the index name, configured column names, and filter field names — is validated against an ASCII-alphanumeric-plus-underscore regex that also requires the first character to be a letter or underscore. Values always flow through $N parameter placeholders. The one place untrusted identifiers could sneak into the SQL string is the filter map's field keys, and those are rejected at build time: pass 1=1-- as a filter field and you get SearchError::BadQuery, not a query.

Errors are classified off the Postgres SQLSTATE code: 42P01 (undefined_table) → IndexNotFound, 42501 (insufficient_privilege) → AccessDenied, anything else in the 42xxx class (syntax or semantic errors) → BadQuery, everything else → Backend. This lets a site that forgot to create its table catch the dispatch as IndexNotFound and run a migration in response, instead of staring at a generic 500.

The pg backend is the right choice when your data already lives in Postgres and your search needs stop at "filter + rank by relevance". It is not a replacement for Meili or Elastic — no typo tolerance, no synonym maps, no distributed inverted index.

Configuration

Search plugins are configured in the [search] block of bext.config.toml:

[search]
provider = "@bext/search-meili"
host = "http://localhost:7700"
api_key = "$MEILI_MASTER_KEY"

For Postgres:

[search]
provider = "@bext/search-pg"
conn_str = "$DATABASE_URL"
text_column = "body"
data_column = "data"
dictionary = "english"

Only one provider is active at a time. Sites that want to search across multiple backends — for example, Meili for product search and pg for audit-log lookups — wire a dispatcher plugin on top rather than expanding the config shape, so the composition is explicit and greppable.

Where it fits

Search Client is the mid-tier capability that lets a site add product search, document search, or log lookup without picking an engine up-front. The minimal query shape is deliberate: grow it later on the trait once three backends have shown the next useful addition, not in advance based on one backend's DSL. Ranking strategies, synonym expansion, typo correction, query rewriting, and multi-index federation all live on top of this trait, not inside it. That split is what keeps the capability cheap to add and the vendor landscape interchangeable.