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