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}