1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
//! Search-client capability trait and types.
//!
//! A `SearchClientPlugin` is the runtime-side face of a search backend:
//! issue queries, push documents into an index, delete documents by id.
//! Backends fall into two families:
//!
//! 1. **Dedicated search engines** (`@bext/search-meili`,
//! `@bext/search-typesense`, `@bext/search-elastic`) — an external
//! service holds the inverted index and handles ranking.
//! 2. **SQL full-text** (`@bext/search-pg`) — the existing Postgres
//! instance runs `to_tsvector` / `plainto_tsquery` on a regular table.
//! No new infrastructure, good enough for "I want search and I have
//! one database" sites.
//!
//! The trait stays sync to match the rest of `bext-plugin-api`. 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` — the same pattern `@bext/auth-jwt`'s JWKS fetcher
//! and `@bext/flags-openfeature` use. Plugins cannot expose async across
//! the sandbox boundary, so the host-facing shape is sync.
//!
//! ## Query shape is intentionally small
//!
//! `SearchQuery` carries a text string, equality filters on attributes,
//! a limit, and an offset. That covers the 80% case (`autocomplete`,
//! `search within category`, `keyword + facet`) without leaking any
//! vendor's query DSL into the trait. Two escape hatches exist 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.
//!
//! The alternative — growing a rich shared query DSL — is the trap the
//! [architecture doc](../../plan/ecosystem/00-architecture.md) calls out:
//! vendor-coupled shapes end up looking like whichever backend shipped
//! first and never fit the next one cleanly.
//!
//! ## Document and hit payloads are JSON strings
//!
//! `Document::fields_json` and `SearchHit::source_json` are plain JSON
//! strings, not `serde_json::Value`s. 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.
/// A single document to push into an index.
///
/// `id` is the stable external identifier — backends use it to
/// deduplicate on re-index and as the target for `delete`. `fields_json`
/// is a JSON object encoded as a string; the backend decides which
/// top-level keys are searchable vs filterable vs stored. The trait does
/// not validate the JSON beyond requiring it to be parseable.
/// A query to issue against a named index.
///
/// Deliberately minimal — see the module docs for the rationale.
/// A single search hit returned by the backend.
///
/// `score` is a backend-defined relevance number. It is not normalised
/// across backends — callers that rank across multiple providers should
/// do their own re-ranking. `source_json` is the indexed document
/// re-serialised as a JSON string, matching the `Document::fields_json`
/// convention on the write side.
/// Result of a single `search` call.
/// Typed error returned by every `SearchClientPlugin` method.
///
/// Flat enum, not `Result<_, String>`, because classification matters:
/// the capability dispatcher distinguishes "you asked for something that
/// does not exist" from "you asked for something you cannot see" from
/// "your query itself was malformed" from "the backend blew up". Each
/// variant carries a message for operator-facing logs; callers should
/// match on the variant, not inspect the string.
/// A search backend.
///
/// The runtime holds one instance per configured backend and dispatches
/// `search.query`, `search.index`, and `search.delete` host calls
/// through it. All three methods are sync; backends that need async
/// transport wrap it internally. Every method takes an explicit index
/// name so a single backend can host many logical collections — this
/// matches Meili, Elastic, Typesense, and the pg-FTS convention of
/// "one index == one table".