Skip to main content

docbox_search/
lib.rs

1#![forbid(unsafe_code)]
2#![recursion_limit = "256"]
3
4use aws_config::SdkConfig;
5use chrono::Utc;
6use docbox_database::{
7    DatabasePoolCache, DbTransaction,
8    models::{
9        document_box::{DocumentBoxScopeRaw, DocumentBoxScopeRawRef},
10        file::FileId,
11        folder::FolderId,
12        tenant::Tenant,
13        tenant_migration::{CreateTenantMigration, TenantMigration},
14    },
15};
16use docbox_secrets::SecretManager;
17use models::{
18    FileSearchRequest, FileSearchResults, SearchIndexData, SearchRequest, SearchResults,
19    UpdateSearchIndexData,
20};
21use serde::{Deserialize, Serialize};
22use std::{ops::DerefMut, sync::Arc};
23use thiserror::Error;
24use uuid::Uuid;
25
26pub mod models;
27
28pub use database::{
29    DatabaseSearchConfig, DatabaseSearchError, DatabaseSearchIndex, DatabaseSearchIndexFactory,
30    DatabaseSearchIndexFactoryError,
31};
32pub use opensearch::{
33    OpenSearchConfig, OpenSearchIndex, OpenSearchIndexFactory, OpenSearchIndexFactoryError,
34    OpenSearchSearchError,
35};
36pub use typesense::{
37    TypesenseApiKey, TypesenseApiKeyProvider, TypesenseApiKeySecret, TypesenseIndex,
38    TypesenseIndexFactory, TypesenseIndexFactoryError, TypesenseSearchConfig, TypesenseSearchError,
39};
40
41mod database;
42mod opensearch;
43mod typesense;
44
45#[derive(Debug, Clone, Deserialize, Serialize)]
46#[serde(tag = "provider", rename_all = "snake_case")]
47pub enum SearchIndexFactoryConfig {
48    Typesense(typesense::TypesenseSearchConfig),
49    OpenSearch(opensearch::OpenSearchConfig),
50    Database(database::DatabaseSearchConfig),
51}
52
53#[derive(Debug, Error)]
54pub enum SearchIndexFactoryError {
55    #[error(transparent)]
56    Typesense(#[from] typesense::TypesenseIndexFactoryError),
57    #[error(transparent)]
58    OpenSearch(#[from] opensearch::OpenSearchIndexFactoryError),
59    #[error(transparent)]
60    Database(#[from] database::DatabaseSearchIndexFactoryError),
61    #[error("unknown search index factory type requested")]
62    UnknownIndexFactory,
63}
64
65impl SearchIndexFactoryConfig {
66    pub fn from_env() -> Result<Self, SearchIndexFactoryError> {
67        let variant = std::env::var("DOCBOX_SEARCH_INDEX_FACTORY")
68            .unwrap_or_else(|_| "database".to_string())
69            .to_lowercase();
70        match variant.as_str() {
71            "open_search" | "opensearch" => opensearch::OpenSearchConfig::from_env()
72                .map(Self::OpenSearch)
73                .map_err(SearchIndexFactoryError::OpenSearch),
74
75            "typesense" => typesense::TypesenseSearchConfig::from_env()
76                .map(Self::Typesense)
77                .map_err(SearchIndexFactoryError::Typesense),
78
79            "database" => database::DatabaseSearchConfig::from_env()
80                .map(Self::Database)
81                .map_err(SearchIndexFactoryError::Database),
82
83            // Unknown type requested
84            _ => Err(SearchIndexFactoryError::UnknownIndexFactory),
85        }
86    }
87}
88
89#[derive(Clone)]
90pub enum SearchIndexFactory {
91    Typesense(typesense::TypesenseIndexFactory),
92    OpenSearch(opensearch::OpenSearchIndexFactory),
93    Database(database::DatabaseSearchIndexFactory),
94}
95
96impl SearchIndexFactory {
97    /// Create a search index factory from the provided `config`
98    pub fn from_config(
99        aws_config: &SdkConfig,
100        secrets: SecretManager,
101        db: Arc<DatabasePoolCache>,
102        config: SearchIndexFactoryConfig,
103    ) -> Result<Self, SearchIndexFactoryError> {
104        match config {
105            SearchIndexFactoryConfig::Typesense(config) => {
106                tracing::debug!("using typesense search index");
107                typesense::TypesenseIndexFactory::from_config(secrets, config)
108                    .map(SearchIndexFactory::Typesense)
109                    .map_err(SearchIndexFactoryError::Typesense)
110            }
111
112            SearchIndexFactoryConfig::OpenSearch(config) => {
113                tracing::debug!("using opensearch search index");
114                opensearch::OpenSearchIndexFactory::from_config(aws_config, config)
115                    .map(SearchIndexFactory::OpenSearch)
116                    .map_err(SearchIndexFactoryError::OpenSearch)
117            }
118
119            SearchIndexFactoryConfig::Database(config) => {
120                tracing::debug!("using opensearch search index");
121                database::DatabaseSearchIndexFactory::from_config(db, config)
122                    .map(SearchIndexFactory::Database)
123                    .map_err(SearchIndexFactoryError::Database)
124            }
125        }
126    }
127
128    /// Create a new "OpenSearch" search index for the tenant
129    pub fn create_search_index(&self, tenant: &Tenant) -> TenantSearchIndex {
130        match self {
131            SearchIndexFactory::Typesense(factory) => {
132                let search_index = tenant.os_index_name.clone();
133                TenantSearchIndex::Typesense(factory.create_search_index(search_index))
134            }
135
136            SearchIndexFactory::OpenSearch(factory) => {
137                let search_index = opensearch::TenantSearchIndexName::from_tenant(tenant);
138                TenantSearchIndex::OpenSearch(factory.create_search_index(search_index))
139            }
140
141            SearchIndexFactory::Database(factory) => {
142                TenantSearchIndex::Database(factory.create_search_index(tenant))
143            }
144        }
145    }
146}
147
148#[derive(Clone)]
149pub enum TenantSearchIndex {
150    Typesense(typesense::TypesenseIndex),
151    OpenSearch(opensearch::OpenSearchIndex),
152    Database(database::DatabaseSearchIndex),
153}
154
155#[derive(Debug, Error)]
156pub enum SearchError {
157    #[error(transparent)]
158    Typesense(#[from] typesense::TypesenseSearchError),
159    #[error(transparent)]
160    OpenSearch(#[from] opensearch::OpenSearchSearchError),
161    #[error(transparent)]
162    Database(#[from] database::DatabaseSearchError),
163    #[error("failed to perform migration")]
164    Migration,
165}
166
167impl TenantSearchIndex {
168    /// Creates a search index for the tenant
169    #[tracing::instrument(skip(self))]
170    pub async fn create_index(&self) -> Result<(), SearchError> {
171        match self {
172            TenantSearchIndex::Typesense(index) => index.create_index().await,
173            TenantSearchIndex::OpenSearch(index) => index.create_index().await,
174            TenantSearchIndex::Database(index) => index.create_index().await,
175        }
176    }
177
178    /// Checks if the tenant search index exists
179    #[tracing::instrument(skip(self))]
180    pub async fn index_exists(&self) -> Result<bool, SearchError> {
181        match self {
182            TenantSearchIndex::Typesense(index) => index.index_exists().await,
183            TenantSearchIndex::OpenSearch(index) => index.index_exists().await,
184            TenantSearchIndex::Database(index) => index.index_exists().await,
185        }
186    }
187
188    /// Deletes the search index for the tenant
189    #[tracing::instrument(skip(self))]
190    pub async fn delete_index(&self) -> Result<(), SearchError> {
191        match self {
192            TenantSearchIndex::Typesense(index) => index.delete_index().await,
193            TenantSearchIndex::OpenSearch(index) => index.delete_index().await,
194            TenantSearchIndex::Database(index) => index.delete_index().await,
195        }
196    }
197
198    /// Searches the search index with the provided query
199    #[tracing::instrument(skip(self))]
200    pub async fn search_index(
201        &self,
202        scope: &[DocumentBoxScopeRaw],
203        query: SearchRequest,
204        folder_children: Option<Vec<FolderId>>,
205    ) -> Result<SearchResults, SearchError> {
206        match self {
207            TenantSearchIndex::Typesense(index) => {
208                index.search_index(scope, query, folder_children).await
209            }
210            TenantSearchIndex::OpenSearch(index) => {
211                index.search_index(scope, query, folder_children).await
212            }
213            TenantSearchIndex::Database(index) => {
214                index.search_index(scope, query, folder_children).await
215            }
216        }
217    }
218
219    /// Searches the index for matches scoped to a specific file
220    #[tracing::instrument(skip(self))]
221    pub async fn search_index_file(
222        &self,
223        scope: &DocumentBoxScopeRaw,
224        file_id: FileId,
225        query: FileSearchRequest,
226    ) -> Result<FileSearchResults, SearchError> {
227        match self {
228            TenantSearchIndex::Typesense(index) => {
229                index.search_index_file(scope, file_id, query).await
230            }
231            TenantSearchIndex::OpenSearch(index) => {
232                index.search_index_file(scope, file_id, query).await
233            }
234            TenantSearchIndex::Database(index) => {
235                index.search_index_file(scope, file_id, query).await
236            }
237        }
238    }
239
240    /// Adds the provided data to the search index
241    #[tracing::instrument(skip(self))]
242    pub async fn add_data(&self, data: Vec<SearchIndexData>) -> Result<(), SearchError> {
243        match self {
244            TenantSearchIndex::Typesense(index) => index.add_data(data).await,
245            TenantSearchIndex::OpenSearch(index) => index.add_data(data).await,
246            TenantSearchIndex::Database(index) => index.add_data(data).await,
247        }
248    }
249
250    /// Updates the provided data in the search index
251    #[tracing::instrument(skip(self))]
252    pub async fn update_data(
253        &self,
254        item_id: Uuid,
255        data: UpdateSearchIndexData,
256    ) -> Result<(), SearchError> {
257        match self {
258            TenantSearchIndex::Typesense(index) => index.update_data(item_id, data).await,
259            TenantSearchIndex::OpenSearch(index) => index.update_data(item_id, data).await,
260            TenantSearchIndex::Database(index) => index.update_data(item_id, data).await,
261        }
262    }
263
264    /// Deletes the provided data from the search index by `id`
265    #[tracing::instrument(skip(self))]
266    pub async fn delete_data(&self, id: Uuid) -> Result<(), SearchError> {
267        match self {
268            TenantSearchIndex::Typesense(index) => index.delete_data(id).await,
269            TenantSearchIndex::OpenSearch(index) => index.delete_data(id).await,
270            TenantSearchIndex::Database(index) => index.delete_data(id).await,
271        }
272    }
273
274    /// Deletes all data contained within the specified `scope`
275    #[tracing::instrument(skip(self))]
276    pub async fn delete_by_scope(
277        &self,
278        scope: DocumentBoxScopeRawRef<'_>,
279    ) -> Result<(), SearchError> {
280        match self {
281            TenantSearchIndex::Typesense(index) => index.delete_by_scope(scope).await,
282            TenantSearchIndex::OpenSearch(index) => index.delete_by_scope(scope).await,
283            TenantSearchIndex::Database(index) => index.delete_by_scope(scope).await,
284        }
285    }
286
287    /// Get all pending migrations based on the `applied_names` list of applied migrations
288    #[tracing::instrument(skip(self))]
289    pub async fn get_pending_migrations(
290        &self,
291        applied_names: Vec<String>,
292    ) -> Result<Vec<String>, SearchError> {
293        match self {
294            TenantSearchIndex::Typesense(index) => {
295                index.get_pending_migrations(applied_names).await
296            }
297            TenantSearchIndex::OpenSearch(index) => {
298                index.get_pending_migrations(applied_names).await
299            }
300            TenantSearchIndex::Database(index) => index.get_pending_migrations(applied_names).await,
301        }
302    }
303
304    /// Apply a specific migration for a `tenant` by `name`
305    #[tracing::instrument(skip(self))]
306    pub async fn apply_migration(
307        &self,
308        tenant: &Tenant,
309        root_t: &mut DbTransaction<'_>,
310        tenant_t: &mut DbTransaction<'_>,
311        name: &str,
312    ) -> Result<(), SearchError> {
313        // Apply migration logic
314        match self {
315            TenantSearchIndex::Typesense(index) => {
316                index
317                    .apply_migration(tenant, root_t, tenant_t, name)
318                    .await?
319            }
320
321            TenantSearchIndex::OpenSearch(index) => {
322                index
323                    .apply_migration(tenant, root_t, tenant_t, name)
324                    .await?
325            }
326
327            TenantSearchIndex::Database(index) => {
328                index
329                    .apply_migration(tenant, root_t, tenant_t, name)
330                    .await?
331            }
332        }
333
334        // Store the applied migration
335        TenantMigration::create(
336            root_t.deref_mut(),
337            CreateTenantMigration {
338                tenant_id: tenant.id,
339                env: tenant.env.clone(),
340                name: name.to_string(),
341                applied_at: Utc::now(),
342            },
343        )
344        .await
345        .map_err(|error| {
346            tracing::error!(?error, "failed to create tenant migration");
347            SearchError::Migration
348        })?;
349
350        Ok(())
351    }
352
353    /// Apply all pending migrations for a `tenant`
354    ///
355    /// When `target_migration_name` is specified only that target migration will
356    /// be run
357    #[tracing::instrument(skip_all, fields(?tenant, ?target_migration_name))]
358    pub async fn apply_migrations(
359        &self,
360        tenant: &Tenant,
361        root_t: &mut DbTransaction<'_>,
362        tenant_t: &mut DbTransaction<'_>,
363        target_migration_name: Option<&str>,
364    ) -> Result<(), SearchError> {
365        let applied_migrations =
366            TenantMigration::find_by_tenant(root_t.deref_mut(), tenant.id, &tenant.env)
367                .await
368                .map_err(|error| {
369                    tracing::error!(?error, "failed to query tenant migrations");
370                    SearchError::Migration
371                })?;
372        let pending_migrations = self
373            .get_pending_migrations(
374                applied_migrations
375                    .into_iter()
376                    .map(|value| value.name)
377                    .collect(),
378            )
379            .await?;
380
381        for migration_name in pending_migrations {
382            // If targeting a specific migration only apply the target one
383            if target_migration_name
384                .is_some_and(|target_migration_name| target_migration_name.ne(&migration_name))
385            {
386                continue;
387            }
388
389            // Apply the migration
390            if let Err(error) = self
391                .apply_migration(tenant, root_t, tenant_t, &migration_name)
392                .await
393            {
394                tracing::error!(%migration_name, ?error, "failed to apply migration");
395                return Err(error);
396            }
397        }
398
399        Ok(())
400    }
401}
402
403pub(crate) trait SearchIndex: Send + Sync + 'static {
404    async fn create_index(&self) -> Result<(), SearchError>;
405
406    async fn index_exists(&self) -> Result<bool, SearchError>;
407
408    async fn delete_index(&self) -> Result<(), SearchError>;
409
410    async fn search_index(
411        &self,
412        scope: &[DocumentBoxScopeRaw],
413        query: SearchRequest,
414        folder_children: Option<Vec<FolderId>>,
415    ) -> Result<SearchResults, SearchError>;
416
417    async fn search_index_file(
418        &self,
419        scope: &DocumentBoxScopeRaw,
420        file_id: FileId,
421        query: FileSearchRequest,
422    ) -> Result<FileSearchResults, SearchError>;
423
424    async fn add_data(&self, data: Vec<SearchIndexData>) -> Result<(), SearchError>;
425
426    async fn update_data(
427        &self,
428        item_id: Uuid,
429        data: UpdateSearchIndexData,
430    ) -> Result<(), SearchError>;
431
432    async fn delete_data(&self, id: Uuid) -> Result<(), SearchError>;
433
434    async fn delete_by_scope(&self, scope: DocumentBoxScopeRawRef<'_>) -> Result<(), SearchError>;
435
436    async fn get_pending_migrations(
437        &self,
438        applied_names: Vec<String>,
439    ) -> Result<Vec<String>, SearchError>;
440
441    async fn apply_migration(
442        &self,
443        tenant: &Tenant,
444        root_t: &mut DbTransaction<'_>,
445        t: &mut DbTransaction<'_>,
446        name: &str,
447    ) -> Result<(), SearchError>;
448}