1pub 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
54pub const SCHEMA_VERSION: &str = "1";
56const EVENT_CAPACITY: usize = 256;
57
58#[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#[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#[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#[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#[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 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 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 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 pub fn has_fts5(&self) -> Result<bool> {
191 self.has_table("indexed_entries_fts")
192 }
193
194 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn global_search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
666 self.search_catalogs(query, limit)
667 }
668
669 pub fn catalog_search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
675 self.search_catalogs(query, limit)
676 }
677
678 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 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 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 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 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#[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 pub fn new(path: impl AsRef<Path>) -> Result<Self> {
914 Ok(Self::with_database(IndexingDatabase::open(path)?))
915 }
916
917 pub fn in_memory() -> Result<Self> {
923 Ok(Self::with_database(IndexingDatabase::in_memory()?))
924 }
925
926 #[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 #[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 #[must_use]
951 pub fn resilience_service(&self, config: resilience::ResilienceConfig) -> resilience::ResilienceService {
952 resilience::ResilienceService::new(config)
953 }
954
955 #[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 #[must_use]
967 pub fn wot_service(&self, policy: wot::TrustPolicy) -> wot::WotService {
968 wot::WotService::new(self, policy)
969 }
970
971 #[must_use]
973 pub fn denylist_service(&self) -> denylist::DenylistService {
974 self.denylist.clone()
975 }
976
977 #[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 pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<IndexedEntry>> {
999 self.database.search(query, limit)
1000 }
1001
1002 pub fn search_global(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
1008 self.database.search_catalogs(query, limit)
1009 }
1010
1011 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
1019 self.search_global(query, limit)
1020 }
1021
1022 #[must_use]
1024 pub fn subscribe(&self) -> broadcast::Receiver<IndexingEvent> {
1025 self.events.subscribe()
1026 }
1027
1028 #[must_use]
1030 pub fn subscribe_events(&self) -> broadcast::Receiver<IndexingEvent> {
1031 self.subscribe()
1032 }
1033
1034 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 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 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 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}