Skip to main content

autumn_search/
backend.rs

1//! The pluggable [`SearchBackend`] seam and the query/result vocabulary.
2//!
3//! The trait is deliberately **plain data in, plain data out**: an index
4//! definition, documents, a filter, a page request. Nothing in the signature
5//! assumes Postgres, so an external engine (Meilisearch, Tantivy, a vector
6//! store) is a new `impl`, not a fork — and, crucially, an out-of-scope engine
7//! still receives the [`SearchFilter`] verbatim, which is what keeps
8//! authorization enforceable everywhere.
9//!
10//! # Adding operations without breaking implementors
11//!
12//! Every query/result struct is `#[non_exhaustive]` with builder methods, and
13//! every trait method beyond the required core has a default implementation —
14//! [`SearchBackend::capabilities`] reports a keyword-only backend, and the
15//! vector methods return [`SearchError::Unsupported`]. New capabilities are
16//! therefore additive.
17
18use std::collections::BTreeMap;
19use std::future::Future;
20use std::pin::Pin;
21
22use autumn_web::pagination::{Page, PageRequest};
23use autumn_web::search::{IndexDefinition, SearchDocument};
24use serde::{Deserialize, Serialize};
25
26use crate::error::{SearchError, SearchResult};
27
28/// Boxed future returned by the object-safe search traits.
29///
30/// `SearchBackend`, [`crate::Embedder`], [`crate::DocumentSource`] and
31/// [`crate::SearchVisibility`] are all stored as `Arc<dyn …>`, so they cannot
32/// use `impl Future`. This mirrors `autumn_web::authorization::Policy`.
33pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
34
35// ── Documents ───────────────────────────────────────────────────────────────
36
37/// A [`SearchDocument`] paired with its (optional) embedding.
38///
39/// The document comes from the model via `#[searchable]`; the embedding comes
40/// from the app's [`crate::Embedder`]. Keeping them in one struct means a
41/// backend writes both halves of a record in a single call, so a keyword index
42/// and a vector index can never drift apart.
43#[derive(Debug, Clone, PartialEq)]
44#[non_exhaustive]
45pub struct IndexedDocument {
46    /// The extracted record.
47    pub document: SearchDocument,
48    /// The embedding of [`SearchDocument::embed_text`], when one was produced.
49    pub embedding: Option<Vec<f32>>,
50}
51
52impl IndexedDocument {
53    /// Wrap a document with no embedding.
54    #[must_use]
55    pub const fn new(document: SearchDocument) -> Self {
56        Self {
57            document,
58            embedding: None,
59        }
60    }
61
62    /// Attach an embedding.
63    #[must_use]
64    pub fn with_embedding(mut self, embedding: Vec<f32>) -> Self {
65        self.embedding = Some(embedding);
66        self
67    }
68
69    /// Primary key of the underlying record.
70    #[must_use]
71    pub const fn id(&self) -> i64 {
72        self.document.id
73    }
74}
75
76// ── Filters (the authorization seam) ────────────────────────────────────────
77
78/// Restrictions applied *inside* the backend query.
79///
80/// This is the "tenant/visibility filter hook" every engine must honour. It is
81/// a required argument of the query methods rather than an optional
82/// post-processing step, so a backend cannot accidentally return rows the
83/// caller is not allowed to see: forgetting the filter is a visibly missing
84/// field, not a silently skipped `if`.
85///
86/// Filters **intersect** ([`SearchFilter::intersect`]) rather than overwrite,
87/// so a caller-supplied filter can only ever narrow what authorization allowed.
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct SearchFilter {
91    /// Only match documents owned by this tenant.
92    pub tenant_id: Option<String>,
93    /// When `Some`, only these record ids may match. `Some(vec![])` means
94    /// "nothing is visible" — an empty allowlist is a real restriction, never
95    /// "no restriction".
96    pub allowed_ids: Option<Vec<i64>>,
97    /// Record ids that must never match.
98    pub excluded_ids: Vec<i64>,
99    /// Exact-match predicates on indexed fields (plus the reserved
100    /// `tenant_id` key). This is what carries an app's own scoping to an
101    /// engine that has no SQL to join against.
102    pub equals: BTreeMap<String, String>,
103}
104
105/// The reserved [`SearchFilter::equals`] key that maps onto
106/// [`SearchDocument::tenant_id`] rather than an indexed field.
107pub const TENANT_FILTER_KEY: &str = "tenant_id";
108
109impl SearchFilter {
110    /// Restrict to one tenant.
111    #[must_use]
112    pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
113        self.tenant_id = Some(tenant_id.into());
114        self
115    }
116
117    /// Restrict to an explicit set of record ids.
118    #[must_use]
119    pub fn allow_ids(mut self, ids: impl IntoIterator<Item = i64>) -> Self {
120        self.allowed_ids = Some(ids.into_iter().collect());
121        self
122    }
123
124    /// Exclude specific record ids.
125    #[must_use]
126    pub fn exclude_ids(mut self, ids: impl IntoIterator<Item = i64>) -> Self {
127        self.excluded_ids.extend(ids);
128        self
129    }
130
131    /// Add an exact-match predicate on an indexed field.
132    ///
133    /// `field` must name a field the index declares (or the reserved
134    /// [`TENANT_FILTER_KEY`]). A key that does not is **not** an error here —
135    /// it makes the filter match nothing, in both the in-Rust
136    /// ([`SearchFilter::permits`]) and SQL paths. Backends must never
137    /// interpolate an undeclared key into a query: the value is bound, but a
138    /// key is an identifier position, so an unchecked one would let request
139    /// text terminate the predicate and `OR` away the restrictions before it.
140    #[must_use]
141    pub fn equals(mut self, field: impl Into<String>, value: impl Into<String>) -> Self {
142        self.equals.insert(field.into(), value.into());
143        self
144    }
145
146    /// Whether this filter can never match anything.
147    ///
148    /// Backends short-circuit on this so an impossible filter costs no query.
149    #[must_use]
150    pub fn matches_nothing(&self) -> bool {
151        self.allowed_ids.as_ref().is_some_and(Vec::is_empty)
152    }
153
154    /// Combine two filters so that **both** must hold.
155    ///
156    /// Two incompatible constraints (different tenants, disjoint allowlists,
157    /// conflicting `equals` on the same key) collapse to an impossible filter
158    /// rather than arbitrarily picking a winner — the safe direction.
159    #[must_use]
160    pub fn intersect(mut self, other: Self) -> Self {
161        let mut impossible = false;
162
163        match (&self.tenant_id, &other.tenant_id) {
164            (Some(a), Some(b)) if a != b => impossible = true,
165            (None, Some(b)) => self.tenant_id = Some(b.clone()),
166            _ => {}
167        }
168
169        self.allowed_ids = match (self.allowed_ids.take(), other.allowed_ids) {
170            (Some(a), Some(b)) => Some(a.into_iter().filter(|id| b.contains(id)).collect()),
171            (Some(a), None) => Some(a),
172            (None, b) => b,
173        };
174
175        self.excluded_ids.extend(other.excluded_ids);
176        self.excluded_ids.sort_unstable();
177        self.excluded_ids.dedup();
178
179        for (key, value) in other.equals {
180            match self.equals.get(&key) {
181                Some(existing) if *existing != value => impossible = true,
182                _ => {
183                    self.equals.insert(key, value);
184                }
185            }
186        }
187
188        if impossible {
189            self.allowed_ids = Some(Vec::new());
190        }
191        self
192    }
193
194    /// Whether `document` satisfies this filter.
195    ///
196    /// Shared by every backend that filters in Rust (the in-memory backend and
197    /// any engine whose driver cannot express a predicate), so the semantics
198    /// are defined once.
199    #[must_use]
200    pub fn permits(&self, document: &SearchDocument) -> bool {
201        if let Some(tenant) = &self.tenant_id
202            && document.tenant_id.as_deref() != Some(tenant.as_str())
203        {
204            return false;
205        }
206        if let Some(allowed) = &self.allowed_ids
207            && !allowed.contains(&document.id)
208        {
209            return false;
210        }
211        if self.excluded_ids.contains(&document.id) {
212            return false;
213        }
214        for (field, expected) in &self.equals {
215            let actual = if field == TENANT_FILTER_KEY {
216                document.tenant_id.as_deref()
217            } else {
218                document.field(field)
219            };
220            if actual != Some(expected.as_str()) {
221                return false;
222            }
223        }
224        true
225    }
226}
227
228// ── Queries ─────────────────────────────────────────────────────────────────
229
230/// A ranked keyword query.
231///
232/// **Query semantics (the cross-backend contract).** `text` is a bag of words,
233/// not a query language: every token must be present for a document to match,
234/// and operator-looking input (`OR`, `field:`, `*`, quotes) is never
235/// interpreted as syntax. That keeps results consistent across engines and
236/// makes a hostile query string structurally incapable of widening the result
237/// set. The Postgres backend implements this with `plainto_tsquery`, which is
238/// parameterized and cannot be injected into.
239#[derive(Debug, Clone)]
240#[non_exhaustive]
241pub struct KeywordQuery {
242    /// Raw user query text.
243    pub text: String,
244    /// Restrictions applied inside the backend query.
245    pub filter: SearchFilter,
246    /// Page number and size.
247    pub page: PageRequest,
248}
249
250impl KeywordQuery {
251    /// Build a keyword query.
252    #[must_use]
253    pub fn new(text: impl Into<String>, page: PageRequest) -> Self {
254        Self {
255            text: text.into(),
256            filter: SearchFilter::default(),
257            page,
258        }
259    }
260
261    /// Apply a filter (replacing any previously set filter).
262    #[must_use]
263    pub fn filter(mut self, filter: SearchFilter) -> Self {
264        self.filter = filter;
265        self
266    }
267}
268
269/// A vector / k-NN similarity query.
270#[derive(Debug, Clone)]
271#[non_exhaustive]
272pub struct VectorQuery {
273    /// The query embedding.
274    pub vector: Vec<f32>,
275    /// Maximum neighbours to return.
276    pub limit: usize,
277    /// Restrictions applied inside the backend query.
278    pub filter: SearchFilter,
279    /// Drop neighbours whose cosine similarity is below this.
280    pub min_score: Option<f32>,
281}
282
283impl VectorQuery {
284    /// Build a vector query for the `limit` nearest neighbours.
285    #[must_use]
286    pub fn new(vector: Vec<f32>, limit: usize) -> Self {
287        Self {
288            vector,
289            limit,
290            filter: SearchFilter::default(),
291            min_score: None,
292        }
293    }
294
295    /// Apply a filter (replacing any previously set filter).
296    #[must_use]
297    pub fn filter(mut self, filter: SearchFilter) -> Self {
298        self.filter = filter;
299        self
300    }
301
302    /// Require at least this cosine similarity.
303    #[must_use]
304    pub const fn min_score(mut self, min_score: f32) -> Self {
305        self.min_score = Some(min_score);
306        self
307    }
308}
309
310// ── Results ─────────────────────────────────────────────────────────────────
311
312/// One ranked result.
313///
314/// A hit carries the *identity* of a record, never its contents: hydration is
315/// the app's job (see `SearchClient::search_hydrated`), which keeps the index
316/// from becoming a second, staler copy of the database that can leak columns.
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[non_exhaustive]
319pub struct SearchHit {
320    /// Index the hit came from.
321    pub index: String,
322    /// Primary key of the matching record.
323    pub id: i64,
324    /// Backend-defined relevance score; higher is better, and comparable only
325    /// within a single result set.
326    pub score: f32,
327}
328
329impl SearchHit {
330    /// Construct a hit.
331    #[must_use]
332    pub fn new(index: impl Into<String>, id: i64, score: f32) -> Self {
333        Self {
334            index: index.into(),
335            id,
336            score,
337        }
338    }
339}
340
341/// What a backend can do, so callers can degrade deliberately.
342///
343/// Five independent yes/no capabilities. `clippy::struct_excessive_bools`
344/// suggests enums, but each of these is genuinely orthogonal — an engine can
345/// support any subset — so a flag set is the honest shape.
346#[allow(clippy::struct_excessive_bools)]
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348#[non_exhaustive]
349pub struct BackendCapabilities {
350    /// Ranked keyword search.
351    pub keyword: bool,
352    /// Vector / k-NN search.
353    pub vector: bool,
354    /// Per-field weighting (`#[searchable(weight = "A")]`).
355    pub weighted_fields: bool,
356    /// Reading a stored embedding back (`similar_to`).
357    pub embedding_readback: bool,
358    /// Honouring [`SearchBackend::index_unless_newer`]'s watermark, so a
359    /// backfill cannot overwrite a concurrent per-record reindex.
360    pub conditional_index: bool,
361}
362
363impl Default for BackendCapabilities {
364    fn default() -> Self {
365        Self {
366            keyword: true,
367            vector: false,
368            weighted_fields: false,
369            embedding_readback: false,
370            conditional_index: false,
371        }
372    }
373}
374
375impl BackendCapabilities {
376    /// Declare vector / k-NN support.
377    #[must_use]
378    pub const fn with_vector(mut self, supported: bool) -> Self {
379        self.vector = supported;
380        self
381    }
382
383    /// Declare per-field weighting support.
384    #[must_use]
385    pub const fn with_weighted_fields(mut self, supported: bool) -> Self {
386        self.weighted_fields = supported;
387        self
388    }
389
390    /// Declare stored-embedding read-back support (`similar_to`).
391    #[must_use]
392    pub const fn with_embedding_readback(mut self, supported: bool) -> Self {
393        self.embedding_readback = supported;
394        self
395    }
396
397    /// Declare that [`SearchBackend::index_unless_newer`]'s watermark is
398    /// honoured rather than ignored.
399    #[must_use]
400    pub const fn with_conditional_index(mut self, supported: bool) -> Self {
401        self.conditional_index = supported;
402        self
403    }
404
405    /// Declare keyword-search support. Backends that cannot answer a keyword
406    /// query at all set this `false`.
407    #[must_use]
408    pub const fn with_keyword(mut self, supported: bool) -> Self {
409        self.keyword = supported;
410        self
411    }
412}
413
414// ── The trait ───────────────────────────────────────────────────────────────
415
416/// A search engine `autumn-search` can drive.
417///
418/// Implement this to add an engine. The three write methods plus
419/// [`keyword_search`](SearchBackend::keyword_search) are required; vector
420/// operations default to [`SearchError::Unsupported`] so a keyword-only engine
421/// is a complete, honest implementation.
422pub trait SearchBackend: Send + Sync + 'static {
423    /// Stable backend name, used in errors and logs.
424    fn name(&self) -> &'static str;
425
426    /// What this backend supports.
427    fn capabilities(&self) -> BackendCapabilities {
428        BackendCapabilities::default()
429    }
430
431    /// Create or migrate the storage for `definition`. Must be idempotent —
432    /// it runs on every boot — and must reject a definition that fails
433    /// [`IndexDefinition::validate`].
434    fn ensure_index<'a>(
435        &'a self,
436        definition: &'a IndexDefinition,
437    ) -> BoxFuture<'a, SearchResult<()>>;
438
439    /// Upsert `documents`, keyed by `(index, id)`. Must be idempotent: the
440    /// reindex job delivers at least once.
441    fn index<'a>(
442        &'a self,
443        definition: &'a IndexDefinition,
444        documents: &'a [IndexedDocument],
445    ) -> BoxFuture<'a, SearchResult<()>>;
446
447    /// A marker for "now" on **the backend's own clock**, for
448    /// [`index_unless_newer`](Self::index_unless_newer).
449    ///
450    /// Opaque and backend-defined: the comparison happens inside the backend,
451    /// so the caller never has to reconcile its clock with the store's. The
452    /// default `None` means this backend has no watermark, and
453    /// `index_unless_newer` degrades to an unconditional write.
454    ///
455    /// # Errors
456    ///
457    /// Returns [`SearchError::Backend`](crate::SearchError::Backend) if the
458    /// watermark cannot be read.
459    fn write_watermark(&self) -> BoxFuture<'_, SearchResult<Option<String>>> {
460        Box::pin(async { Ok(None) })
461    }
462
463    /// Index `documents`, but **do not overwrite** a document that has been
464    /// written since `watermark`.
465    ///
466    /// This is how a backfill avoids clobbering a concurrent per-record
467    /// reindex. A backfill reads a batch, embeds it (a provider round-trip:
468    /// slow), then writes. A mutation in that window enqueues a reindex job
469    /// that reads and writes the *new* state — and the backfill's in-flight
470    /// batch would then overwrite it with what the source said minutes ago.
471    /// Nothing re-runs the reindex, so the index stays stale until the next
472    /// mutation, and the same ordering resurrects a record deleted mid-run.
473    ///
474    /// A backfill takes one watermark up front and passes it to every batch,
475    /// so any document a reindex wrote after the backfill started is left
476    /// alone. Newer always wins, and the two writers converge without needing
477    /// to know about each other.
478    ///
479    /// The default implementation ignores `watermark` and delegates to
480    /// [`index`](Self::index) — correct for a backend with no notion of write
481    /// time, at the cost of last-writer-wins during a backfill. Report
482    /// [`BackendCapabilities::conditional_index`] to say you honour it.
483    ///
484    /// # Errors
485    ///
486    /// As [`index`](Self::index).
487    fn index_unless_newer<'a>(
488        &'a self,
489        definition: &'a IndexDefinition,
490        documents: &'a [IndexedDocument],
491        watermark: Option<&'a str>,
492    ) -> BoxFuture<'a, SearchResult<()>> {
493        let _ = watermark;
494        self.index(definition, documents)
495    }
496
497    /// Remove documents by record id. Removing an absent id is a no-op, not an
498    /// error — deletes are replayed.
499    fn delete<'a>(
500        &'a self,
501        definition: &'a IndexDefinition,
502        ids: &'a [i64],
503    ) -> BoxFuture<'a, SearchResult<()>>;
504
505    /// Remove every document in the index.
506    fn clear<'a>(&'a self, definition: &'a IndexDefinition) -> BoxFuture<'a, SearchResult<()>>;
507
508    /// Ranked, paginated keyword search. See [`KeywordQuery`] for the query
509    /// semantics every backend must implement.
510    fn keyword_search<'a>(
511        &'a self,
512        definition: &'a IndexDefinition,
513        query: &'a KeywordQuery,
514    ) -> BoxFuture<'a, SearchResult<Page<SearchHit>>>;
515
516    /// k-NN search over the embedded field.
517    fn vector_search<'a>(
518        &'a self,
519        _definition: &'a IndexDefinition,
520        _query: &'a VectorQuery,
521    ) -> BoxFuture<'a, SearchResult<Vec<SearchHit>>> {
522        let name = self.name();
523        Box::pin(async move {
524            Err(SearchError::Unsupported {
525                backend: name,
526                operation: "vector search",
527            })
528        })
529    }
530
531    /// Read back the stored embedding for one record, so "find records like
532    /// *this* one" needs no re-embedding.
533    ///
534    /// `filter` is **not** optional: this read is a query like any other, and
535    /// the seed record is caller-supplied. Returning an embedding the caller
536    /// may not see would be a cross-tenant inference channel — the caller
537    /// could rank their *own* probe documents against a record they cannot
538    /// read. Implementations MUST return `None` for a record the filter
539    /// excludes, exactly as if it were not indexed.
540    fn embedding<'a>(
541        &'a self,
542        _definition: &'a IndexDefinition,
543        _id: i64,
544        _filter: &'a SearchFilter,
545    ) -> BoxFuture<'a, SearchResult<Option<Vec<f32>>>> {
546        let name = self.name();
547        Box::pin(async move {
548            Err(SearchError::Unsupported {
549                backend: name,
550                operation: "embedding read-back",
551            })
552        })
553    }
554}
555
556/// Build an empty page for `request` — the fail-closed result every backend
557/// returns for a blank query or an impossible filter.
558#[must_use]
559pub fn empty_page(request: &PageRequest) -> Page<SearchHit> {
560    Page::new(Vec::new(), 0, request)
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn doc(id: i64) -> SearchDocument {
568        SearchDocument::new("articles", id).with_field("title", 'A', "Hello")
569    }
570
571    #[test]
572    fn an_empty_filter_permits_everything() {
573        assert!(SearchFilter::default().permits(&doc(1)));
574        assert!(!SearchFilter::default().matches_nothing());
575    }
576
577    #[test]
578    fn an_empty_allowlist_is_a_restriction_not_an_absence() {
579        let filter = SearchFilter::default().allow_ids(Vec::<i64>::new());
580        assert!(filter.matches_nothing());
581        assert!(!filter.permits(&doc(1)));
582    }
583
584    #[test]
585    fn tenant_filter_requires_an_exact_owner() {
586        let filter = SearchFilter::default().tenant("acme");
587        assert!(!filter.permits(&doc(1)), "an untenanted doc is not acme's");
588        assert!(filter.permits(&doc(1).with_tenant("acme")));
589        assert!(!filter.permits(&doc(1).with_tenant("globex")));
590    }
591
592    #[test]
593    fn excluded_ids_win_over_the_allowlist() {
594        let filter = SearchFilter::default()
595            .allow_ids([1_i64])
596            .exclude_ids([1_i64]);
597        assert!(!filter.permits(&doc(1)));
598    }
599
600    #[test]
601    fn equals_matches_indexed_fields_and_the_reserved_tenant_key() {
602        let filter = SearchFilter::default().equals("title", "Hello");
603        assert!(filter.permits(&doc(1)));
604        assert!(
605            !SearchFilter::default()
606                .equals("title", "Nope")
607                .permits(&doc(1))
608        );
609        assert!(
610            SearchFilter::default()
611                .equals(TENANT_FILTER_KEY, "acme")
612                .permits(&doc(1).with_tenant("acme"))
613        );
614    }
615
616    #[test]
617    fn equals_on_a_field_the_document_lacks_never_matches() {
618        assert!(
619            !SearchFilter::default()
620                .equals("missing", "x")
621                .permits(&doc(1))
622        );
623    }
624
625    #[test]
626    fn intersect_narrows_and_never_widens() {
627        let merged = SearchFilter::default()
628            .allow_ids([1_i64, 2, 3])
629            .intersect(SearchFilter::default().allow_ids([2_i64, 4]));
630        assert_eq!(merged.allowed_ids, Some(vec![2]));
631
632        // An unconstrained side must not erase the constrained one.
633        let merged = SearchFilter::default()
634            .allow_ids([1_i64])
635            .intersect(SearchFilter::default());
636        assert_eq!(merged.allowed_ids, Some(vec![1]));
637
638        let merged = SearchFilter::default().intersect(SearchFilter::default().allow_ids([1_i64]));
639        assert_eq!(merged.allowed_ids, Some(vec![1]));
640    }
641
642    #[test]
643    fn intersect_collapses_conflicting_constraints_to_nothing() {
644        let merged = SearchFilter::default()
645            .tenant("acme")
646            .intersect(SearchFilter::default().tenant("globex"));
647        assert!(merged.matches_nothing());
648
649        let merged = SearchFilter::default()
650            .equals("state", "published")
651            .intersect(SearchFilter::default().equals("state", "draft"));
652        assert!(merged.matches_nothing());
653    }
654
655    #[test]
656    fn intersect_unions_and_dedups_exclusions() {
657        let merged = SearchFilter::default()
658            .exclude_ids([1_i64, 2])
659            .intersect(SearchFilter::default().exclude_ids([2_i64, 3]));
660        assert_eq!(merged.excluded_ids, vec![1, 2, 3]);
661    }
662
663    #[test]
664    fn default_capabilities_claim_only_keyword_search() {
665        let caps = BackendCapabilities::default();
666        assert!(caps.keyword);
667        assert!(!caps.vector);
668        assert!(!caps.weighted_fields);
669        assert!(!caps.embedding_readback);
670    }
671
672    #[test]
673    fn capabilities_are_declarable_from_outside_this_crate() {
674        // `#[non_exhaustive]` forbids a struct literal downstream, so without
675        // these builders a third-party backend could only ever report the
676        // keyword-only default — it could never advertise vector support.
677        let caps = BackendCapabilities::default()
678            .with_vector(true)
679            .with_weighted_fields(true)
680            .with_embedding_readback(true);
681        assert!(caps.keyword);
682        assert!(caps.vector);
683        assert!(caps.weighted_fields);
684        assert!(caps.embedding_readback);
685
686        assert!(!BackendCapabilities::default().with_keyword(false).keyword);
687    }
688
689    #[test]
690    fn empty_page_reports_a_zero_total() {
691        let page = empty_page(&PageRequest::default());
692        assert!(page.content.is_empty());
693        assert_eq!(page.total_elements, 0);
694    }
695}