Skip to main content

syncweb_core/
indexing.rs

1//! Opt-in local indexing for synchronized folders.
2//!
3//! The indexer deliberately owns its database and event consumers. The core
4//! synchronization path does not depend on the index being enabled or healthy.
5
6pub mod catalog;
7pub mod denylist;
8pub mod links;
9pub use catalog::{Catalog, CatalogMetadata, CatalogRecord, CatalogService};
10pub use denylist::{Denied, DenyReason, Denylist, DenylistRule, DenylistService, FilterContext, FilterList};
11pub use links::{
12    CapabilityLink, ContentLink, ImmutableLink, Link, LinkResolution, LinkResolver, LinkStore, Mirror, MutableLink,
13    MutablePointer, NameLink, PrivateLink, ProviderFetch, ResolveOptions, ResolvedLink, SignedMutablePointer,
14    collect_files, fetch_from_mirrors, hash_source,
15};
16
17use std::{
18    collections::HashMap,
19    path::{Path, PathBuf},
20    str::FromStr,
21    sync::{Arc, Mutex},
22    time::{SystemTime, UNIX_EPOCH},
23};
24
25use iroh_blobs::Hash;
26use iroh_docs::{Entry, NamespaceId, engine::LiveEvent};
27use n0_future::StreamExt;
28use rusqlite::{Connection, OptionalExtension, params};
29use tokio::{sync::broadcast, task::JoinHandle};
30
31use crate::{
32    error::{Result, SyncwebError},
33    folder::SyncwebFolder,
34};
35
36/// Current indexing database schema version.
37pub const SCHEMA_VERSION: &str = "1";
38const EVENT_CAPACITY: usize = 256;
39
40/// A content entry known to the local indexing service.
41#[derive(Clone, Debug, Eq, PartialEq)]
42#[non_exhaustive]
43pub struct IndexedEntry {
44    pub namespace_id: NamespaceId,
45    pub key: Vec<u8>,
46    pub hash: Hash,
47    pub size: u64,
48}
49
50/// Core synchronization events that do not add or remove an indexed entry.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52#[non_exhaustive]
53pub enum CoreIndexingEvent {
54    ContentReady { hash: Hash },
55    PendingContentReady,
56    NeighborUp,
57    NeighborDown,
58    SyncFinished,
59}
60
61/// Events emitted by an [`IndexingService`].
62#[derive(Clone, Debug, Eq, PartialEq)]
63#[non_exhaustive]
64pub enum IndexingEvent {
65    FolderEnabled {
66        namespace_id: NamespaceId,
67    },
68    FolderDisabled {
69        namespace_id: NamespaceId,
70    },
71    EntryIndexed(IndexedEntry),
72    Core {
73        namespace_id: NamespaceId,
74        event: CoreIndexingEvent,
75    },
76    Error {
77        namespace_id: NamespaceId,
78        message: String,
79    },
80}
81
82/// A folder opt-in returned by [`IndexingService::enable_folder`].
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84#[non_exhaustive]
85pub struct IndexingHandle {
86    namespace_id: NamespaceId,
87}
88
89impl IndexingHandle {
90    #[must_use]
91    pub const fn namespace_id(self) -> NamespaceId {
92        self.namespace_id
93    }
94}
95
96/// Thread-safe `SQLite` database used by the indexing service.
97#[derive(Clone)]
98pub struct IndexingDatabase {
99    connection: Arc<Mutex<Connection>>,
100    path: Arc<PathBuf>,
101}
102
103impl std::fmt::Debug for IndexingDatabase {
104    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        formatter
106            .debug_struct("IndexingDatabase")
107            .field("path", &self.path)
108            .finish_non_exhaustive()
109    }
110}
111
112impl IndexingDatabase {
113    /// Open or create an indexing database.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the database cannot be opened or initialized.
118    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
119        let database_path = path.as_ref().to_path_buf();
120        if database_path.as_os_str() != ":memory:"
121            && let Some(parent) = database_path.parent()
122            && !parent.as_os_str().is_empty()
123        {
124            std::fs::create_dir_all(parent)
125                .map_err(|error| SyncwebError::operation("failed to create indexing database directory", error))?;
126        }
127        let connection = Connection::open(&database_path)
128            .map_err(|error| SyncwebError::operation("failed to open indexing database", error))?;
129        initialize_connection(&connection)?;
130        Ok(Self {
131            connection: Arc::new(Mutex::new(connection)),
132            path: Arc::new(database_path),
133        })
134    }
135
136    /// Create an isolated in-memory indexing database.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if `SQLite` cannot initialize the schema.
141    pub fn in_memory() -> Result<Self> {
142        Self::open(":memory:")
143    }
144
145    #[must_use]
146    pub fn path(&self) -> &Path {
147        self.path.as_path()
148    }
149
150    /// Return the persisted indexing schema version.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if the schema metadata cannot be read.
155    pub fn schema_version(&self) -> Result<String> {
156        self.with_connection(|connection| {
157            connection
158                .query_row(
159                    "SELECT value FROM index_metadata WHERE key = 'schema_version'",
160                    [],
161                    |row| row.get(0),
162                )
163                .map_err(|error| database_error("failed to read indexing schema version", error))
164        })
165    }
166
167    /// Return whether the `SQLite` FTS5 virtual table is available.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if the schema cannot be queried.
172    pub fn has_fts5(&self) -> Result<bool> {
173        self.has_table("indexed_entries_fts")
174    }
175
176    /// Return the names of schema objects owned by the index.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if `SQLite` cannot query its schema.
181    pub fn schema_objects(&self) -> Result<Vec<String>> {
182        self.with_connection(|connection| {
183            let mut statement = connection
184                .prepare("SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY name")
185                .map_err(|error| database_error("failed to prepare schema query", error))?;
186            let names = statement
187                .query_map([], |row| row.get::<_, String>(0))
188                .map_err(|error| database_error("failed to query schema objects", error))?
189                .collect::<std::result::Result<Vec<_>, _>>()
190                .map_err(|error| database_error("failed to read schema objects", error))?;
191            Ok(names)
192        })
193    }
194
195    /// Return whether a schema table or view exists.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if `SQLite` cannot query its schema.
200    pub fn has_table(&self, name: &str) -> Result<bool> {
201        self.with_connection(|connection| {
202            let exists = connection
203                .query_row(
204                    "SELECT EXISTS(
205                        SELECT 1 FROM sqlite_master
206                        WHERE name = ?1 AND type IN ('table', 'view')
207                    )",
208                    [name],
209                    |row| row.get(0),
210                )
211                .map_err(|error| database_error("failed to query schema object", error))?;
212            Ok(exists)
213        })
214    }
215
216    /// Return the number of opted-in folders.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if `SQLite` cannot query the database.
221    pub fn folder_count(&self) -> Result<usize> {
222        self.with_connection(|connection| {
223            let count = connection
224                .query_row("SELECT COUNT(*) FROM indexed_folders", [], |row| row.get::<_, i64>(0))
225                .map_err(|error| database_error("failed to count indexed folders", error))?;
226            usize::try_from(count).map_err(|error| database_error("indexed folder count is invalid", error))
227        })
228    }
229
230    /// Return the number of indexed entries.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if `SQLite` cannot query the database.
235    pub fn entry_count(&self) -> Result<usize> {
236        self.with_connection(|connection| {
237            let count = connection
238                .query_row("SELECT COUNT(*) FROM indexed_entries", [], |row| row.get::<_, i64>(0))
239                .map_err(|error| database_error("failed to count indexed entries", error))?;
240            usize::try_from(count).map_err(|error| database_error("indexed entry count is invalid", error))
241        })
242    }
243
244    /// Return whether a namespace is currently opted into indexing.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if `SQLite` cannot query the database.
249    pub fn is_folder_enabled(&self, namespace_id: NamespaceId) -> Result<bool> {
250        self.with_connection(|connection| {
251            let enabled = connection
252                .query_row(
253                    "SELECT EXISTS(
254                        SELECT 1 FROM indexed_folders WHERE namespace_id = ?1
255                    )",
256                    [namespace_id.to_string()],
257                    |row| row.get(0),
258                )
259                .map_err(|error| database_error("failed to query indexed folder", error))?;
260            Ok(enabled)
261        })
262    }
263
264    /// Insert or update an entry in the index.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if the database cannot persist the entry.
269    pub fn upsert_entry(
270        &self,
271        namespace_id: NamespaceId,
272        key: impl AsRef<[u8]>,
273        hash: Hash,
274        size: u64,
275    ) -> Result<IndexedEntry> {
276        let key_bytes = key.as_ref().to_vec();
277        let namespace = namespace_id.to_string();
278        let hash_bytes = hash.as_bytes().to_vec();
279        let size_value =
280            i64::try_from(size).map_err(|error| database_error("indexed entry size is too large", error))?;
281        let key_text = String::from_utf8_lossy(&key_bytes).into_owned();
282        let title = Path::new(&key_text)
283            .file_name()
284            .and_then(|name| name.to_str())
285            .unwrap_or(&key_text)
286            .to_owned();
287        self.with_connection(|connection| {
288            let transaction = connection
289                .unchecked_transaction()
290                .map_err(|error| database_error("failed to begin indexing transaction", error))?;
291            let existing_id = transaction
292                .query_row(
293                    "SELECT id FROM indexed_entries WHERE namespace_id = ?1 AND entry_key = ?2",
294                    params![namespace, key_bytes],
295                    |row| row.get::<_, i64>(0),
296                )
297                .optional()
298                .map_err(|error| database_error("failed to find indexed entry", error))?;
299            let id = if let Some(id) = existing_id {
300                transaction
301                    .execute(
302                        "UPDATE indexed_entries
303                         SET content_hash = ?1, content_len = ?2, updated_at = ?3
304                         WHERE id = ?4",
305                        params![hash_bytes, size_value, now_seconds(), id],
306                    )
307                    .map_err(|error| database_error("failed to update indexed entry", error))?;
308                transaction
309                    .execute("DELETE FROM indexed_entries_fts WHERE rowid = ?1", [id])
310                    .map_err(|error| database_error("failed to refresh indexed search entry", error))?;
311                id
312            } else {
313                transaction
314                    .execute(
315                        "INSERT INTO indexed_entries
316                         (namespace_id, entry_key, content_hash, content_len, updated_at)
317                         VALUES (?1, ?2, ?3, ?4, ?5)",
318                        params![namespace, key_bytes, hash_bytes, size_value, now_seconds()],
319                    )
320                    .map_err(|error| database_error("failed to insert indexed entry", error))?;
321                transaction.last_insert_rowid()
322            };
323            transaction
324                .execute(
325                    "INSERT INTO indexed_entries_fts(rowid, namespace_id, entry_key, title, tags)
326                     VALUES (?1, ?2, ?3, ?4, ?5)",
327                    params![id, namespace, key_text, title, ""],
328                )
329                .map_err(|error| database_error("failed to insert indexed search entry", error))?;
330            transaction
331                .commit()
332                .map_err(|error| database_error("failed to commit indexed entry", error))?;
333            Ok(IndexedEntry {
334                namespace_id,
335                key: key_bytes,
336                hash,
337                size,
338            })
339        })
340    }
341
342    /// Remove an indexed entry if it exists.
343    ///
344    /// # Errors
345    ///
346    /// Returns an error if the database cannot remove the entry.
347    pub fn remove_entry(&self, namespace_id: NamespaceId, key: impl AsRef<[u8]>) -> Result<bool> {
348        self.with_connection(|connection| {
349            let transaction = connection
350                .unchecked_transaction()
351                .map_err(|error| database_error("failed to begin indexing transaction", error))?;
352            let id = transaction
353                .query_row(
354                    "SELECT id FROM indexed_entries WHERE namespace_id = ?1 AND entry_key = ?2",
355                    params![namespace_id.to_string(), key.as_ref()],
356                    |row| row.get::<_, i64>(0),
357                )
358                .optional()
359                .map_err(|error| database_error("failed to find indexed entry", error))?;
360            let Some(entry_id) = id else {
361                transaction
362                    .commit()
363                    .map_err(|error| database_error("failed to commit indexing transaction", error))?;
364                return Ok(false);
365            };
366            transaction
367                .execute("DELETE FROM indexed_entries_fts WHERE rowid = ?1", [entry_id])
368                .map_err(|error| database_error("failed to remove indexed search entry", error))?;
369            transaction
370                .execute("DELETE FROM indexed_entries WHERE id = ?1", [entry_id])
371                .map_err(|error| database_error("failed to remove indexed entry", error))?;
372            transaction
373                .commit()
374                .map_err(|error| database_error("failed to commit indexing transaction", error))?;
375            Ok(true)
376        })
377    }
378
379    /// Search indexed paths and metadata using `SQLite` FTS5.
380    ///
381    /// An empty query returns the most recently updated entries. FTS5 query
382    /// syntax is passed through for non-empty queries.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if the query is invalid or `SQLite` cannot read results.
387    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<IndexedEntry>> {
388        if limit == 0 {
389            return Ok(Vec::new());
390        }
391        let limit_value = i64::try_from(limit).map_err(|error| database_error("search limit is invalid", error))?;
392        self.with_connection(|connection| {
393            let mut output = Vec::new();
394            if query.trim().is_empty() {
395                let mut statement = connection
396                    .prepare(
397                        "SELECT namespace_id, entry_key, content_hash, content_len
398                         FROM indexed_entries ORDER BY updated_at DESC, id DESC LIMIT ?1",
399                    )
400                    .map_err(|error| database_error("failed to prepare index query", error))?;
401                let rows = statement
402                    .query_map([limit_value], indexed_entry_from_row)
403                    .map_err(|error| database_error("failed to query indexed entries", error))?;
404                for row in rows {
405                    output.push(row.map_err(|error| database_error("failed to read indexed entry", error))?);
406                }
407            } else {
408                let mut statement = connection
409                    .prepare(
410                        "SELECT e.namespace_id, e.entry_key, e.content_hash, e.content_len
411                         FROM indexed_entries_fts f
412                         JOIN indexed_entries e ON e.id = f.rowid
413                         WHERE indexed_entries_fts MATCH ?1
414                         ORDER BY bm25(indexed_entries_fts), e.id DESC
415                         LIMIT ?2",
416                    )
417                    .map_err(|error| database_error("failed to prepare full-text query", error))?;
418                let rows = statement
419                    .query_map(params![query, limit_value], indexed_entry_from_row)
420                    .map_err(|error| database_error("failed to query full-text index", error))?;
421                for row in rows {
422                    output.push(row.map_err(|error| database_error("failed to read full-text result", error))?);
423                }
424            }
425            Ok(output)
426        })
427    }
428
429    /// Register a catalog that is published or subscribed to locally.
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if the catalog metadata cannot be stored.
434    pub fn enable_catalog(&self, namespace_id: NamespaceId, label: &str) -> Result<()> {
435        self.with_connection(|connection| {
436            connection
437                .execute(
438                    "INSERT INTO indexed_catalogs(namespace_id, label, subscribed_at)
439                     VALUES (?1, ?2, ?3)
440                     ON CONFLICT(namespace_id) DO UPDATE SET label = excluded.label",
441                    params![namespace_id.to_string(), label, now_seconds()],
442                )
443                .map_err(|error| database_error("failed to enable indexed catalog", error))?;
444            Ok(())
445        })
446    }
447
448    /// Return the number of known catalogs.
449    ///
450    /// # Errors
451    ///
452    /// Returns an error if `SQLite` cannot query the database.
453    pub fn catalog_count(&self) -> Result<usize> {
454        self.with_connection(|connection| {
455            let count = connection
456                .query_row("SELECT COUNT(*) FROM indexed_catalogs", [], |row| row.get::<_, i64>(0))
457                .map_err(|error| database_error("failed to count indexed catalogs", error))?;
458            usize::try_from(count).map_err(|error| database_error("indexed catalog count is invalid", error))
459        })
460    }
461
462    /// Return all known catalogs with their namespace identifiers and labels.
463    ///
464    /// # Errors
465    ///
466    /// Returns an error if the catalog metadata cannot be read.
467    pub fn load_catalogs(&self) -> Result<Vec<(NamespaceId, String)>> {
468        use std::str::FromStr;
469        self.with_connection(|connection| {
470            let mut statement = connection
471                .prepare("SELECT namespace_id, label FROM indexed_catalogs ORDER BY subscribed_at DESC")
472                .map_err(|error| database_error("failed to prepare catalog listing", error))?;
473            let rows = statement
474                .query_map([], |row| {
475                    let namespace: String = row.get(0)?;
476                    let label: String = row.get(1)?;
477                    Ok((namespace, label))
478                })
479                .map_err(|error| database_error("failed to read indexed catalogs", error))?;
480            let mut catalogs = Vec::new();
481            for row in rows {
482                let (namespace, label) =
483                    row.map_err(|error| database_error("failed to read indexed catalog record", error))?;
484                let namespace_id = NamespaceId::from_str(&namespace)
485                    .map_err(|error| database_error("indexed catalog namespace is invalid", error))?;
486                catalogs.push((namespace_id, label));
487            }
488            Ok(catalogs)
489        })
490    }
491
492    /// Return the number of records imported from catalogs.
493    ///
494    /// # Errors
495    ///
496    /// Returns an error if `SQLite` cannot query the database.
497    pub fn catalog_entry_count(&self) -> Result<usize> {
498        self.with_connection(|connection| {
499            let count = connection
500                .query_row("SELECT COUNT(*) FROM indexed_catalog_entries", [], |row| {
501                    row.get::<_, i64>(0)
502                })
503                .map_err(|error| database_error("failed to count indexed catalog entries", error))?;
504            usize::try_from(count).map_err(|error| database_error("indexed catalog entry count is invalid", error))
505        })
506    }
507
508    /// Insert or update a record received from a catalog.
509    ///
510    /// # Errors
511    ///
512    /// Returns an error if the catalog is not registered or the record cannot
513    /// be persisted.
514    pub fn upsert_catalog_record(&self, record: &CatalogRecord) -> Result<CatalogRecord> {
515        record.validate()?;
516        let catalog_namespace = record.catalog_namespace_id.to_string();
517        let folder_namespace = record.folder_namespace_id.to_string();
518        let hash_bytes = record.hash.as_bytes().to_vec();
519        let size =
520            i64::try_from(record.size).map_err(|error| database_error("catalog record size is too large", error))?;
521        let tags = serde_json::to_string(&record.tags)
522            .map_err(|error| database_error("failed to serialize catalog record tags", error))?;
523        let key = record.key.clone();
524        let publisher = record.publisher.clone();
525        self.with_connection(|connection| {
526            let transaction = connection
527                .unchecked_transaction()
528                .map_err(|error| database_error("failed to begin catalog transaction", error))?;
529            let catalog_exists = transaction
530                .query_row(
531                    "SELECT EXISTS(
532                        SELECT 1 FROM indexed_catalogs WHERE namespace_id = ?1
533                    )",
534                    [&catalog_namespace],
535                    |row| row.get::<_, bool>(0),
536                )
537                .map_err(|error| database_error("failed to find indexed catalog", error))?;
538            if !catalog_exists {
539                return Err(SyncwebError::InvalidConfig(format!(
540                    "catalog is not enabled: {catalog_namespace}"
541                )));
542            }
543            let existing_id = transaction
544                .query_row(
545                    "SELECT id FROM indexed_catalog_entries
546                     WHERE catalog_namespace_id = ?1
547                       AND folder_namespace_id = ?2
548                       AND entry_key = ?3",
549                    params![catalog_namespace, folder_namespace, key],
550                    |row| row.get::<_, i64>(0),
551                )
552                .optional()
553                .map_err(|error| database_error("failed to find indexed catalog record", error))?;
554            let id = if let Some(id) = existing_id {
555                transaction
556                    .execute(
557                        "UPDATE indexed_catalog_entries
558                         SET content_hash = ?1, content_len = ?2, folder_name = ?3,
559                             title = ?4, tags = ?5, publisher = ?6, updated_at = ?7
560                         WHERE id = ?8",
561                        params![
562                            hash_bytes,
563                            size,
564                            record.folder_name,
565                            record.title,
566                            tags,
567                            publisher,
568                            now_seconds(),
569                            id
570                        ],
571                    )
572                    .map_err(|error| database_error("failed to update indexed catalog record", error))?;
573                transaction
574                    .execute("DELETE FROM indexed_catalog_entries_fts WHERE rowid = ?1", [id])
575                    .map_err(|error| database_error("failed to refresh catalog search record", error))?;
576                id
577            } else {
578                transaction
579                    .execute(
580                        "INSERT INTO indexed_catalog_entries
581                         (catalog_namespace_id, folder_namespace_id, entry_key, content_hash,
582                          content_len, folder_name, title, tags, publisher, updated_at)
583                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
584                        params![
585                            catalog_namespace,
586                            folder_namespace,
587                            key,
588                            hash_bytes,
589                            size,
590                            record.folder_name,
591                            record.title,
592                            tags,
593                            publisher,
594                            now_seconds()
595                        ],
596                    )
597                    .map_err(|error| database_error("failed to insert indexed catalog record", error))?;
598                transaction.last_insert_rowid()
599            };
600            transaction
601                .execute(
602                    "INSERT INTO indexed_catalog_entries_fts(
603                         rowid, catalog_namespace_id, folder_namespace_id, entry_key,
604                        folder_name, title, tags, publisher, content_hash
605                     )
606                    VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
607                    params![
608                        id,
609                        record.catalog_namespace_id.to_string(),
610                        record.folder_namespace_id.to_string(),
611                        String::from_utf8_lossy(&record.key),
612                        record.folder_name,
613                        record.title,
614                        tags,
615                        publisher,
616                        record.hash.to_string()
617                    ],
618                )
619                .map_err(|error| database_error("failed to insert catalog search record", error))?;
620            transaction
621                .commit()
622                .map_err(|error| database_error("failed to commit catalog record", error))?;
623            Ok(record.clone())
624        })
625    }
626
627    /// Search all subscribed catalogs with `SQLite` FTS5.
628    ///
629    /// An empty query returns the most recently updated catalog records.
630    ///
631    /// # Errors
632    ///
633    /// Returns an error if the query is invalid or `SQLite` cannot read results.
634    pub fn search_catalogs(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
635        if limit == 0 {
636            return Ok(Vec::new());
637        }
638        let limit_value =
639            i64::try_from(limit).map_err(|error| database_error("catalog search limit is invalid", error))?;
640        self.with_connection(|connection| {
641            let sql = if query.trim().is_empty() {
642                "SELECT catalog_namespace_id, folder_namespace_id, entry_key, content_hash,
643                        content_len, folder_name, title, tags, publisher
644                 FROM indexed_catalog_entries
645                 ORDER BY updated_at DESC, id DESC LIMIT ?1"
646            } else {
647                "SELECT e.catalog_namespace_id, e.folder_namespace_id, e.entry_key, e.content_hash,
648                        e.content_len, e.folder_name, e.title, e.tags, e.publisher
649                 FROM indexed_catalog_entries_fts f
650                 JOIN indexed_catalog_entries e ON e.id = f.rowid
651                 WHERE indexed_catalog_entries_fts MATCH ?1
652                 ORDER BY bm25(indexed_catalog_entries_fts), e.id DESC
653                 LIMIT ?2"
654            };
655            let mut statement = connection
656                .prepare(sql)
657                .map_err(|error| database_error("failed to prepare catalog search", error))?;
658            let rows = if query.trim().is_empty() {
659                statement
660                    .query_map([rusqlite::types::Value::Integer(limit_value)], catalog_record_from_row)
661                    .map_err(|error| database_error("failed to query catalog records", error))?
662            } else {
663                statement
664                    .query_map(params![query, limit_value], catalog_record_from_row)
665                    .map_err(|error| database_error("failed to query catalog records", error))?
666            };
667            rows.collect::<std::result::Result<Vec<_>, _>>()
668                .map_err(|error| database_error("failed to read catalog records", error))
669        })
670    }
671
672    /// Remove a catalog and all records imported from it.
673    ///
674    /// # Errors
675    ///
676    /// Returns an error if the catalog cannot be removed.
677    pub fn disable_catalog(&self, namespace_id: NamespaceId) -> Result<()> {
678        self.with_connection(|connection| {
679            let transaction = connection
680                .unchecked_transaction()
681                .map_err(|error| database_error("failed to begin catalog removal", error))?;
682            let namespace = namespace_id.to_string();
683            let mut statement = transaction
684                .prepare("SELECT id FROM indexed_catalog_entries WHERE catalog_namespace_id = ?1")
685                .map_err(|error| database_error("failed to find catalog records", error))?;
686            let ids = statement
687                .query_map([namespace.as_str()], |row| row.get::<_, i64>(0))
688                .map_err(|error| database_error("failed to list catalog records", error))?
689                .collect::<std::result::Result<Vec<_>, _>>()
690                .map_err(|error| database_error("failed to read catalog record IDs", error))?;
691            drop(statement);
692            for id in ids {
693                transaction
694                    .execute("DELETE FROM indexed_catalog_entries_fts WHERE rowid = ?1", [id])
695                    .map_err(|error| database_error("failed to remove catalog search record", error))?;
696            }
697            transaction
698                .execute(
699                    "DELETE FROM indexed_catalog_entries WHERE catalog_namespace_id = ?1",
700                    [namespace.as_str()],
701                )
702                .map_err(|error| database_error("failed to remove catalog records", error))?;
703            transaction
704                .execute(
705                    "DELETE FROM indexed_catalogs WHERE namespace_id = ?1",
706                    [namespace.as_str()],
707                )
708                .map_err(|error| database_error("failed to disable indexed catalog", error))?;
709            transaction
710                .commit()
711                .map_err(|error| database_error("failed to commit catalog removal", error))?;
712            Ok(())
713        })
714    }
715
716    /// Persist the mapping from a channel name to its catalog namespace.
717    ///
718    /// # Errors
719    ///
720    /// Returns an error if the mapping cannot be stored.
721    pub fn set_channel_namespace(&self, channel_name: &str, namespace_id: NamespaceId) -> Result<()> {
722        self.with_connection(|connection| {
723            connection
724                .execute(
725                    "INSERT INTO channel_namespaces(channel_name, catalog_namespace_id, created_at)
726                     VALUES (?1, ?2, ?3)
727                     ON CONFLICT(channel_name) DO UPDATE SET catalog_namespace_id = excluded.catalog_namespace_id",
728                    params![channel_name, namespace_id.to_string(), now_seconds()],
729                )
730                .map_err(|error| database_error("failed to store channel namespace", error))?;
731            Ok(())
732        })
733    }
734
735    /// Look up the catalog namespace for a channel name.
736    ///
737    /// # Errors
738    ///
739    /// Returns an error if the database cannot be queried.
740    pub fn get_channel_namespace(&self, channel_name: &str) -> Result<Option<NamespaceId>> {
741        use std::str::FromStr;
742        self.with_connection(|connection| {
743            let result = connection
744                .query_row(
745                    "SELECT catalog_namespace_id FROM channel_namespaces WHERE channel_name = ?1",
746                    [channel_name],
747                    |row| row.get::<_, String>(0),
748                )
749                .optional()
750                .map_err(|error| database_error("failed to look up channel namespace", error))?;
751            match result {
752                Some(ns) => {
753                    let namespace_id = NamespaceId::from_str(&ns)
754                        .map_err(|error| database_error("channel namespace is invalid", error))?;
755                    Ok(Some(namespace_id))
756                }
757                None => Ok(None),
758            }
759        })
760    }
761
762    /// Return all channel-to-namespace mappings.
763    ///
764    /// # Errors
765    ///
766    /// Returns an error if the database cannot be queried.
767    pub fn load_channel_namespaces(&self) -> Result<Vec<(String, NamespaceId)>> {
768        use std::str::FromStr;
769        self.with_connection(|connection| {
770            let mut statement = connection
771                .prepare("SELECT channel_name, catalog_namespace_id FROM channel_namespaces")
772                .map_err(|error| database_error("failed to prepare channel listing", error))?;
773            let rows = statement
774                .query_map([], |row| {
775                    let name: String = row.get(0)?;
776                    let namespace: String = row.get(1)?;
777                    Ok((name, namespace))
778                })
779                .map_err(|error| database_error("failed to read channel namespaces", error))?;
780            let mut channels = Vec::new();
781            for row in rows {
782                let (name, namespace) =
783                    row.map_err(|error| database_error("failed to read channel namespace row", error))?;
784                let namespace_id = NamespaceId::from_str(&namespace)
785                    .map_err(|error| database_error("channel namespace is invalid", error))?;
786                channels.push((name, namespace_id));
787            }
788            Ok(channels)
789        })
790    }
791
792    /// Register a namespace for local indexing.
793    ///
794    /// # Errors
795    ///
796    /// Returns an error if the folder metadata cannot be stored.
797    pub fn enable_folder(&self, namespace_id: NamespaceId, label: &str) -> Result<()> {
798        self.with_connection(|connection| {
799            connection
800                .execute(
801                    "INSERT INTO indexed_folders(namespace_id, label, enabled_at)
802                     VALUES (?1, ?2, ?3)
803                     ON CONFLICT(namespace_id) DO UPDATE SET label = excluded.label",
804                    params![namespace_id.to_string(), label, now_seconds()],
805                )
806                .map_err(|error| database_error("failed to enable indexed folder", error))?;
807            Ok(())
808        })
809    }
810
811    /// Remove a namespace and its indexed entries.
812    ///
813    /// # Errors
814    ///
815    /// Returns an error if the folder metadata or entries cannot be removed.
816    pub fn disable_folder(&self, namespace_id: NamespaceId) -> Result<()> {
817        self.with_connection(|connection| {
818            let transaction = connection
819                .unchecked_transaction()
820                .map_err(|error| database_error("failed to begin folder removal", error))?;
821            let namespace = namespace_id.to_string();
822            let mut statement = transaction
823                .prepare("SELECT id FROM indexed_entries WHERE namespace_id = ?1")
824                .map_err(|error| database_error("failed to find folder entries", error))?;
825            let ids = statement
826                .query_map([namespace.as_str()], |row| row.get::<_, i64>(0))
827                .map_err(|error| database_error("failed to list folder entries", error))?
828                .collect::<std::result::Result<Vec<_>, _>>()
829                .map_err(|error| database_error("failed to read folder entries", error))?;
830            drop(statement);
831            for id in ids {
832                transaction
833                    .execute("DELETE FROM indexed_entries_fts WHERE rowid = ?1", [id])
834                    .map_err(|error| database_error("failed to remove folder search entry", error))?;
835            }
836            transaction
837                .execute(
838                    "DELETE FROM indexed_entries WHERE namespace_id = ?1",
839                    [namespace.as_str()],
840                )
841                .map_err(|error| database_error("failed to remove folder entries", error))?;
842            transaction
843                .execute(
844                    "DELETE FROM indexed_folders WHERE namespace_id = ?1",
845                    [namespace.as_str()],
846                )
847                .map_err(|error| database_error("failed to disable indexed folder", error))?;
848            transaction
849                .commit()
850                .map_err(|error| database_error("failed to commit folder removal", error))?;
851            Ok(())
852        })
853    }
854
855    fn with_connection<T>(&self, operation: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> {
856        let mut connection = self
857            .connection
858            .lock()
859            .map_err(|error| SyncwebError::operation("indexing database lock poisoned", error))?;
860        operation(&mut connection)
861    }
862
863    /// Persist denylist rules to the database.
864    ///
865    /// # Errors
866    ///
867    /// Returns an error if the rules cannot be saved.
868    pub fn save_denylist_rules(&self, rules: &[DenylistRule]) -> Result<()> {
869        self.with_connection(|connection| {
870            connection
871                .execute("DELETE FROM denylist_rules", [])
872                .map_err(|error| database_error("failed to clear denylist rules", error))?;
873            let now = now_seconds();
874            for rule in rules {
875                let (rule_type, rule_value, namespace_id) = match rule {
876                    DenylistRule::Device(d) => ("device", d.as_bytes().to_vec(), None::<String>),
877                    DenylistRule::Hash(h) => ("hash", h.as_bytes().to_vec(), None),
878                    DenylistRule::File { namespace_id: ns, key } => ("file", key.clone(), ns.map(|n| n.to_string())),
879                };
880                connection
881                    .execute(
882                        "INSERT OR REPLACE INTO denylist_rules(rule_type, rule_value, namespace_id, updated_at)
883                     VALUES (?1, ?2, ?3, ?4)",
884                        params![rule_type, rule_value, namespace_id, now],
885                    )
886                    .map_err(|error| database_error("failed to save denylist rule", error))?;
887            }
888            Ok(())
889        })
890    }
891
892    /// Load persisted denylist rules from the database.
893    ///
894    /// # Errors
895    ///
896    /// Returns an error if the rules cannot be read.
897    pub fn load_denylist_rules(&self) -> Result<Vec<DenylistRule>> {
898        self.with_connection(|connection| {
899            let mut stmt = connection
900                .prepare("SELECT rule_type, rule_value, namespace_id FROM denylist_rules ORDER BY updated_at")
901                .map_err(|error| database_error("failed to prepare denylist query", error))?;
902            let rules = stmt
903                .query_map([], |row| {
904                    let rule_type: String = row.get(0)?;
905                    let rule_value: Vec<u8> = row.get(1)?;
906                    let namespace_id: Option<String> = row.get(2)?;
907                    match rule_type.as_str() {
908                        "device" => Ok(DenylistRule::Device(String::from_utf8_lossy(&rule_value).to_string())),
909                        "hash" => {
910                            let arr: [u8; 32] = rule_value.try_into().map_err(|error: Vec<u8>| {
911                                rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
912                                    "invalid hash length",
913                                    format!("expected 32 bytes, got {}", error.len()),
914                                )))
915                            })?;
916                            Ok(DenylistRule::Hash(Hash::from(arr)))
917                        }
918                        "file" => {
919                            let ns = namespace_id.and_then(|s| s.parse::<NamespaceId>().ok());
920                            Ok(DenylistRule::File {
921                                namespace_id: ns,
922                                key: rule_value,
923                            })
924                        }
925                        _ => Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
926                            SyncwebError::operation("unknown rule type", rule_type),
927                        ))),
928                    }
929                })
930                .map_err(|error| database_error("failed to query denylist rules", error))?
931                .collect::<std::result::Result<Vec<_>, _>>()
932                .map_err(|error| database_error("failed to read denylist rules", error))?;
933            Ok(rules)
934        })
935    }
936
937    /// Load persisted filter list subscriptions from the database.
938    ///
939    /// # Errors
940    ///
941    /// Returns an error if the filter lists cannot be read.
942    pub fn load_filter_lists(&self) -> Result<Vec<denylist::FilterList>> {
943        self.with_connection(|connection| {
944            let mut stmt = connection
945                .prepare("SELECT namespace_id, sequence, publisher, payload, updated_at FROM filter_lists ORDER BY updated_at")
946                .map_err(|error| database_error("failed to prepare filter lists query", error))?;
947            let lists = stmt
948                .query_map([], |row| {
949                    let payload: Vec<u8> = row.get(3)?;
950                    let list: denylist::FilterList = serde_json::from_slice(&payload).map_err(|error| {
951                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
952                            "failed to deserialize filter list",
953                            error,
954                        )))
955                    })?;
956                    Ok(list)
957                })
958                .map_err(|error| database_error("failed to query filter lists", error))?
959                .collect::<std::result::Result<Vec<_>, _>>()
960                .map_err(|error| database_error("failed to read filter list rows", error))?;
961            Ok(lists)
962        })
963    }
964
965    /// Upsert a filter list subscription.
966    ///
967    /// # Errors
968    ///
969    /// Returns an error if the filter list cannot be persisted.
970    pub fn upsert_filter_list(&self, list: &denylist::FilterList) -> Result<()> {
971        let payload = list.to_bytes()?;
972        self.with_connection(|connection| {
973            connection
974                .execute(
975                    "INSERT OR REPLACE INTO filter_lists(namespace_id, sequence, publisher, payload, updated_at)
976                 VALUES (?1, ?2, ?3, ?4, ?5)",
977                    params![
978                        list.namespace_id.to_string(),
979                        i64::try_from(list.sequence).unwrap_or(i64::MAX),
980                        list.publisher,
981                        payload,
982                        now_seconds(),
983                    ],
984                )
985                .map_err(|error| database_error("failed to upsert filter list", error))?;
986            Ok(())
987        })
988    }
989
990    /// Load stable links from the database.
991    ///
992    /// # Errors
993    ///
994    /// Returns an error if the links cannot be read.
995    pub fn load_links(&self) -> Result<(Vec<links::MutablePointer>, Vec<String>, Vec<links::PrivateLink>)> {
996        self.with_connection(|connection| {
997            let mut stmt = connection
998                .prepare("SELECT link, kind, payload, updated_at FROM stable_links ORDER BY updated_at")
999                .map_err(|error| database_error("failed to prepare links query", error))?;
1000            let mut pointers = Vec::new();
1001            let rows = stmt
1002                .query_map([], |row| {
1003                    let kind: String = row.get(1)?;
1004                    let payload: Vec<u8> = row.get(2)?;
1005                    Ok((kind, payload))
1006                })
1007                .map_err(|error| database_error("failed to query stable links", error))?
1008                .collect::<std::result::Result<Vec<_>, _>>()
1009                .map_err(|error| database_error("failed to read link rows", error))?;
1010            for (kind, payload) in rows {
1011                if kind.as_str() == "mutable"
1012                    && let Ok(p) = serde_json::from_slice::<links::MutablePointer>(&payload)
1013                {
1014                    pointers.push(p);
1015                }
1016            }
1017
1018            let mut mirror_stmt = connection
1019                .prepare("SELECT ticket FROM link_mirrors ORDER BY priority")
1020                .map_err(|error| database_error("failed to prepare mirror query", error))?;
1021            let mirrors = mirror_stmt
1022                .query_map([], |row| row.get::<_, String>(0))
1023                .map_err(|error| database_error("failed to query mirrors", error))?
1024                .collect::<std::result::Result<Vec<_>, _>>()
1025                .map_err(|error| database_error("failed to read mirror rows", error))?;
1026
1027            let mut revoked = Vec::new();
1028            if let Ok(mut rev_stmt) = connection.prepare("SELECT manifest, capability, revoked_at FROM revoked_links")
1029                && let Ok(rev_rows) = rev_stmt.query_map([], |row| {
1030                    let manifest_bytes: Vec<u8> = row.get(0)?;
1031                    let capability_blob: Vec<u8> = row.get(1)?;
1032                    if manifest_bytes.len() != 32 {
1033                        return Err(rusqlite::Error::InvalidColumnType(
1034                            0,
1035                            "manifest must be 32 bytes".to_owned(),
1036                            rusqlite::types::Type::Blob,
1037                        ));
1038                    }
1039                    if capability_blob.len() != 32 {
1040                        return Err(rusqlite::Error::InvalidColumnType(
1041                            1,
1042                            "capability must be 32 bytes".to_owned(),
1043                            rusqlite::types::Type::Blob,
1044                        ));
1045                    }
1046                    let Ok(manifests) = manifest_bytes.try_into() else {
1047                        return Err(rusqlite::Error::InvalidColumnType(
1048                            0,
1049                            "manifest conversion failed despite length check".to_owned(),
1050                            rusqlite::types::Type::Blob,
1051                        ));
1052                    };
1053                    let hash = Hash::from_bytes(manifests);
1054                    let Ok(cap) = capability_blob.try_into() else {
1055                        return Err(rusqlite::Error::InvalidColumnType(
1056                            1,
1057                            "capability conversion failed despite length check".to_owned(),
1058                            rusqlite::types::Type::Blob,
1059                        ));
1060                    };
1061                    Ok((hash, cap))
1062                })
1063            {
1064                for row in rev_rows.flatten() {
1065                    if let Ok(link) = PrivateLink::new(row.0, row.1, u64::MAX) {
1066                        revoked.push(link);
1067                    }
1068                }
1069            }
1070            Ok((pointers, mirrors, revoked))
1071        })
1072    }
1073
1074    /// Save stable links to the database.
1075    ///
1076    /// # Errors
1077    ///
1078    /// Returns an error if the links cannot be persisted.
1079    pub fn save_links(
1080        &self,
1081        pointers: &[links::MutablePointer],
1082        mirrors: &[String],
1083        revoked: &[links::PrivateLink],
1084    ) -> Result<()> {
1085        self.with_connection(|connection| {
1086            connection.execute("DELETE FROM stable_links", [])
1087                .map_err(|error| database_error("failed to clear stable links", error))?;
1088            connection.execute("DELETE FROM link_mirrors", [])
1089                .map_err(|error| database_error("failed to clear link mirrors", error))?;
1090            connection.execute("DELETE FROM revoked_links", [])
1091                .map_err(|error| database_error("failed to clear revoked links", error))?;
1092            let now = now_seconds();
1093            for pointer in pointers {
1094                let payload = serde_json::to_vec(pointer)
1095                    .map_err(|error| database_error("failed to serialize pointer", error))?;
1096                connection.execute(
1097                    "INSERT OR REPLACE INTO stable_links(link, kind, publisher, alias, content_hash, sequence, version, payload, updated_at)
1098                     VALUES (?1, 'mutable', ?2, ?3, NULL, ?4, ?5, ?6, ?7)",
1099                    params![
1100                        format!("mutable:{}:{}", pointer.publisher, pointer.alias),
1101                        pointer.publisher.to_string(),
1102                        pointer.alias,
1103                        i64::try_from(pointer.sequence).unwrap_or(i64::MAX),
1104                         pointer.version.as_deref(),
1105                        payload,
1106                        now,
1107                    ],
1108                ).map_err(|error| database_error("failed to save pointer", error))?;
1109            }
1110            for uri in mirrors {
1111                connection.execute(
1112                    "INSERT INTO link_mirrors(link, provider, ticket, priority, updated_at)
1113                     VALUES ('', 'local', ?1, 0, ?2)",
1114                    params![uri, now],
1115                ).map_err(|error| database_error("failed to save mirror", error))?;
1116            }
1117            for link in revoked {
1118                connection.execute(
1119                    "INSERT OR REPLACE INTO revoked_links(manifest, capability, revoked_at)
1120                     VALUES (?1, ?2, ?3)",
1121                    params![link.manifest.as_bytes(), link.capability.as_slice(), now],
1122                ).map_err(|error| database_error("failed to save revoked link", error))?;
1123            }
1124            Ok(())
1125        })
1126    }
1127
1128    /// Upsert a single mutable pointer to the stable links table.
1129    ///
1130    /// # Errors
1131    ///
1132    /// Returns an error if the pointer cannot be persisted.
1133    pub fn upsert_link_pointer(&self, pointer: &links::MutablePointer) -> Result<()> {
1134        let payload =
1135            serde_json::to_vec(pointer).map_err(|error| database_error("failed to serialize pointer", error))?;
1136        self.with_connection(|connection| {
1137            connection.execute(
1138                "INSERT OR REPLACE INTO stable_links(link, kind, publisher, alias, content_hash, sequence, version, payload, updated_at)
1139                 VALUES (?1, 'mutable', ?2, ?3, NULL, ?4, ?5, ?6, ?7)",
1140                params![
1141                    format!("mutable:{}:{}", pointer.publisher, pointer.alias),
1142                    pointer.publisher.to_string(),
1143                    pointer.alias,
1144                    i64::try_from(pointer.sequence).unwrap_or(i64::MAX),
1145                    pointer.version.as_deref(),
1146                    payload,
1147                    now_seconds(),
1148                ],
1149            )
1150            .map_err(|error| database_error("failed to upsert link pointer", error))?;
1151            Ok(())
1152        })
1153    }
1154
1155    /// Insert a single mirror ticket.
1156    ///
1157    /// # Errors
1158    ///
1159    /// Returns an error if the mirror cannot be persisted.
1160    pub fn upsert_link_mirror(&self, ticket: &str) -> Result<()> {
1161        self.with_connection(|connection| {
1162            connection
1163                .execute(
1164                    "INSERT OR REPLACE INTO link_mirrors(link, provider, ticket, priority, updated_at)
1165                 VALUES ('', 'local', ?1, 0, ?2)",
1166                    params![ticket, now_seconds()],
1167                )
1168                .map_err(|error| database_error("failed to upsert link mirror", error))?;
1169            Ok(())
1170        })
1171    }
1172}
1173
1174/// The opt-in indexing service for synchronized folders.
1175#[derive(Clone)]
1176pub struct IndexingService {
1177    database: IndexingDatabase,
1178    events: broadcast::Sender<IndexingEvent>,
1179    tasks: Arc<Mutex<HashMap<NamespaceId, JoinHandle<()>>>>,
1180    denylist: denylist::DenylistService,
1181}
1182
1183impl std::fmt::Debug for IndexingService {
1184    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1185        formatter
1186            .debug_struct("IndexingService")
1187            .field("database", &self.database)
1188            .finish_non_exhaustive()
1189    }
1190}
1191
1192impl IndexingService {
1193    /// Start an indexing service backed by `path`.
1194    ///
1195    /// # Errors
1196    ///
1197    /// Returns an error if the database cannot be opened or initialized.
1198    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
1199        Self::with_database(IndexingDatabase::open(path)?)
1200    }
1201
1202    /// Start an in-memory indexing service.
1203    ///
1204    /// # Errors
1205    ///
1206    /// Returns an error if `SQLite` cannot initialize the schema.
1207    pub fn in_memory() -> Result<Self> {
1208        Self::with_database(IndexingDatabase::in_memory()?)
1209    }
1210
1211    /// Start a service from an already opened database.
1212    ///
1213    /// # Errors
1214    ///
1215    /// Returns an error if the denylist rules cannot be loaded from the database.
1216    pub fn with_database(database: IndexingDatabase) -> Result<Self> {
1217        let (events, _) = broadcast::channel(EVENT_CAPACITY);
1218        let denylist = denylist::DenylistService::with_database(database.clone())?;
1219        Ok(Self {
1220            database,
1221            events,
1222            tasks: Arc::new(Mutex::new(HashMap::new())),
1223            denylist,
1224        })
1225    }
1226
1227    /// Create a catalog service backed by this indexing service.
1228    #[must_use]
1229    pub fn catalog_service(
1230        &self,
1231        docs: &crate::node::docs_engine::DocsEngine,
1232        blobs: &crate::node::blob_store::BlobStore,
1233        author: iroh_docs::AuthorId,
1234    ) -> CatalogService {
1235        CatalogService::new(self, docs, blobs, author)
1236    }
1237
1238    /// Return the thread-safe local denylist used by indexing hooks.
1239    #[must_use]
1240    pub fn denylist_service(&self) -> denylist::DenylistService {
1241        self.denylist.clone()
1242    }
1243
1244    #[must_use]
1245    pub const fn database(&self) -> &IndexingDatabase {
1246        &self.database
1247    }
1248
1249    /// Search entries in folders enabled for local indexing.
1250    ///
1251    /// # Errors
1252    ///
1253    /// Returns an error if the query is invalid or `SQLite` cannot read results.
1254    pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<IndexedEntry>> {
1255        self.database.search(query, limit)
1256    }
1257
1258    /// Search records imported from subscribed catalogs.
1259    ///
1260    /// # Errors
1261    ///
1262    /// Returns an error if the query is invalid or `SQLite` cannot read results.
1263    pub fn search_global(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
1264        self.database.search_catalogs(query, limit)
1265    }
1266
1267    /// Search records imported from subscribed catalogs.
1268    ///
1269    /// This is the service-level search used by `indexing search`. Records
1270    /// blocked by the local denylist are excluded from discovery.
1271    ///
1272    /// # Errors
1273    ///
1274    /// Returns an error if the query is invalid or `SQLite` cannot read results.
1275    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
1276        Ok(self
1277            .search_global(query, limit)?
1278            .into_iter()
1279            .filter(|record| self.denylist.check_discovery(record).is_ok())
1280            .collect())
1281    }
1282
1283    /// Subscribe to indexing and core-engine events.
1284    #[must_use]
1285    pub fn subscribe(&self) -> broadcast::Receiver<IndexingEvent> {
1286        self.events.subscribe()
1287    }
1288
1289    /// Opt a folder into indexing and begin consuming its document events.
1290    ///
1291    /// Existing document entries are indexed before the method returns.
1292    ///
1293    /// # Errors
1294    ///
1295    /// Returns an error if the folder cannot be registered, read, or watched.
1296    pub async fn enable_folder(&self, folder: &SyncwebFolder) -> Result<IndexingHandle> {
1297        let namespace_id = folder.namespace_id();
1298        if self
1299            .tasks
1300            .lock()
1301            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
1302            .get(&namespace_id)
1303            .is_some_and(|task| !task.is_finished())
1304        {
1305            return Ok(IndexingHandle { namespace_id });
1306        }
1307
1308        let live_events = folder.docs_engine().watch_and_sync(folder.doc()).await?;
1309        self.database.enable_folder(namespace_id, &namespace_id.to_string())?;
1310        send_event(&self.events, IndexingEvent::FolderEnabled { namespace_id });
1311
1312        for entry in folder.docs_engine().list_latest(folder.doc()).await? {
1313            let indexed = self.index_entry(namespace_id, &entry)?;
1314            send_event(&self.events, IndexingEvent::EntryIndexed(indexed));
1315        }
1316
1317        let database = self.database.clone();
1318        let events = self.events.clone();
1319        let task = tokio::spawn(async move {
1320            consume_folder_events(namespace_id, live_events, database, events).await;
1321        });
1322        self.tasks
1323            .lock()
1324            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
1325            .insert(namespace_id, task);
1326        Ok(IndexingHandle { namespace_id })
1327    }
1328
1329    /// Stop indexing a folder and remove its local index entries.
1330    ///
1331    /// # Errors
1332    ///
1333    /// Returns an error if the folder index cannot be removed.
1334    pub async fn disable_folder(&self, namespace_id: NamespaceId) -> Result<()> {
1335        let task_handle = self
1336            .tasks
1337            .lock()
1338            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
1339            .remove(&namespace_id);
1340        if let Some(task) = task_handle {
1341            task.abort();
1342        }
1343        let database = self.database.clone();
1344        tokio::task::spawn_blocking(move || database.disable_folder(namespace_id))
1345            .await
1346            .map_err(|error| SyncwebError::operation("indexing folder removal task failed", error))??;
1347        send_event(&self.events, IndexingEvent::FolderDisabled { namespace_id });
1348        Ok(())
1349    }
1350
1351    /// Return whether an indexing task is active for a folder.
1352    ///
1353    /// # Errors
1354    ///
1355    /// Returns an error if the task registry lock is poisoned.
1356    pub fn is_folder_enabled(&self, namespace_id: NamespaceId) -> Result<bool> {
1357        let active_task = self
1358            .tasks
1359            .lock()
1360            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
1361            .get(&namespace_id)
1362            .is_some_and(|task| !task.is_finished());
1363        Ok(active_task || self.database.is_folder_enabled(namespace_id)?)
1364    }
1365
1366    /// Index an entry supplied by another catalog or an application.
1367    ///
1368    /// This method does not require a folder task, but the namespace must
1369    /// already be enabled.
1370    ///
1371    /// # Errors
1372    ///
1373    /// Returns an error if the namespace is not enabled or the entry cannot be
1374    /// stored.
1375    pub fn index_entry(&self, namespace_id: NamespaceId, entry: &Entry) -> Result<IndexedEntry> {
1376        if !self.database.is_folder_enabled(namespace_id)? {
1377            return Err(SyncwebError::FolderNotFound(namespace_id.to_string()));
1378        }
1379        self.database
1380            .upsert_entry(namespace_id, entry.key(), entry.content_hash(), entry.content_len())
1381    }
1382}
1383
1384async fn consume_folder_events(
1385    namespace_id: NamespaceId,
1386    mut live_events: impl n0_future::Stream<Item = Result<LiveEvent>> + Send + Unpin + 'static,
1387    database: IndexingDatabase,
1388    events: broadcast::Sender<IndexingEvent>,
1389) {
1390    while let Some(event_result) = live_events.next().await {
1391        let event = match event_result {
1392            Ok(event) => event,
1393            Err(error) => {
1394                send_event(
1395                    &events,
1396                    IndexingEvent::Error {
1397                        namespace_id,
1398                        message: error.to_string(),
1399                    },
1400                );
1401                break;
1402            }
1403        };
1404        match event {
1405            LiveEvent::InsertLocal { entry } | LiveEvent::InsertRemote { entry, .. } => {
1406                match database.upsert_entry(namespace_id, entry.key(), entry.content_hash(), entry.content_len()) {
1407                    Ok(indexed) => send_event(&events, IndexingEvent::EntryIndexed(indexed)),
1408                    Err(error) => {
1409                        send_event(
1410                            &events,
1411                            IndexingEvent::Error {
1412                                namespace_id,
1413                                message: error.to_string(),
1414                            },
1415                        );
1416                    }
1417                }
1418            }
1419            LiveEvent::ContentReady { hash } => send_event(
1420                &events,
1421                IndexingEvent::Core {
1422                    namespace_id,
1423                    event: CoreIndexingEvent::ContentReady { hash },
1424                },
1425            ),
1426            LiveEvent::PendingContentReady => send_event(
1427                &events,
1428                IndexingEvent::Core {
1429                    namespace_id,
1430                    event: CoreIndexingEvent::PendingContentReady,
1431                },
1432            ),
1433            LiveEvent::NeighborUp(_) => send_event(
1434                &events,
1435                IndexingEvent::Core {
1436                    namespace_id,
1437                    event: CoreIndexingEvent::NeighborUp,
1438                },
1439            ),
1440            LiveEvent::NeighborDown(_) => send_event(
1441                &events,
1442                IndexingEvent::Core {
1443                    namespace_id,
1444                    event: CoreIndexingEvent::NeighborDown,
1445                },
1446            ),
1447            LiveEvent::SyncFinished(_) => send_event(
1448                &events,
1449                IndexingEvent::Core {
1450                    namespace_id,
1451                    event: CoreIndexingEvent::SyncFinished,
1452                },
1453            ),
1454        }
1455    }
1456}
1457
1458fn indexed_entry_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedEntry> {
1459    let namespace = row.get::<_, String>(0)?;
1460    let namespace_id = NamespaceId::from_str(&namespace).map_err(|error| {
1461        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
1462        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(conversion_error))
1463    })?;
1464    let key = row.get(1)?;
1465    let hash_bytes = row.get::<_, Vec<u8>>(2)?;
1466    let hash_array = <[u8; 32]>::try_from(hash_bytes.as_slice())
1467        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Blob, Box::new(error)))?;
1468    let size_value = row.get::<_, i64>(3)?;
1469    let size = u64::try_from(size_value).map_err(|error| {
1470        rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Integer, Box::new(error))
1471    })?;
1472    Ok(IndexedEntry {
1473        namespace_id,
1474        key,
1475        hash: Hash::from_bytes(hash_array),
1476        size,
1477    })
1478}
1479
1480fn catalog_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CatalogRecord> {
1481    let catalog_namespace = row.get::<_, String>(0)?;
1482    let catalog_namespace_id = NamespaceId::from_str(&catalog_namespace).map_err(|error| {
1483        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
1484        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(conversion_error))
1485    })?;
1486    let folder_namespace = row.get::<_, String>(1)?;
1487    let folder_namespace_id = NamespaceId::from_str(&folder_namespace).map_err(|error| {
1488        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
1489        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(conversion_error))
1490    })?;
1491    let hash_bytes = row.get::<_, Vec<u8>>(3)?;
1492    let hash_array = <[u8; 32]>::try_from(hash_bytes.as_slice())
1493        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Blob, Box::new(error)))?;
1494    let size_value = row.get::<_, i64>(4)?;
1495    let size = u64::try_from(size_value).map_err(|error| {
1496        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Integer, Box::new(error))
1497    })?;
1498    let tags_json = row.get::<_, String>(7)?;
1499    let tags = serde_json::from_str(&tags_json)
1500        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error)))?;
1501    Ok(CatalogRecord {
1502        catalog_namespace_id,
1503        folder_namespace_id,
1504        key: row.get(2)?,
1505        hash: Hash::from_bytes(hash_array),
1506        size,
1507        folder_name: row.get(5)?,
1508        title: row.get(6)?,
1509        tags,
1510        publisher: row.get(8)?,
1511    })
1512}
1513
1514const SCHEMA_PART1: &str = "PRAGMA journal_mode = WAL;
1515     CREATE TABLE IF NOT EXISTS index_metadata (
1516         key TEXT PRIMARY KEY NOT NULL,
1517         value TEXT NOT NULL
1518     );
1519     CREATE TABLE IF NOT EXISTS indexed_folders (
1520         namespace_id TEXT PRIMARY KEY NOT NULL,
1521         label TEXT NOT NULL,
1522         enabled_at INTEGER NOT NULL
1523     );
1524     CREATE TABLE IF NOT EXISTS indexed_entries (
1525         id INTEGER PRIMARY KEY,
1526         namespace_id TEXT NOT NULL REFERENCES indexed_folders(namespace_id) ON DELETE CASCADE,
1527         entry_key BLOB NOT NULL,
1528         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
1529         content_len INTEGER NOT NULL CHECK(content_len >= 0),
1530         updated_at INTEGER NOT NULL,
1531         UNIQUE(namespace_id, entry_key)
1532     );
1533     CREATE INDEX IF NOT EXISTS indexed_entries_namespace
1534         ON indexed_entries(namespace_id);
1535     CREATE VIRTUAL TABLE IF NOT EXISTS indexed_entries_fts USING fts5(
1536         namespace_id UNINDEXED,
1537         entry_key,
1538         title,
1539         tags,
1540         tokenize = 'unicode61'
1541     );
1542     CREATE TABLE IF NOT EXISTS indexed_catalogs (
1543         namespace_id TEXT PRIMARY KEY NOT NULL,
1544         label TEXT NOT NULL,
1545         subscribed_at INTEGER NOT NULL
1546     );
1547     CREATE TABLE IF NOT EXISTS indexed_catalog_entries (
1548         id INTEGER PRIMARY KEY,
1549         catalog_namespace_id TEXT NOT NULL REFERENCES indexed_catalogs(namespace_id) ON DELETE CASCADE,
1550         folder_namespace_id TEXT NOT NULL,
1551         entry_key BLOB NOT NULL,
1552         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
1553         content_len INTEGER NOT NULL CHECK(content_len >= 0),
1554         folder_name TEXT NOT NULL,
1555         title TEXT NOT NULL,
1556         tags TEXT NOT NULL,
1557         publisher TEXT NOT NULL,
1558         updated_at INTEGER NOT NULL,
1559         UNIQUE(catalog_namespace_id, folder_namespace_id, entry_key)
1560     );
1561     CREATE INDEX IF NOT EXISTS indexed_catalog_entries_catalog
1562         ON indexed_catalog_entries(catalog_namespace_id);
1563     CREATE VIRTUAL TABLE IF NOT EXISTS indexed_catalog_entries_fts USING fts5(
1564         catalog_namespace_id UNINDEXED,
1565         folder_namespace_id UNINDEXED,
1566         entry_key,
1567         folder_name,
1568         title,
1569         tags,
1570         publisher,
1571         content_hash,
1572          tokenize = 'unicode61'
1573      );";
1574
1575const SCHEMA_PART2: &str = "CREATE TABLE IF NOT EXISTS stable_links (
1576     link TEXT PRIMARY KEY NOT NULL,
1577     kind TEXT NOT NULL,
1578     publisher TEXT,
1579     alias TEXT,
1580     content_hash BLOB CHECK(content_hash IS NULL OR length(content_hash) = 32),
1581     sequence INTEGER NOT NULL CHECK(sequence >= 0),
1582     version TEXT,
1583     payload BLOB NOT NULL,
1584     updated_at INTEGER NOT NULL
1585 );
1586CREATE TABLE IF NOT EXISTS link_mirrors (
1587link TEXT NOT NULL,
1588provider TEXT NOT NULL,
1589ticket TEXT NOT NULL,
1590priority INTEGER NOT NULL DEFAULT 0,
1591updated_at INTEGER NOT NULL,
1592PRIMARY KEY(link, provider)
1593);
1594CREATE TABLE IF NOT EXISTS revoked_links (
1595     manifest BLOB NOT NULL CHECK(length(manifest) = 32),
1596     capability TEXT NOT NULL,
1597     revoked_at INTEGER NOT NULL,
1598     PRIMARY KEY(manifest, capability)
1599);
1600     CREATE TABLE IF NOT EXISTS denylist_rules (
1601         rule_type TEXT NOT NULL,
1602         rule_value BLOB NOT NULL,
1603         namespace_id TEXT,
1604         updated_at INTEGER NOT NULL,
1605         PRIMARY KEY(rule_type, rule_value, namespace_id)
1606     );
1607      CREATE TABLE IF NOT EXISTS filter_lists (
1608          namespace_id TEXT PRIMARY KEY NOT NULL,
1609          sequence INTEGER NOT NULL CHECK(sequence > 0),
1610          publisher TEXT NOT NULL,
1611          payload BLOB NOT NULL,
1612          updated_at INTEGER NOT NULL
1613      );";
1614
1615const SCHEMA_PART3: &str = "CREATE TABLE IF NOT EXISTS channel_namespaces (
1616     channel_name TEXT PRIMARY KEY NOT NULL,
1617     catalog_namespace_id TEXT NOT NULL,
1618     created_at INTEGER NOT NULL
1619 );";
1620
1621fn initialize_connection(connection: &Connection) -> Result<()> {
1622    connection
1623        .busy_timeout(std::time::Duration::from_secs(5))
1624        .map_err(|error| database_error("failed to configure indexing database", error))?;
1625    connection
1626        .pragma_update(None, "foreign_keys", "ON")
1627        .map_err(|error| database_error("failed to enable indexing foreign keys", error))?;
1628    initialize_schema(connection)?;
1629    connection
1630        .execute(
1631            "INSERT INTO index_metadata(key, value)
1632             VALUES ('schema_version', ?1)
1633             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1634            [SCHEMA_VERSION],
1635        )
1636        .map_err(|error| database_error("failed to persist indexing schema version", error))?;
1637    Ok(())
1638}
1639
1640fn initialize_schema(connection: &Connection) -> Result<()> {
1641    for part in [SCHEMA_PART1, SCHEMA_PART2, SCHEMA_PART3] {
1642        connection
1643            .execute_batch(part)
1644            .map_err(|error| database_error("failed to initialize indexing database schema", error))?;
1645    }
1646    Ok(())
1647}
1648
1649fn now_seconds() -> i64 {
1650    SystemTime::now()
1651        .duration_since(UNIX_EPOCH)
1652        .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX))
1653}
1654
1655fn database_error(context: &'static str, error: impl std::fmt::Display) -> SyncwebError {
1656    SyncwebError::operation(context, error)
1657}
1658
1659fn send_event(events: &broadcast::Sender<IndexingEvent>, event: IndexingEvent) {
1660    let _ = events.send(event);
1661}