1use std::{
11 collections::HashMap,
12 path::Path,
13 sync::{Arc, Mutex},
14};
15
16use iroh_blobs::Hash;
17use iroh_docs::{AuthorId, DocTicket, Entry, NamespaceId, api::Doc, engine::LiveEvent};
18use n0_future::StreamExt;
19use serde::{Deserialize, Serialize};
20use tokio::task::JoinHandle;
21
22use crate::{
23 error::{Result, SyncwebError},
24 folder::SyncwebFolder,
25 indexing::{IndexingDatabase, IndexingService},
26 node::{blob_store::BlobStore, docs_engine::DocsEngine},
27};
28const CATALOG_RECORD_PREFIX: &[u8] = b"record/";
29const RETRY_COUNT: usize = 20;
30const MAX_CONSECUTIVE_STREAM_ERRORS: usize = 5;
31const STREAM_RETRY_BASE_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
32const STREAM_RETRY_MAX_DELAY: std::time::Duration = std::time::Duration::from_mins(1);
33
34#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
36#[non_exhaustive]
37pub struct CatalogMetadata {
38 pub name: String,
39 pub description: Option<String>,
40 pub publisher: String,
41}
42
43impl CatalogMetadata {
44 #[must_use]
46 pub fn new(name: impl Into<String>, publisher: impl Into<String>) -> Self {
47 Self {
48 name: name.into(),
49 description: None,
50 publisher: publisher.into(),
51 }
52 }
53
54 pub fn validate(&self) -> Result<()> {
58 if self.name.trim().is_empty() {
59 return Err(SyncwebError::InvalidConfig("catalog name cannot be empty".to_owned()));
60 }
61 Ok(())
62 }
63
64 pub fn to_bytes(&self) -> Result<Vec<u8>> {
68 self.validate()?;
69 serde_json::to_vec(self).map_err(|error| SyncwebError::operation("failed to serialize catalog metadata", error))
70 }
71
72 pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
76 let metadata: Self = serde_json::from_slice(bytes.as_ref())
77 .map_err(|error| SyncwebError::operation("failed to deserialize catalog metadata", error))?;
78 metadata.validate()?;
79 Ok(metadata)
80 }
81}
82
83#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85#[non_exhaustive]
86pub struct CatalogRecord {
87 #[serde(default = "empty_namespace")]
90 pub catalog_namespace_id: NamespaceId,
91 pub folder_namespace_id: NamespaceId,
92 pub key: Vec<u8>,
93 pub hash: Hash,
94 pub size: u64,
95 pub folder_name: String,
96 pub title: String,
97 pub tags: Vec<String>,
98 pub publisher: String,
99}
100
101impl CatalogRecord {
102 #[must_use]
104 pub fn new(folder_namespace_id: NamespaceId, key: impl AsRef<[u8]>, hash: Hash, size: u64) -> Self {
105 let key_bytes = key.as_ref().to_vec();
106 let key_text = String::from_utf8_lossy(&key_bytes);
107 let title = Path::new(key_text.as_ref())
108 .file_name()
109 .and_then(|name| name.to_str())
110 .unwrap_or_else(|| key_text.as_ref())
111 .to_owned();
112 Self {
113 catalog_namespace_id: empty_namespace(),
114 folder_namespace_id,
115 key: key_bytes,
116 hash,
117 size,
118 folder_name: folder_namespace_id.to_string(),
119 title,
120 tags: Vec::new(),
121 publisher: String::new(),
122 }
123 }
124
125 #[must_use]
127 pub const fn in_catalog(mut self, namespace_id: NamespaceId) -> Self {
128 self.catalog_namespace_id = namespace_id;
129 self
130 }
131
132 pub fn validate(&self) -> Result<()> {
136 if self.folder_name.trim().is_empty() {
137 return Err(SyncwebError::InvalidConfig(
138 "catalog record folder name cannot be empty".to_owned(),
139 ));
140 }
141 if self.title.trim().is_empty() {
142 return Err(SyncwebError::InvalidConfig(
143 "catalog record title cannot be empty".to_owned(),
144 ));
145 }
146 Ok(())
147 }
148
149 pub fn to_bytes(&self) -> Result<Vec<u8>> {
153 self.validate()?;
154 serde_json::to_vec(self).map_err(|error| SyncwebError::operation("failed to serialize catalog record", error))
155 }
156
157 pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
161 let record: Self = serde_json::from_slice(bytes.as_ref())
162 .map_err(|error| SyncwebError::operation("failed to deserialize catalog record", error))?;
163 record.validate()?;
164 Ok(record)
165 }
166}
167
168#[derive(Clone)]
170pub struct Catalog {
171 doc: Doc,
172 namespace_id: NamespaceId,
173 name: String,
174}
175
176impl std::fmt::Debug for Catalog {
177 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 formatter
179 .debug_struct("Catalog")
180 .field("namespace_id", &self.namespace_id)
181 .field("name", &self.name)
182 .finish_non_exhaustive()
183 }
184}
185
186impl Catalog {
187 #[must_use]
188 pub fn new(doc: Doc, name: impl Into<String>) -> Self {
189 let namespace_id = doc.id();
190 Self {
191 doc,
192 namespace_id,
193 name: name.into(),
194 }
195 }
196
197 #[must_use]
198 pub const fn doc(&self) -> &Doc {
199 &self.doc
200 }
201
202 #[must_use]
203 pub const fn namespace_id(&self) -> NamespaceId {
204 self.namespace_id
205 }
206
207 #[must_use]
208 pub fn name(&self) -> &str {
209 &self.name
210 }
211}
212
213#[derive(Clone)]
215pub struct CatalogService {
216 indexing: IndexingService,
217 docs: DocsEngine,
218 blobs: BlobStore,
219 author: AuthorId,
220 tasks: Arc<Mutex<HashMap<NamespaceId, JoinHandle<()>>>>,
221}
222
223impl std::fmt::Debug for CatalogService {
224 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 formatter.debug_struct("CatalogService").finish_non_exhaustive()
226 }
227}
228
229impl CatalogService {
230 #[must_use]
232 pub fn new(indexing: &IndexingService, docs: &DocsEngine, blobs: &BlobStore, author: AuthorId) -> Self {
233 Self {
234 indexing: indexing.clone(),
235 docs: docs.clone(),
236 blobs: blobs.clone(),
237 author,
238 tasks: Arc::new(Mutex::new(HashMap::new())),
239 }
240 }
241
242 #[must_use]
243 pub const fn database(&self) -> &IndexingDatabase {
244 self.indexing.database()
245 }
246
247 #[must_use]
248 pub const fn indexing(&self) -> &IndexingService {
249 &self.indexing
250 }
251
252 pub async fn create_catalog(&self, name: impl Into<String>) -> Result<Catalog> {
263 let metadata = CatalogMetadata::new(name, self.author.to_string());
264 metadata.validate()?;
265 let provisioned = self
266 .docs
267 .provision(
268 crate::node::docs_engine::ProvisionKind::Catalog,
269 None,
270 metadata.to_bytes()?,
271 )
272 .await?;
273 let catalog = Catalog::new(provisioned.doc, metadata.name.clone());
274 self.database().enable_catalog(catalog.namespace_id, catalog.name())?;
275 Ok(catalog)
276 }
277
278 pub async fn get_or_create_catalog(&self, name: &str) -> Result<Catalog> {
287 let registered = self.database().load_catalogs()?;
288 if let Some((namespace_id, _)) = registered.into_iter().find(|(_, label)| label == name) {
289 return self.subscribe_namespace(namespace_id).await;
290 }
291 self.create_catalog(name).await
292 }
293
294 pub async fn publish_folder_with_metadata(
304 &self,
305 catalog: &Catalog,
306 folder: &SyncwebFolder,
307 folder_name: impl Into<String>,
308 tags: &[String],
309 ) -> Result<usize> {
310 let folder_name_value = folder_name.into();
311 if folder_name_value.trim().is_empty() {
312 return Err(SyncwebError::InvalidConfig(
313 "catalog folder name cannot be empty".to_owned(),
314 ));
315 }
316 self.database().enable_catalog(catalog.namespace_id, catalog.name())?;
317 let entries = folder.docs_engine().list_latest(folder.doc()).await?;
318 let mut published = 0_usize;
319 for entry in entries {
320 if is_system_key(entry.key()) {
321 continue;
322 }
323 let mut record = CatalogRecord::new(
324 folder.namespace_id(),
325 entry.key(),
326 entry.content_hash(),
327 entry.content_len(),
328 )
329 .in_catalog(catalog.namespace_id);
330 record.folder_name.clone_from(&folder_name_value);
331 record.tags = tags.to_vec();
332 record.publisher = self.author.to_string();
333 let key = record_key(&record);
334 self.docs
335 .set(&catalog.doc, self.author, key, record.to_bytes()?)
336 .await?;
337 self.database().upsert_catalog_record(&record)?;
338 published = published.saturating_add(1);
339 }
340 Ok(published)
341 }
342
343 pub async fn ticket(&self, catalog: &Catalog, writable: bool) -> Result<DocTicket> {
349 self.docs.share_ticket(&catalog.doc, writable).await
350 }
351
352 pub async fn subscribe(&self, ticket: DocTicket) -> Result<Catalog> {
361 let doc = self.docs.import_ticket(ticket).await?;
362 let catalog = Catalog::new(doc, "remote catalog");
363 self.activate(&catalog).await?;
364 Ok(catalog)
365 }
366
367 pub async fn subscribe_namespace(&self, namespace_id: NamespaceId) -> Result<Catalog> {
373 let (doc, _ticket) = self.docs.create_or_open_namespace(Some(namespace_id)).await?;
374 let catalog = Catalog::new(doc, "remote catalog");
375 self.activate(&catalog).await?;
376 Ok(catalog)
377 }
378
379 pub async fn sync_catalog(&self, catalog: &Catalog) -> Result<usize> {
385 self.docs.start_sync(&catalog.doc, Vec::new()).await?;
386 self.index_catalog(catalog).await
387 }
388
389 pub async fn index_catalog(&self, catalog: &Catalog) -> Result<usize> {
395 self.database().enable_catalog(catalog.namespace_id, catalog.name())?;
396 let entries = self.docs.list_latest(&catalog.doc).await?;
397 let mut indexed = 0_usize;
398 for entry in entries {
399 if self.index_catalog_entry(catalog.namespace_id, &entry).await? {
400 indexed = indexed.saturating_add(1);
401 }
402 }
403 Ok(indexed)
404 }
405
406 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
414 self.indexing.search(query, limit)
415 }
416
417 pub fn unsubscribe(&self, namespace_id: NamespaceId) -> Result<()> {
423 let task_handle = self
424 .tasks
425 .lock()
426 .map_err(|error| SyncwebError::operation("catalog task lock poisoned", error))?
427 .remove(&namespace_id);
428 if let Some(handle) = task_handle {
429 handle.abort();
430 }
431 self.database().disable_catalog(namespace_id)
432 }
433
434 async fn activate(&self, catalog: &Catalog) -> Result<()> {
435 let live_events = self.docs.watch_and_sync(&catalog.doc).await?;
436 self.database().enable_catalog(catalog.namespace_id, catalog.name())?;
437 self.index_catalog(catalog).await?;
438
439 let namespace_id = catalog.namespace_id;
440 let database = self.indexing.database().clone();
441 let docs = self.docs.clone();
442 let blobs = self.blobs.clone();
443 let task = tokio::spawn(async move {
444 consume_catalog_events(namespace_id, live_events, database, blobs, docs).await;
445 });
446 self.tasks
447 .lock()
448 .map_err(|error| SyncwebError::operation("catalog task lock poisoned", error))?
449 .insert(namespace_id, task);
450 Ok(())
451 }
452
453 async fn index_catalog_entry(&self, namespace_id: NamespaceId, entry: &Entry) -> Result<bool> {
454 if !is_record_key(entry.key()) {
455 return Ok(false);
456 }
457 let bytes = self.blobs.get(entry.content_hash()).await?;
458 let mut record = CatalogRecord::from_bytes(bytes)?;
459 record.catalog_namespace_id = namespace_id;
460 self.database().upsert_catalog_record(&record)?;
461 Ok(true)
462 }
463}
464
465async fn consume_catalog_events(
466 namespace_id: NamespaceId,
467 live_events: impl n0_future::Stream<Item = Result<LiveEvent>> + Send + Unpin + 'static,
468 database: IndexingDatabase,
469 blobs: BlobStore,
470 docs: DocsEngine,
471) {
472 let mut consecutive_errors = 0_usize;
473 let mut current_stream: Option<Box<dyn n0_future::Stream<Item = Result<LiveEvent>> + Send + Unpin>> =
474 Some(Box::new(live_events));
475 loop {
476 let Some(stream) = current_stream.as_mut() else {
477 match reconnect_catalog_stream(namespace_id, &docs, &mut consecutive_errors).await {
478 Some(new_stream) => {
479 current_stream = Some(new_stream);
480 continue;
481 }
482 None if consecutive_errors >= MAX_CONSECUTIVE_STREAM_ERRORS => return,
483 None => continue,
484 }
485 };
486 match stream.next().await {
487 Some(Ok(event)) => {
488 consecutive_errors = 0;
489 if let Err(error) = process_catalog_event(namespace_id, event, &database, &blobs, &docs).await {
490 tracing::error!(%namespace_id, error = %error, "failed to process catalog event");
491 }
492 }
493 Some(Err(error)) => {
494 tracing::error!(%namespace_id, error = %error, "catalog document event stream error");
495 consecutive_errors = consecutive_errors.saturating_add(1);
496 current_stream = None;
497 }
498 None => {
499 tracing::info!(%namespace_id, "catalog document event stream ended");
500 return;
501 }
502 }
503 }
504}
505
506async fn reconnect_catalog_stream(
507 namespace_id: NamespaceId,
508 docs: &DocsEngine,
509 consecutive_errors: &mut usize,
510) -> Option<Box<dyn n0_future::Stream<Item = Result<LiveEvent>> + Send + Unpin>> {
511 if *consecutive_errors >= MAX_CONSECUTIVE_STREAM_ERRORS {
512 tracing::error!(
513 %namespace_id,
514 consecutive_errors = *consecutive_errors,
515 "catalog watcher gave up after max retries"
516 );
517 return None;
518 }
519 let delay = STREAM_RETRY_BASE_DELAY
520 .checked_mul(2_u32.pow(u32::try_from(*consecutive_errors).unwrap_or(0)))
521 .unwrap_or(STREAM_RETRY_MAX_DELAY)
522 .min(STREAM_RETRY_MAX_DELAY);
523 tracing::warn!(%namespace_id, delay_ms = delay.as_millis(), "catalog watcher reconnecting");
524 tokio::time::sleep(delay).await;
525 let Ok(Some(doc)) = docs.open(namespace_id).await else {
526 *consecutive_errors = consecutive_errors.saturating_add(1);
527 return None;
528 };
529 match docs.watch(&doc).await {
530 Ok(new_stream) => {
531 *consecutive_errors = 0;
532 Some(Box::new(new_stream))
533 }
534 Err(error) => {
535 tracing::error!(%namespace_id, error = %error, "catalog watcher re-subscribe failed");
536 *consecutive_errors = consecutive_errors.saturating_add(1);
537 None
538 }
539 }
540}
541
542async fn process_catalog_event(
543 namespace_id: NamespaceId,
544 event: LiveEvent,
545 database: &IndexingDatabase,
546 blobs: &BlobStore,
547 docs: &DocsEngine,
548) -> Result<()> {
549 match event {
550 LiveEvent::InsertLocal { entry } | LiveEvent::InsertRemote { entry, .. } => {
551 if !is_record_key(entry.key()) {
552 return Ok(());
553 }
554 let mut record = read_catalog_record(blobs, &entry).await?;
555 record.catalog_namespace_id = namespace_id;
556 database.upsert_catalog_record(&record)?;
557 }
558 LiveEvent::ContentReady { hash } => {
559 let doc = docs
560 .open(namespace_id)
561 .await?
562 .ok_or(SyncwebError::NamespaceNotAvailable)?;
563 for entry in docs.list_latest(&doc).await? {
564 if !is_record_key(entry.key()) || entry.content_hash() != hash {
565 continue;
566 }
567 let Ok(bytes) = blobs.get(entry.content_hash()).await else {
568 continue;
569 };
570 let mut record = CatalogRecord::from_bytes(bytes)?;
571 record.catalog_namespace_id = namespace_id;
572 database.upsert_catalog_record(&record)?;
573 break;
574 }
575 }
576 LiveEvent::PendingContentReady
577 | LiveEvent::NeighborUp(_)
578 | LiveEvent::NeighborDown(_)
579 | LiveEvent::SyncFinished(_) => {}
580 }
581 Ok(())
582}
583
584async fn read_catalog_record(blobs: &BlobStore, entry: &Entry) -> Result<CatalogRecord> {
585 let mut last_error = None;
586 for attempt in 0..RETRY_COUNT {
587 match blobs.get(entry.content_hash()).await {
588 Ok(bytes) => return CatalogRecord::from_bytes(bytes),
589 Err(error) => {
590 last_error = Some(error);
591 if attempt.saturating_add(1) < RETRY_COUNT {
592 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
593 }
594 }
595 }
596 }
597 Err(last_error.unwrap_or_else(|| SyncwebError::InvalidConfig("catalog record content is unavailable".to_owned())))
598}
599
600fn record_key(record: &CatalogRecord) -> Vec<u8> {
601 format!(
602 "{}{}/{}",
603 String::from_utf8_lossy(CATALOG_RECORD_PREFIX),
604 record.folder_namespace_id,
605 hex::encode(&record.key)
606 )
607 .into_bytes()
608}
609
610fn is_record_key(key: &[u8]) -> bool {
611 key.starts_with(CATALOG_RECORD_PREFIX)
612}
613
614fn is_system_key(key: &[u8]) -> bool {
615 key.starts_with(b"sys/")
616}
617
618fn empty_namespace() -> NamespaceId {
619 NamespaceId::from([0_u8; 32])
620}