1pub mod access;
2pub mod crawler;
3pub mod local_lists;
4pub mod snapshot;
5
6pub use access::SocialGraphAccessControl;
7pub use crawler::SocialGraphCrawler;
8pub use local_lists::{
9 read_local_list_file_state, sync_local_list_files_force, sync_local_list_files_if_changed,
10 LocalListFileState, LocalListSyncOutcome,
11};
12
13#[cfg(windows)]
14mod durable_file;
15mod index_buckets;
16mod profile_file_lock;
17
18use index_buckets::{
19 dedupe_events, latest_metadata_events_by_pubkey, EventIndexBucket, ProfileIndexBucket,
20};
21
22use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
23use std::fs::{File, OpenOptions, TryLockError};
24use std::io::Write;
25use std::path::{Path, PathBuf};
26use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
27use std::time::{Duration, Instant};
28
29use anyhow::{Context, Result};
30use bytes::Bytes;
31use futures::executor::block_on;
32use hashtree_core::{
33 nhash_decode, nhash_encode_full, sha256, to_hex, BufferedStore, Cid, HashTree, HashTreeConfig,
34 NHashData, Store,
35};
36use hashtree_index::BTree;
37use hashtree_nostr::{
38 is_parameterized_replaceable_kind, is_replaceable_kind, stored_event_from_nostr_sdk_event,
39 ListEventsOptions, NostrEventStore, NostrEventStoreError, ProfileGuard as NostrProfileGuard,
40 StoredNostrEvent,
41};
42#[cfg(test)]
43use hashtree_nostr::{
44 reset_profile as reset_nostr_profile, set_profile_enabled as set_nostr_profile_enabled,
45 take_profile as take_nostr_profile,
46};
47use heed::EnvFlags;
48use nostr::{Event, Filter, JsonUtil, Kind, SingleLetterTag};
49use nostr_social_graph::{
50 BinaryBudget, GraphStats, NostrEvent as GraphEvent, SocialGraph,
51 SocialGraphBackend as NostrSocialGraphBackend,
52};
53use nostr_social_graph_heed::HeedSocialGraph;
54use sha2::{Digest, Sha256};
55
56use crate::managed_env::ManagedEnv;
57use crate::storage::{LocalStore, StorageRouter};
58
59pub type UserSet = BTreeSet<[u8; 32]>;
60
61const PROFILE_PUBLICATION_FENCE_RELATIVE_PATH: &str =
62 "nostr-index/bulk-projection-v3/profile-publication.fenced";
63const DEFAULT_ROOT_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000000";
64const EVENTS_ROOT_FILE: &str = "events-root.msgpack";
65const AMBIENT_EVENTS_ROOT_FILE: &str = "events-root-ambient.msgpack";
66const AMBIENT_EVENTS_BLOB_DIR: &str = "ambient-blobs";
67const PROFILE_SEARCH_ROOT_FILE: &str = "profile-search-root.msgpack";
68const PROFILES_BY_PUBKEY_ROOT_FILE: &str = "profiles-by-pubkey-root.msgpack";
69const PROFILE_ROOT_PAIR_COMMIT_FILE: &str = "profile-root-pair.commit.json";
70const PROFILE_PROJECTION_PENDING_FILE: &str = "profile-projection.pending.json";
71const PROFILE_ROOT_PAIR_LOCK_FILE: &str = "profile-root-pair.lock";
72const PROFILE_PUBLICATION_LOCK_FILE: &str = "profile-publication.lock";
73const PROFILE_REPAIR_EVIDENCE_RELATIVE_DIR: &str =
74 "nostr-index/bulk-projection-v2/profile-repair-v1";
75const PROFILE_REPAIR_COMPLETION_FILE: &str = "completion.json";
76pub const PROFILE_REPAIR_FORMAT: &str = "iris-social/bulk-profile-index-repair@1";
77pub const PROFILE_REPAIR_RECEIPT_FORMAT: &str = "iris-social/bulk-profile-index-repair-receipt@1";
78pub const PROFILE_REPAIR_COMPLETION_FORMAT: &str =
79 "iris-social/bulk-profile-index-repair-completion@1";
80const PROFILE_ROOT_PAIR_COMMIT_VERSION: u32 = 1;
81const PROFILE_PROJECTION_PENDING_VERSION: u32 = 1;
82const PROFILE_ROOT_PAIR_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
83const PROFILE_ROOT_PAIR_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(10);
84const UNKNOWN_FOLLOW_DISTANCE: u32 = 1000;
85const DEFAULT_SOCIALGRAPH_MAP_SIZE_BYTES: u64 = 4 * 1024 * 1024 * 1024;
86const MIN_SOCIALGRAPH_MAP_SIZE_BYTES: u64 = 64 * 1024 * 1024;
87const SOCIALGRAPH_MAX_DBS: u32 = 16;
88const PROFILE_SEARCH_INDEX_ORDER: usize = 64;
89const PROFILE_SEARCH_PREFIX: &str = "p:";
90const PROFILE_NAME_MAX_LENGTH: usize = 100;
91
92pub fn profile_publication_fence_path(data_dir: &Path) -> PathBuf {
93 data_dir.join(PROFILE_PUBLICATION_FENCE_RELATIVE_PATH)
94}
95
96pub fn profile_repair_evidence_paths(data_dir: &Path) -> (PathBuf, PathBuf) {
97 let directory = data_dir.join(PROFILE_REPAIR_EVIDENCE_RELATIVE_DIR);
98 (
99 directory.join("intent.json"),
100 directory.join("receipt.json"),
101 )
102}
103
104pub fn profile_repair_completion_path(data_dir: &Path) -> PathBuf {
105 data_dir
106 .join(PROFILE_REPAIR_EVIDENCE_RELATIVE_DIR)
107 .join(PROFILE_REPAIR_COMPLETION_FILE)
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111#[serde(deny_unknown_fields)]
112struct ProfileRepairCompletionWitness {
113 format: String,
114 intent_sha256: String,
115 receipt_sha256: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
119#[serde(deny_unknown_fields)]
120struct ProfileRepairRootPairPin {
121 by_pubkey: String,
122 by_pubkey_file_sha256: String,
123 search: String,
124 search_file_sha256: String,
125}
126
127#[derive(Debug, serde::Deserialize)]
128struct ProfileRepairAuthorizationIntent {
129 format: String,
130 data_dir: String,
131 old_roots: ProfileRepairRootPairPin,
132 new_roots: ProfileRepairRootPairPin,
133}
134
135#[derive(Debug, serde::Deserialize)]
136struct ProfileRepairAuthorizationReceipt {
137 format: String,
138 intent_sha256: String,
139 installed_roots: ProfileRepairRootPairPin,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143enum ProfileIndexRepairAuthorityPhase {
144 Commit,
145 Completion,
146}
147
148pub struct ProfileIndexRepairAuthority {
155 root_pair_lock_path: PathBuf,
156 intent_sha256: String,
157 receipt_sha256: Option<String>,
158 old_roots: ProfileIndexRoots,
159 new_roots: ProfileIndexRoots,
160 phase: ProfileIndexRepairAuthorityPhase,
161}
162
163fn profile_repair_sha256(bytes: &[u8]) -> String {
164 to_hex(&sha256(bytes))
165}
166
167pub fn profile_repair_completion_witness_bytes(
168 intent_bytes: &[u8],
169 receipt_bytes: &[u8],
170) -> Result<Vec<u8>> {
171 let witness = ProfileRepairCompletionWitness {
172 format: PROFILE_REPAIR_COMPLETION_FORMAT.to_string(),
173 intent_sha256: profile_repair_sha256(intent_bytes),
174 receipt_sha256: profile_repair_sha256(receipt_bytes),
175 };
176 let mut bytes =
177 serde_json::to_vec(&witness).context("encode canonical profile repair completion")?;
178 bytes.push(b'\n');
179 Ok(bytes)
180}
181
182pub fn profile_publication_is_fenced(data_dir: &Path) -> Result<bool> {
183 let path = profile_publication_fence_path(data_dir);
184 match std::fs::symlink_metadata(&path) {
185 Ok(_) => Ok(true),
186 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
187 Err(error) => Err(error)
188 .with_context(|| format!("inspect profile publication fence {}", path.display())),
189 }
190}
191
192pub fn require_profile_publication_unfenced(data_dir: &Path) -> Result<()> {
193 if profile_publication_is_fenced(data_dir)? {
194 let path = profile_publication_fence_path(data_dir);
195 anyhow::bail!(
196 "profile-root publication is fenced by active v3 tranche marker {}",
197 path.display()
198 );
199 }
200 Ok(())
201}
202
203pub struct ProfilePublicationGuard {
204 _transaction: ProfileRootPairTransactionGuard,
205}
206
207pub struct ProfilePublicationFenceGuard {
208 _transaction: ProfileRootPairTransactionGuard,
209}
210
211pub struct ProfileRootSnapshotGuard {
212 db_dir: PathBuf,
213 _transaction: ProfileRootPairTransactionGuard,
214}
215
216impl ProfileRootSnapshotGuard {
217 pub(crate) fn retention_roots(&self) -> Result<Vec<Cid>> {
222 let mut roots = Vec::new();
223 for file_name in [
224 EVENTS_ROOT_FILE,
225 AMBIENT_EVENTS_ROOT_FILE,
226 PROFILE_SEARCH_ROOT_FILE,
227 PROFILES_BY_PUBKEY_ROOT_FILE,
228 ] {
229 if let Some(root) = read_root_file(&self.db_dir.join(file_name))? {
230 roots.push(root);
231 }
232 }
233 if let Some(commit) =
234 load_profile_root_pair_commit(&self.db_dir.join(PROFILE_ROOT_PAIR_COMMIT_FILE))?
235 {
236 roots.extend(
237 [
238 commit.old_search,
239 commit.old_by_pubkey,
240 commit.new_search,
241 commit.new_by_pubkey,
242 ]
243 .into_iter()
244 .flatten()
245 .map(cid_from_stored),
246 );
247 }
248 roots.sort_by_key(Cid::to_string);
249 roots.dedup();
250 Ok(roots)
251 }
252}
253
254fn profile_publication_lock_path(data_dir: &Path) -> PathBuf {
255 data_dir
256 .join("socialgraph")
257 .join(PROFILE_PUBLICATION_LOCK_FILE)
258}
259
260pub fn bootstrap_profile_root_pair_transaction_lock(data_dir: &Path) -> Result<()> {
264 let db_dir = data_dir.join("socialgraph");
265 match std::fs::symlink_metadata(&db_dir) {
266 Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => {}
267 Ok(_) => anyhow::bail!(
268 "socialgraph database is not a direct directory: {}",
269 db_dir.display()
270 ),
271 Err(error) => {
272 return Err(error)
273 .with_context(|| format!("inspect socialgraph database {}", db_dir.display()));
274 }
275 }
276 let _transaction = acquire_profile_root_pair_lock(
277 &db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE),
278 ProfileRootPairLockMode::Exclusive,
279 true,
280 )?;
281 Ok(())
282}
283
284pub fn acquire_profile_root_snapshot_guard(
289 data_dir: &Path,
290) -> Result<Option<ProfileRootSnapshotGuard>> {
291 let db_dir = data_dir.join("socialgraph");
292 match std::fs::symlink_metadata(&db_dir) {
293 Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => {}
294 Ok(_) => anyhow::bail!(
295 "socialgraph database is not a direct directory: {}",
296 db_dir.display()
297 ),
298 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
299 Err(error) => {
300 return Err(error)
301 .with_context(|| format!("inspect socialgraph database {}", db_dir.display()));
302 }
303 }
304 let transaction = acquire_profile_root_pair_lock(
305 &db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE),
306 ProfileRootPairLockMode::Shared,
307 true,
308 )?;
309 Ok(Some(ProfileRootSnapshotGuard {
310 db_dir,
311 _transaction: transaction,
312 }))
313}
314
315pub async fn acquire_profile_publication_guard(data_dir: &Path) -> Result<ProfilePublicationGuard> {
320 let transaction = acquire_profile_root_pair_lock_async(
321 &profile_publication_lock_path(data_dir),
322 ProfileRootPairLockMode::Shared,
323 true,
324 )
325 .await?;
326 require_profile_publication_unfenced(data_dir)?;
327 Ok(ProfilePublicationGuard {
328 _transaction: transaction,
329 })
330}
331
332pub async fn acquire_profile_publication_fence_guard(
338 data_dir: &Path,
339) -> Result<ProfilePublicationFenceGuard> {
340 let transaction = acquire_profile_root_pair_lock_async(
341 &profile_publication_lock_path(data_dir),
342 ProfileRootPairLockMode::Exclusive,
343 true,
344 )
345 .await?;
346 Ok(ProfilePublicationFenceGuard {
347 _transaction: transaction,
348 })
349}
350
351fn direct_regular_file_exists(path: &Path, label: &str) -> Result<bool> {
352 match std::fs::symlink_metadata(path) {
353 Ok(metadata) => {
354 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
355 anyhow::bail!("{label} is not a direct regular file: {}", path.display());
356 }
357 Ok(true)
358 }
359 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
360 Err(error) => Err(error).with_context(|| format!("inspect {label} {}", path.display())),
361 }
362}
363
364fn profile_repair_data_dir_from_lock_path(root_pair_lock_path: &Path) -> Result<&Path> {
365 let profile_db_dir = root_pair_lock_path.parent().with_context(|| {
366 format!(
367 "{} has no profile database parent",
368 root_pair_lock_path.display()
369 )
370 })?;
371 profile_db_dir
372 .parent()
373 .with_context(|| format!("{} has no data-directory parent", profile_db_dir.display()))
374}
375
376fn profile_repair_root_pair_pin(roots: &ProfileIndexRoots) -> Result<ProfileRepairRootPairPin> {
377 let by_pubkey = roots
378 .by_pubkey
379 .as_ref()
380 .context("profile-by-pubkey repair root is missing")?;
381 let search = roots
382 .search
383 .as_ref()
384 .context("profile-search repair root is missing")?;
385 let by_pubkey_file_sha256 = roots
386 .by_pubkey_file_sha256
387 .clone()
388 .context("profile-by-pubkey repair root-file SHA-256 is missing")?;
389 let search_file_sha256 = roots
390 .search_file_sha256
391 .clone()
392 .context("profile-search repair root-file SHA-256 is missing")?;
393 if by_pubkey_file_sha256 != profile_index_root_file_sha256(by_pubkey)?
394 || search_file_sha256 != profile_index_root_file_sha256(search)?
395 {
396 anyhow::bail!("profile repair root-file digest does not match its CID");
397 }
398 Ok(ProfileRepairRootPairPin {
399 by_pubkey: nhash_encode_full(&NHashData {
400 hash: by_pubkey.hash,
401 decrypt_key: by_pubkey.key,
402 })
403 .context("encode profile-by-pubkey repair root")?,
404 by_pubkey_file_sha256,
405 search: nhash_encode_full(&NHashData {
406 hash: search.hash,
407 decrypt_key: search.key,
408 })
409 .context("encode profile-search repair root")?,
410 search_file_sha256,
411 })
412}
413
414fn profile_repair_root_pair_from_pin(pin: &ProfileRepairRootPairPin) -> Result<ProfileIndexRoots> {
415 fn parse_root(value: &str, label: &str) -> Result<Cid> {
416 let decoded =
417 nhash_decode(value).with_context(|| format!("decode pinned {label} repair root"))?;
418 if nhash_encode_full(&decoded).context("re-encode pinned repair root")? != value {
419 anyhow::bail!("pinned {label} repair root is not canonical");
420 }
421 Ok(Cid {
422 hash: decoded.hash,
423 key: decoded.decrypt_key,
424 })
425 }
426
427 let by_pubkey = parse_root(&pin.by_pubkey, "profile-by-pubkey")?;
428 let search = parse_root(&pin.search, "profile-search")?;
429 let roots = ProfileIndexRoots {
430 by_pubkey: Some(by_pubkey),
431 search: Some(search),
432 by_pubkey_file_sha256: Some(pin.by_pubkey_file_sha256.clone()),
433 search_file_sha256: Some(pin.search_file_sha256.clone()),
434 };
435 profile_repair_root_pair_pin(&roots)?;
436 Ok(roots)
437}
438
439fn load_profile_index_repair_authority(
440 root_pair_lock_path: &Path,
441 prepared: &PreparedProfileIndexRepair,
442 phase: ProfileIndexRepairAuthorityPhase,
443 expected_intent_bytes: Option<&[u8]>,
444 expected_receipt_bytes: Option<&[u8]>,
445) -> Result<ProfileIndexRepairAuthority> {
446 let data_dir = profile_repair_data_dir_from_lock_path(root_pair_lock_path)?;
447 let (intent_path, receipt_path) = profile_repair_evidence_paths(data_dir);
448 let completion_path = profile_repair_completion_path(data_dir);
449 let intent_exists = direct_regular_file_exists(&intent_path, "profile repair intent")?;
450 let receipt_exists = direct_regular_file_exists(&receipt_path, "profile repair receipt")?;
451 let completion_exists =
452 direct_regular_file_exists(&completion_path, "profile repair completion")?;
453 let expected_state = match phase {
454 ProfileIndexRepairAuthorityPhase::Commit => (true, false, false),
455 ProfileIndexRepairAuthorityPhase::Completion => (true, true, false),
456 };
457 if (intent_exists, receipt_exists, completion_exists) != expected_state {
458 anyhow::bail!(
459 "profile-index repair authority requires evidence state {:?}, found intent={} receipt={} completion={}",
460 phase,
461 intent_exists,
462 receipt_exists,
463 completion_exists
464 );
465 }
466
467 let intent_bytes = std::fs::read(&intent_path)
468 .with_context(|| format!("read profile repair intent {}", intent_path.display()))?;
469 if expected_intent_bytes.is_some_and(|expected| expected != intent_bytes.as_slice()) {
470 anyhow::bail!("profile repair intent differs from the fully validated canonical bytes");
471 }
472 let intent: ProfileRepairAuthorizationIntent =
473 serde_json::from_slice(&intent_bytes).context("decode profile repair authority intent")?;
474 if intent.format != PROFILE_REPAIR_FORMAT {
475 anyhow::bail!("profile repair intent has an unsupported format");
476 }
477 let canonical_data_dir = data_dir
478 .canonicalize()
479 .context("canonicalize profile repair authority data directory")?
480 .to_string_lossy()
481 .into_owned();
482 if intent.data_dir != canonical_data_dir {
483 anyhow::bail!("profile repair intent is bound to a different data directory");
484 }
485 if intent.old_roots != profile_repair_root_pair_pin(&prepared.old_roots)?
486 || intent.new_roots != profile_repair_root_pair_pin(&prepared.new_roots)?
487 {
488 anyhow::bail!("profile repair intent does not bind the exact prepared root pair");
489 }
490 let intent_sha256 = profile_repair_sha256(&intent_bytes);
491
492 let receipt_sha256 = if phase == ProfileIndexRepairAuthorityPhase::Completion {
493 let receipt_bytes = std::fs::read(&receipt_path)
494 .with_context(|| format!("read profile repair receipt {}", receipt_path.display()))?;
495 if expected_receipt_bytes.is_some_and(|expected| expected != receipt_bytes.as_slice()) {
496 anyhow::bail!(
497 "profile repair receipt differs from the fully validated canonical bytes"
498 );
499 }
500 let receipt: ProfileRepairAuthorizationReceipt = serde_json::from_slice(&receipt_bytes)
501 .context("decode profile repair authority receipt")?;
502 if receipt.format != PROFILE_REPAIR_RECEIPT_FORMAT
503 || receipt.intent_sha256 != intent_sha256
504 || receipt.installed_roots != profile_repair_root_pair_pin(&prepared.new_roots)?
505 {
506 anyhow::bail!(
507 "profile repair receipt does not bind the exact intent and installed root pair"
508 );
509 }
510 Some(profile_repair_sha256(&receipt_bytes))
511 } else {
512 if expected_receipt_bytes.is_some() {
513 anyhow::bail!("commit-phase repair authority cannot bind receipt bytes");
514 }
515 None
516 };
517
518 Ok(ProfileIndexRepairAuthority {
519 root_pair_lock_path: root_pair_lock_path.to_path_buf(),
520 intent_sha256,
521 receipt_sha256,
522 old_roots: prepared.old_roots.clone(),
523 new_roots: prepared.new_roots.clone(),
524 phase,
525 })
526}
527
528fn revalidate_profile_index_repair_authority(
529 authority: &ProfileIndexRepairAuthority,
530 root_pair_lock_path: &Path,
531 prepared: &PreparedProfileIndexRepair,
532 phase: ProfileIndexRepairAuthorityPhase,
533) -> Result<()> {
534 if authority.root_pair_lock_path != root_pair_lock_path
535 || authority.old_roots != prepared.old_roots
536 || authority.new_roots != prepared.new_roots
537 || authority.phase != phase
538 {
539 anyhow::bail!("profile-index repair authority belongs to a different transaction");
540 }
541 let current =
542 load_profile_index_repair_authority(root_pair_lock_path, prepared, phase, None, None)?;
543 if current.intent_sha256 != authority.intent_sha256
544 || current.receipt_sha256 != authority.receipt_sha256
545 {
546 anyhow::bail!("profile-index repair authority evidence changed before publication");
547 }
548 Ok(())
549}
550
551fn validate_profile_repair_completion(
552 data_dir: &Path,
553 intent_path: &Path,
554 receipt_path: &Path,
555 completion_path: &Path,
556) -> Result<()> {
557 let intent_bytes = std::fs::read(intent_path)
558 .with_context(|| format!("read profile repair intent {}", intent_path.display()))?;
559 let receipt_bytes = std::fs::read(receipt_path)
560 .with_context(|| format!("read profile repair receipt {}", receipt_path.display()))?;
561 let completion_bytes = std::fs::read(completion_path).with_context(|| {
562 format!(
563 "read profile repair completion {}",
564 completion_path.display()
565 )
566 })?;
567
568 let intent: ProfileRepairAuthorizationIntent =
569 serde_json::from_slice(&intent_bytes).context("decode profile repair intent")?;
570 let receipt: ProfileRepairAuthorizationReceipt =
571 serde_json::from_slice(&receipt_bytes).context("decode profile repair receipt")?;
572 if intent.format != PROFILE_REPAIR_FORMAT {
573 anyhow::bail!("profile repair intent has an unsupported format");
574 }
575 if receipt.format != PROFILE_REPAIR_RECEIPT_FORMAT {
576 anyhow::bail!("profile repair receipt has an unsupported format");
577 }
578 let canonical_data_dir = data_dir
579 .canonicalize()
580 .context("canonicalize completed profile repair data directory")?
581 .to_string_lossy()
582 .into_owned();
583 if intent.data_dir != canonical_data_dir {
584 anyhow::bail!("profile repair intent is bound to a different data directory");
585 }
586 profile_repair_root_pair_from_pin(&intent.old_roots)?;
587 let new_roots = profile_repair_root_pair_from_pin(&intent.new_roots)?;
588 let installed_roots = profile_repair_root_pair_from_pin(&receipt.installed_roots)?;
589 if installed_roots != new_roots {
590 anyhow::bail!("profile repair receipt does not bind the intended installed root pair");
591 }
592 let intent_sha256 = profile_repair_sha256(&intent_bytes);
593 if receipt.intent_sha256 != intent_sha256 {
594 anyhow::bail!("profile repair receipt does not bind the durable intent");
595 }
596
597 let completion: ProfileRepairCompletionWitness =
598 serde_json::from_slice(&completion_bytes).context("decode profile repair completion")?;
599 let canonical = profile_repair_completion_witness_bytes(&intent_bytes, &receipt_bytes)?;
600 if completion_bytes != canonical
601 || completion.format != PROFILE_REPAIR_COMPLETION_FORMAT
602 || completion.intent_sha256 != intent_sha256
603 || completion.receipt_sha256 != profile_repair_sha256(&receipt_bytes)
604 {
605 anyhow::bail!("profile repair completion does not bind the exact intent and receipt");
606 }
607 Ok(())
608}
609
610fn incomplete_profile_repair_intent_path(root_pair_lock_path: &Path) -> Result<Option<PathBuf>> {
611 let data_dir = profile_repair_data_dir_from_lock_path(root_pair_lock_path)?;
612 let (intent_path, receipt_path) = profile_repair_evidence_paths(data_dir);
613 let completion_path = profile_repair_completion_path(data_dir);
614 let intent_exists = direct_regular_file_exists(&intent_path, "profile repair intent")?;
615 let receipt_exists = direct_regular_file_exists(&receipt_path, "profile repair receipt")?;
616 let completion_exists =
617 direct_regular_file_exists(&completion_path, "profile repair completion")?;
618 match (intent_exists, receipt_exists, completion_exists) {
619 (false, false, false) => Ok(None),
620 (true, false, false) | (true, true, false) => Ok(Some(intent_path)),
621 (true, true, true) => {
622 validate_profile_repair_completion(
623 data_dir,
624 &intent_path,
625 &receipt_path,
626 &completion_path,
627 )
628 .context("profile root write is blocked by invalid repair completion")?;
629 Ok(None)
630 }
631 (false, true, _) => anyhow::bail!(
632 "profile root write is blocked by receipt without repair intent: {}",
633 receipt_path.display()
634 ),
635 (false, false, true) => anyhow::bail!(
636 "profile root write is blocked by completion without repair intent: {}",
637 completion_path.display()
638 ),
639 (true, false, true) => anyhow::bail!(
640 "profile root write is blocked by completion without repair receipt: {}",
641 completion_path.display()
642 ),
643 }
644}
645
646fn require_no_incomplete_profile_repair_for_root_write(root_pair_lock_path: &Path) -> Result<()> {
647 if let Some(intent_path) = incomplete_profile_repair_intent_path(root_pair_lock_path)? {
648 anyhow::bail!(
649 "profile root write is blocked by incomplete durable repair intent {}",
650 intent_path.display()
651 );
652 }
653 Ok(())
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
657pub enum EventStorageClass {
658 Public,
659 Ambient,
660}
661
662#[cfg_attr(not(test), allow(dead_code))]
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub(crate) enum EventQueryScope {
665 PublicOnly,
666 AmbientOnly,
667 All,
668}
669
670#[derive(Debug, Clone, PartialEq)]
671pub(crate) enum PublicEventsRootApplyOutcome {
672 Applied,
673 Conflict { current_root: Option<Cid> },
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
677#[serde(deny_unknown_fields)]
678struct StoredCid {
679 hash: [u8; 32],
680 key: Option<[u8; 32]>,
681}
682
683#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
684#[serde(deny_unknown_fields)]
685struct ProfileRootPairCommit {
686 version: u32,
687 old_search: Option<StoredCid>,
688 old_by_pubkey: Option<StoredCid>,
689 new_search: Option<StoredCid>,
690 new_by_pubkey: Option<StoredCid>,
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
694#[serde(rename_all = "kebab-case")]
695enum StoredEventStorageClass {
696 Public,
697 Ambient,
698}
699
700impl From<EventStorageClass> for StoredEventStorageClass {
701 fn from(value: EventStorageClass) -> Self {
702 match value {
703 EventStorageClass::Public => Self::Public,
704 EventStorageClass::Ambient => Self::Ambient,
705 }
706 }
707}
708
709impl From<StoredEventStorageClass> for EventStorageClass {
710 fn from(value: StoredEventStorageClass) -> Self {
711 match value {
712 StoredEventStorageClass::Public => Self::Public,
713 StoredEventStorageClass::Ambient => Self::Ambient,
714 }
715 }
716}
717
718#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
719#[serde(tag = "mode", rename_all = "kebab-case", deny_unknown_fields)]
720enum PendingProfileProjectionMode {
721 Incremental {
722 old_root: Option<StoredCid>,
723 new_root: StoredCid,
724 events: Vec<String>,
725 },
726 RebuildPublicRoot {
727 old_root: Option<StoredCid>,
728 new_root: StoredCid,
729 },
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
733#[serde(deny_unknown_fields)]
734struct PendingProfileProjection {
735 version: u32,
736 storage_class: StoredEventStorageClass,
737 projection: PendingProfileProjectionMode,
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
741pub struct StoredProfileSearchEntry {
742 pub pubkey: String,
743 pub name: String,
744 #[serde(default)]
745 pub aliases: Vec<String>,
746 #[serde(default)]
747 pub nip05: Option<String>,
748 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub follow_distance: Option<u32>,
750 pub created_at: u64,
751 pub event_nhash: String,
752}
753
754#[derive(Debug, Clone, PartialEq)]
755pub struct ProfileIndexRoots {
756 pub by_pubkey: Option<Cid>,
757 pub search: Option<Cid>,
758 pub by_pubkey_file_sha256: Option<String>,
759 pub search_file_sha256: Option<String>,
760}
761
762fn profile_index_roots_from_cids(
763 by_pubkey: Option<Cid>,
764 search: Option<Cid>,
765) -> Result<ProfileIndexRoots> {
766 Ok(ProfileIndexRoots {
767 by_pubkey_file_sha256: by_pubkey
768 .as_ref()
769 .map(profile_index_root_file_sha256)
770 .transpose()?,
771 search_file_sha256: search
772 .as_ref()
773 .map(profile_index_root_file_sha256)
774 .transpose()?,
775 by_pubkey,
776 search,
777 })
778}
779
780fn read_profile_index_root_pair_snapshot(
781 by_pubkey_path: &Path,
782 search_path: &Path,
783) -> Result<ProfileIndexRoots> {
784 let (by_pubkey, by_pubkey_file_sha256) = read_root_file_snapshot(by_pubkey_path)?;
785 let (search, search_file_sha256) = read_root_file_snapshot(search_path)?;
786 Ok(ProfileIndexRoots {
787 by_pubkey,
788 search,
789 by_pubkey_file_sha256,
790 search_file_sha256,
791 })
792}
793
794impl ProfileIndexRepairAuthority {
795 fn require_pending_commit(
796 &self,
797 commit: &ProfileRootPairCommit,
798 by_pubkey_path: &Path,
799 search_path: &Path,
800 ) -> Result<()> {
801 let old_roots = profile_index_roots_from_cids(
802 commit.old_by_pubkey.clone().map(cid_from_stored),
803 commit.old_search.clone().map(cid_from_stored),
804 )?;
805 let new_roots = profile_index_roots_from_cids(
806 commit.new_by_pubkey.clone().map(cid_from_stored),
807 commit.new_search.clone().map(cid_from_stored),
808 )?;
809 if old_roots != self.old_roots || new_roots != self.new_roots {
810 anyhow::bail!(
811 "pending profile root-pair commit is not bound to the authorized repair roots"
812 );
813 }
814
815 let current = read_profile_index_root_pair_snapshot(by_pubkey_path, search_path)?;
816 let search_first = profile_index_roots_from_cids(
817 self.old_roots.by_pubkey.clone(),
818 self.new_roots.search.clone(),
819 )?;
820 if current != self.old_roots && current != search_first && current != self.new_roots {
821 anyhow::bail!("profile root-pair files are not an authorized repair forward state");
822 }
823 Ok(())
824 }
825
826 fn require_write_target(
827 &self,
828 by_pubkey_root: Option<&Cid>,
829 search_root: Option<&Cid>,
830 current: &ProfileIndexRoots,
831 ) -> Result<()> {
832 let target = profile_index_roots_from_cids(by_pubkey_root.cloned(), search_root.cloned())?;
833 if self.phase != ProfileIndexRepairAuthorityPhase::Commit
834 || target != self.new_roots
835 || *current != self.old_roots
836 {
837 anyhow::bail!("profile root-pair write is not the exact authorized repair transition");
838 }
839 Ok(())
840 }
841}
842
843#[derive(Debug, Clone, PartialEq)]
844pub struct PreparedProfileIndexRepair {
845 old_roots: ProfileIndexRoots,
846 new_roots: ProfileIndexRoots,
847}
848
849impl PreparedProfileIndexRepair {
850 pub fn from_roots(old_roots: ProfileIndexRoots, new_roots: ProfileIndexRoots) -> Self {
854 Self {
855 old_roots,
856 new_roots,
857 }
858 }
859
860 pub fn old_roots(&self) -> &ProfileIndexRoots {
861 &self.old_roots
862 }
863
864 pub fn new_roots(&self) -> &ProfileIndexRoots {
865 &self.new_roots
866 }
867}
868
869#[derive(Debug, Clone, Copy, PartialEq, Eq)]
870pub enum ProfileIndexRepairCommitOutcome {
871 Applied,
872 AlreadyApplied,
873}
874
875pub struct ProfileIndexRepairPublicationGuard {
876 by_pubkey_root_path: PathBuf,
877 search_root_path: PathBuf,
878 installed_roots: ProfileIndexRoots,
879 outcome: ProfileIndexRepairCommitOutcome,
880 _transaction: ProfileRootPairTransactionGuard,
881}
882
883impl ProfileIndexRepairPublicationGuard {
884 pub fn outcome(&self) -> ProfileIndexRepairCommitOutcome {
885 self.outcome
886 }
887
888 pub fn installed_roots(&self) -> &ProfileIndexRoots {
889 &self.installed_roots
890 }
891
892 pub fn require_unchanged(&self) -> Result<()> {
893 let (by_pubkey, by_pubkey_file_sha256) =
894 read_root_file_snapshot(&self.by_pubkey_root_path)?;
895 let (search, search_file_sha256) = read_root_file_snapshot(&self.search_root_path)?;
896 let current = ProfileIndexRoots {
897 by_pubkey,
898 search,
899 by_pubkey_file_sha256,
900 search_file_sha256,
901 };
902 if current != self.installed_roots {
903 anyhow::bail!("published profile roots changed while repair publication was locked");
904 }
905 Ok(())
906 }
907}
908
909#[derive(Debug, Clone, Copy, PartialEq, Eq)]
910enum ProfileRootPairLockMode {
911 Shared,
912 Exclusive,
913}
914
915struct ProfileRootPairTransactionGuard {
916 _process_read: Option<tokio::sync::OwnedRwLockReadGuard<()>>,
917 _process_write: Option<tokio::sync::OwnedRwLockWriteGuard<()>>,
918 file: File,
919}
920
921impl Drop for ProfileRootPairTransactionGuard {
922 fn drop(&mut self) {
923 let _ = profile_file_lock::unlock(&self.file);
924 }
925}
926
927fn profile_root_pair_process_locks(
928) -> &'static StdMutex<HashMap<PathBuf, Weak<tokio::sync::RwLock<()>>>> {
929 static LOCKS: OnceLock<StdMutex<HashMap<PathBuf, Weak<tokio::sync::RwLock<()>>>>> =
930 OnceLock::new();
931 LOCKS.get_or_init(|| StdMutex::new(HashMap::new()))
932}
933
934fn profile_root_pair_process_lock(
935 root_pair_lock_path: &Path,
936) -> Result<Arc<tokio::sync::RwLock<()>>> {
937 let db_dir = root_pair_lock_path
938 .parent()
939 .with_context(|| format!("{} has no parent directory", root_pair_lock_path.display()))?;
940 let canonical_db_dir = std::fs::canonicalize(db_dir)
941 .with_context(|| format!("canonicalize profile index directory {}", db_dir.display()))?;
942 let lock_file_name = root_pair_lock_path.file_name().with_context(|| {
943 format!(
944 "{} has no profile transaction lock file name",
945 root_pair_lock_path.display()
946 )
947 })?;
948 let canonical_lock_path = canonical_db_dir.join(lock_file_name);
949 let mut locks = profile_root_pair_process_locks()
950 .lock()
951 .map_err(|_| anyhow::anyhow!("profile root-pair process lock registry was poisoned"))?;
952 locks.retain(|_, lock| lock.strong_count() > 0);
953 if let Some(lock) = locks.get(&canonical_lock_path).and_then(Weak::upgrade) {
954 return Ok(lock);
955 }
956 let lock = Arc::new(tokio::sync::RwLock::new(()));
957 locks.insert(canonical_lock_path, Arc::downgrade(&lock));
958 Ok(lock)
959}
960
961#[cfg(test)]
962type ProfileRootPairTransactionProbe =
963 Arc<dyn Fn(&Path, ProfileRootPairLockMode) + Send + Sync + 'static>;
964
965#[cfg(test)]
966fn profile_root_pair_transaction_probe(
967) -> &'static StdMutex<Option<ProfileRootPairTransactionProbe>> {
968 static PROBE: OnceLock<StdMutex<Option<ProfileRootPairTransactionProbe>>> = OnceLock::new();
969 PROBE.get_or_init(|| StdMutex::new(None))
970}
971
972#[cfg(test)]
973struct ProfileRootPairTransactionProbeGuard;
974
975#[cfg(test)]
976impl Drop for ProfileRootPairTransactionProbeGuard {
977 fn drop(&mut self) {
978 if let Ok(mut probe) = profile_root_pair_transaction_probe().lock() {
979 *probe = None;
980 }
981 }
982}
983
984#[cfg(test)]
985fn install_profile_root_pair_transaction_probe(
986 probe: ProfileRootPairTransactionProbe,
987) -> ProfileRootPairTransactionProbeGuard {
988 *profile_root_pair_transaction_probe()
989 .lock()
990 .expect("profile root-pair transaction probe lock poisoned") = Some(probe);
991 ProfileRootPairTransactionProbeGuard
992}
993
994#[cfg(test)]
995fn run_profile_root_pair_transaction_probe(path: &Path, mode: ProfileRootPairLockMode) {
996 let probe = profile_root_pair_transaction_probe()
997 .lock()
998 .expect("profile root-pair transaction probe lock poisoned")
999 .clone();
1000 if let Some(probe) = probe {
1001 probe(path, mode);
1002 }
1003}
1004
1005#[cfg(test)]
1006type PendingProfileProjectionPersistedProbe =
1007 Arc<dyn Fn(&Path) -> Result<()> + Send + Sync + 'static>;
1008
1009#[cfg(test)]
1010fn pending_profile_projection_persisted_probe(
1011) -> &'static StdMutex<Option<PendingProfileProjectionPersistedProbe>> {
1012 static PROBE: OnceLock<StdMutex<Option<PendingProfileProjectionPersistedProbe>>> =
1013 OnceLock::new();
1014 PROBE.get_or_init(|| StdMutex::new(None))
1015}
1016
1017#[cfg(test)]
1018struct PendingProfileProjectionPersistedProbeGuard;
1019
1020#[cfg(test)]
1021impl Drop for PendingProfileProjectionPersistedProbeGuard {
1022 fn drop(&mut self) {
1023 if let Ok(mut probe) = pending_profile_projection_persisted_probe().lock() {
1024 *probe = None;
1025 }
1026 }
1027}
1028
1029#[cfg(test)]
1030fn install_pending_profile_projection_persisted_probe(
1031 probe: PendingProfileProjectionPersistedProbe,
1032) -> PendingProfileProjectionPersistedProbeGuard {
1033 *pending_profile_projection_persisted_probe()
1034 .lock()
1035 .expect("pending profile projection probe lock poisoned") = Some(probe);
1036 PendingProfileProjectionPersistedProbeGuard
1037}
1038
1039#[cfg(test)]
1040fn run_pending_profile_projection_persisted_probe(path: &Path) -> Result<()> {
1041 let probe = pending_profile_projection_persisted_probe()
1042 .lock()
1043 .map_err(|_| anyhow::anyhow!("pending profile projection probe lock poisoned"))?
1044 .clone();
1045 if let Some(probe) = probe {
1046 probe(path)?;
1047 }
1048 Ok(())
1049}
1050
1051fn try_open_and_lock_profile_root_pair_file(
1052 root_pair_lock_path: &Path,
1053 mode: ProfileRootPairLockMode,
1054 create: bool,
1055) -> Result<Option<File>> {
1056 let mut options = OpenOptions::new();
1057 options.read(true);
1058 if create {
1059 options.write(true).create(true).truncate(false);
1060 }
1061 let file = options.open(root_pair_lock_path).with_context(|| {
1062 format!(
1063 "open {} profile root-pair transaction lock {}",
1064 if create { "writable" } else { "existing" },
1065 root_pair_lock_path.display()
1066 )
1067 })?;
1068
1069 let result = profile_file_lock::try_lock(&file, mode);
1070 match result {
1071 Ok(()) => {}
1072 Err(TryLockError::WouldBlock) => return Ok(None),
1073 Err(TryLockError::Error(error)) => {
1074 return Err(error).with_context(|| {
1075 format!(
1076 "lock profile root-pair transaction ({:?}) at {}",
1077 mode,
1078 root_pair_lock_path.display()
1079 )
1080 });
1081 }
1082 }
1083
1084 Ok(Some(file))
1085}
1086
1087fn profile_root_pair_lock_timeout_error(
1088 root_pair_lock_path: &Path,
1089 mode: ProfileRootPairLockMode,
1090 timeout: Duration,
1091) -> anyhow::Error {
1092 anyhow::anyhow!(
1093 "timed out after {} ms waiting for profile root-pair transaction ({:?}) at {}",
1094 timeout.as_millis(),
1095 mode,
1096 root_pair_lock_path.display()
1097 )
1098}
1099
1100fn try_acquire_profile_root_pair_lock_once(
1101 process_lock: &Arc<tokio::sync::RwLock<()>>,
1102 root_pair_lock_path: &Path,
1103 mode: ProfileRootPairLockMode,
1104 create: bool,
1105) -> Result<Option<ProfileRootPairTransactionGuard>> {
1106 let (process_read, process_write) = match mode {
1107 ProfileRootPairLockMode::Shared => {
1108 let Ok(guard) = Arc::clone(process_lock).try_read_owned() else {
1109 return Ok(None);
1110 };
1111 (Some(guard), None)
1112 }
1113 ProfileRootPairLockMode::Exclusive => {
1114 let Ok(guard) = Arc::clone(process_lock).try_write_owned() else {
1115 return Ok(None);
1116 };
1117 (None, Some(guard))
1118 }
1119 };
1120 let Some(file) = try_open_and_lock_profile_root_pair_file(root_pair_lock_path, mode, create)?
1121 else {
1122 return Ok(None);
1123 };
1124 let guard = ProfileRootPairTransactionGuard {
1125 _process_read: process_read,
1126 _process_write: process_write,
1127 file,
1128 };
1129 #[cfg(test)]
1130 run_profile_root_pair_transaction_probe(root_pair_lock_path, mode);
1131 Ok(Some(guard))
1132}
1133
1134fn acquire_profile_root_pair_lock_with_timeout(
1135 root_pair_lock_path: &Path,
1136 mode: ProfileRootPairLockMode,
1137 create: bool,
1138 timeout: Duration,
1139) -> Result<ProfileRootPairTransactionGuard> {
1140 let process_lock = profile_root_pair_process_lock(root_pair_lock_path)?;
1141 let started = Instant::now();
1142 loop {
1143 if let Some(guard) = try_acquire_profile_root_pair_lock_once(
1144 &process_lock,
1145 root_pair_lock_path,
1146 mode,
1147 create,
1148 )? {
1149 return Ok(guard);
1150 }
1151 let elapsed = started.elapsed();
1152 if elapsed >= timeout {
1153 return Err(profile_root_pair_lock_timeout_error(
1154 root_pair_lock_path,
1155 mode,
1156 timeout,
1157 ));
1158 }
1159 std::thread::sleep(
1160 PROFILE_ROOT_PAIR_LOCK_RETRY_INTERVAL.min(timeout.saturating_sub(elapsed)),
1161 );
1162 }
1163}
1164
1165fn acquire_profile_root_pair_lock(
1166 root_pair_lock_path: &Path,
1167 mode: ProfileRootPairLockMode,
1168 create: bool,
1169) -> Result<ProfileRootPairTransactionGuard> {
1170 acquire_profile_root_pair_lock_with_timeout(
1171 root_pair_lock_path,
1172 mode,
1173 create,
1174 PROFILE_ROOT_PAIR_LOCK_TIMEOUT,
1175 )
1176}
1177
1178async fn acquire_profile_root_pair_lock_async_with_timeout(
1179 root_pair_lock_path: &Path,
1180 mode: ProfileRootPairLockMode,
1181 create: bool,
1182 timeout: Duration,
1183) -> Result<ProfileRootPairTransactionGuard> {
1184 let process_lock = profile_root_pair_process_lock(root_pair_lock_path)?;
1185 let started = Instant::now();
1186 loop {
1187 if let Some(guard) = try_acquire_profile_root_pair_lock_once(
1188 &process_lock,
1189 root_pair_lock_path,
1190 mode,
1191 create,
1192 )? {
1193 return Ok(guard);
1194 }
1195 let elapsed = started.elapsed();
1196 if elapsed >= timeout {
1197 return Err(profile_root_pair_lock_timeout_error(
1198 root_pair_lock_path,
1199 mode,
1200 timeout,
1201 ));
1202 }
1203 tokio::time::sleep(
1204 PROFILE_ROOT_PAIR_LOCK_RETRY_INTERVAL.min(timeout.saturating_sub(elapsed)),
1205 )
1206 .await;
1207 }
1208}
1209
1210async fn acquire_profile_root_pair_lock_async(
1211 root_pair_lock_path: &Path,
1212 mode: ProfileRootPairLockMode,
1213 create: bool,
1214) -> Result<ProfileRootPairTransactionGuard> {
1215 acquire_profile_root_pair_lock_async_with_timeout(
1216 root_pair_lock_path,
1217 mode,
1218 create,
1219 PROFILE_ROOT_PAIR_LOCK_TIMEOUT,
1220 )
1221 .await
1222}
1223
1224#[derive(Debug, Clone, Default, serde::Serialize)]
1225pub struct SocialGraphStats {
1226 pub total_users: usize,
1227 pub root: Option<String>,
1228 pub total_follows: usize,
1229 pub max_depth: u32,
1230 pub size_by_distance: BTreeMap<u32, usize>,
1231 pub enabled: bool,
1232}
1233
1234#[derive(Debug, Clone)]
1235struct DistanceCache {
1236 stats: SocialGraphStats,
1237 users_by_distance: BTreeMap<u32, Vec<[u8; 32]>>,
1238}
1239
1240#[derive(Debug, thiserror::Error)]
1241#[error("{0}")]
1242pub struct UpstreamGraphBackendError(String);
1243
1244pub struct SocialGraphStore {
1245 graph: StdMutex<HeedSocialGraph>,
1246 _graph_env_lifecycle: ManagedEnv,
1249 ambient_store: Arc<StorageRouter>,
1250 distance_cache: StdMutex<Option<DistanceCache>>,
1251 public_events: EventIndexBucket,
1252 ambient_events: EventIndexBucket,
1253 profile_index: ProfileIndexBucket,
1254 profile_index_overmute_threshold: StdMutex<f64>,
1255}
1256
1257pub trait SocialGraphBackend: Send + Sync {
1258 fn stats(&self) -> Result<SocialGraphStats>;
1259 fn users_by_follow_distance(&self, distance: u32) -> Result<Vec<[u8; 32]>>;
1260 fn follow_distance(&self, pk_bytes: &[u8; 32]) -> Result<Option<u32>>;
1261 fn follow_list_created_at(&self, owner: &[u8; 32]) -> Result<Option<u64>>;
1262 fn followed_targets(&self, owner: &[u8; 32]) -> Result<UserSet>;
1263 fn is_overmuted_user(&self, user_pk: &[u8; 32], threshold: f64) -> Result<bool>;
1264 fn profile_search_root(&self) -> Result<Option<Cid>> {
1265 Ok(None)
1266 }
1267 fn snapshot_chunks(&self, root: &[u8; 32], options: &BinaryBudget) -> Result<Vec<Bytes>>;
1268 fn ingest_event(&self, event: &Event) -> Result<()>;
1269 fn ingest_event_with_storage_class(
1270 &self,
1271 event: &Event,
1272 storage_class: EventStorageClass,
1273 ) -> Result<()> {
1274 let _ = storage_class;
1275 self.ingest_event(event)
1276 }
1277 fn ingest_events(&self, events: &[Event]) -> Result<()> {
1278 for event in events {
1279 self.ingest_event(event)?;
1280 }
1281 Ok(())
1282 }
1283 fn ingest_events_with_storage_class(
1284 &self,
1285 events: &[Event],
1286 storage_class: EventStorageClass,
1287 ) -> Result<()> {
1288 for event in events {
1289 self.ingest_event_with_storage_class(event, storage_class)?;
1290 }
1291 Ok(())
1292 }
1293 fn ingest_graph_events(&self, events: &[Event]) -> Result<()> {
1294 self.ingest_events(events)
1295 }
1296 fn query_events(&self, filter: &Filter, limit: usize) -> Result<Vec<Event>>;
1297}
1298
1299#[cfg(test)]
1300pub type TestLockGuard = tokio::sync::MutexGuard<'static, ()>;
1301
1302#[cfg(test)]
1303static NDB_TEST_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1304
1305#[cfg(test)]
1306fn test_mutex() -> &'static tokio::sync::Mutex<()> {
1307 NDB_TEST_LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
1308}
1309
1310#[cfg(test)]
1311pub async fn test_lock() -> TestLockGuard {
1312 test_mutex().lock().await
1313}
1314
1315#[cfg(test)]
1316pub fn test_lock_blocking() -> TestLockGuard {
1317 test_mutex().blocking_lock()
1318}
1319
1320pub fn open_social_graph_store(data_dir: &Path) -> Result<Arc<SocialGraphStore>> {
1321 open_social_graph_store_with_mapsize(data_dir, None)
1322}
1323
1324pub fn read_profile_index_roots(data_dir: &Path) -> Result<ProfileIndexRoots> {
1327 read_profile_index_roots_with_timeout(data_dir, PROFILE_ROOT_PAIR_LOCK_TIMEOUT)
1328}
1329
1330pub fn profile_index_root_file_sha256(root: &Cid) -> Result<String> {
1331 Ok(to_hex(&sha256(&encode_cid(root)?)))
1332}
1333
1334fn read_profile_index_roots_with_timeout(
1335 data_dir: &Path,
1336 timeout: Duration,
1337) -> Result<ProfileIndexRoots> {
1338 let db_dir = data_dir.join("socialgraph");
1339 match std::fs::symlink_metadata(&db_dir) {
1340 Ok(_) => {}
1341 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1342 return Ok(ProfileIndexRoots {
1343 by_pubkey: None,
1344 search: None,
1345 by_pubkey_file_sha256: None,
1346 search_file_sha256: None,
1347 });
1348 }
1349 Err(error) => {
1350 return Err(error)
1351 .with_context(|| format!("inspect profile index directory {}", db_dir.display()));
1352 }
1353 }
1354 let _transaction = acquire_profile_root_pair_lock_with_timeout(
1355 &db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE),
1356 ProfileRootPairLockMode::Shared,
1357 false,
1358 timeout,
1359 )?;
1360 require_no_pending_profile_root_pair_commit(&db_dir)?;
1361 require_no_pending_profile_projection(&db_dir)?;
1362 let (by_pubkey, by_pubkey_file_sha256) =
1363 read_root_file_snapshot(&db_dir.join(PROFILES_BY_PUBKEY_ROOT_FILE))?;
1364 let (search, search_file_sha256) =
1365 read_root_file_snapshot(&db_dir.join(PROFILE_SEARCH_ROOT_FILE))?;
1366 Ok(ProfileIndexRoots {
1367 by_pubkey,
1368 search,
1369 by_pubkey_file_sha256,
1370 search_file_sha256,
1371 })
1372}
1373
1374pub async fn validate_profile_indexes_read_only<S: Store>(
1377 data_dir: &Path,
1378 store: Arc<S>,
1379 event: &Event,
1380) -> Result<StoredProfileSearchEntry> {
1381 let roots = read_profile_index_roots(data_dir)?;
1382 validate_profile_indexes_at_roots(store, &roots, event).await
1383}
1384
1385pub async fn validate_profile_indexes_at_roots<S: Store>(
1389 store: Arc<S>,
1390 roots: &ProfileIndexRoots,
1391 event: &Event,
1392) -> Result<StoredProfileSearchEntry> {
1393 if event.kind != Kind::Metadata {
1394 anyhow::bail!("profile index validation requires a kind-0 metadata event");
1395 }
1396 let by_pubkey_root = roots
1397 .by_pubkey
1398 .clone()
1399 .context("profile-by-pubkey root is missing")?;
1400 let search_root = roots
1401 .search
1402 .clone()
1403 .context("profile-search root is missing")?;
1404 let index = BTree::new(
1405 Arc::clone(&store),
1406 hashtree_index::BTreeOptions {
1407 order: Some(PROFILE_SEARCH_INDEX_ORDER),
1408 },
1409 );
1410 let pubkey = event.pubkey.to_hex();
1411 let mirrored_cid = index
1412 .get_link(Some(&by_pubkey_root), &pubkey)
1413 .await
1414 .context("query profile-by-pubkey root")?
1415 .with_context(|| format!("profile-by-pubkey omitted {pubkey}"))?;
1416 let tree = HashTree::new(HashTreeConfig::new(store));
1417 let mirrored_bytes = tree
1418 .get(&mirrored_cid, None)
1419 .await
1420 .context("read mirrored profile event")?
1421 .with_context(|| format!("mirrored profile blob for {pubkey} is missing"))?;
1422 let mirrored = Event::from_json(
1423 String::from_utf8(mirrored_bytes).context("decode mirrored profile event as utf-8")?,
1424 )
1425 .context("decode mirrored profile event json")?;
1426 if mirrored != *event {
1427 anyhow::bail!(
1428 "profile-by-pubkey returned event {} with different bytes than {} for {pubkey}",
1429 mirrored.id,
1430 event.id
1431 );
1432 }
1433
1434 let term = profile_search_terms_for_event(event)
1435 .into_iter()
1436 .next()
1437 .with_context(|| format!("profile {pubkey} did not produce a search term"))?;
1438 let exact_key = format!("{PROFILE_SEARCH_PREFIX}{term}:{pubkey}");
1439 let encoded = index
1440 .get(Some(&search_root), &exact_key)
1441 .await
1442 .context("query profile-search root")?
1443 .with_context(|| format!("profile-search omitted exact key {exact_key}"))?;
1444 let entry: StoredProfileSearchEntry =
1445 serde_json::from_str(&encoded).context("decode stored profile search entry JSON")?;
1446 let expected_nhash = nhash_encode_full(&NHashData {
1447 hash: mirrored_cid.hash,
1448 decrypt_key: mirrored_cid.key,
1449 })
1450 .context("encode mirrored profile event nhash")?;
1451 if entry.pubkey != pubkey
1452 || entry.created_at != event.created_at.as_secs()
1453 || entry.event_nhash != expected_nhash
1454 {
1455 anyhow::bail!(
1456 "profile-search entry for {exact_key} does not match its profile-by-pubkey event"
1457 );
1458 }
1459 Ok(entry)
1460}
1461
1462pub fn open_social_graph_store_with_mapsize(
1463 data_dir: &Path,
1464 mapsize_bytes: Option<u64>,
1465) -> Result<Arc<SocialGraphStore>> {
1466 let db_dir = data_dir.join("socialgraph");
1467 open_social_graph_store_at_path(&db_dir, mapsize_bytes)
1468}
1469
1470pub fn open_social_graph_store_with_storage(
1471 data_dir: &Path,
1472 store: Arc<StorageRouter>,
1473 mapsize_bytes: Option<u64>,
1474) -> Result<Arc<SocialGraphStore>> {
1475 let db_dir = data_dir.join("socialgraph");
1476 open_social_graph_store_at_path_with_storage(&db_dir, store, mapsize_bytes)
1477}
1478
1479#[cfg(test)]
1480pub fn open_test_social_graph_store(data_dir: &Path) -> Result<Arc<SocialGraphStore>> {
1481 open_test_social_graph_store_with_mapsize(data_dir, None)
1482}
1483
1484#[cfg(test)]
1485pub fn open_test_social_graph_store_with_mapsize(
1486 data_dir: &Path,
1487 mapsize_bytes: Option<u64>,
1488) -> Result<Arc<SocialGraphStore>> {
1489 open_test_social_graph_store_at_path(&data_dir.join("socialgraph"), mapsize_bytes)
1490}
1491
1492#[cfg(test)]
1493pub fn open_test_social_graph_store_with_storage(
1494 data_dir: &Path,
1495 store: Arc<StorageRouter>,
1496 mapsize_bytes: Option<u64>,
1497) -> Result<Arc<SocialGraphStore>> {
1498 open_embedded_social_graph_store_with_storage(data_dir, store, mapsize_bytes)
1499}
1500
1501#[cfg(test)]
1502pub fn open_test_social_graph_store_at_path(
1503 db_dir: &Path,
1504 mapsize_bytes: Option<u64>,
1505) -> Result<Arc<SocialGraphStore>> {
1506 open_embedded_social_graph_store_at_path(db_dir, mapsize_bytes)
1507}
1508
1509pub fn open_embedded_social_graph_store_with_storage(
1510 data_dir: &Path,
1511 store: Arc<StorageRouter>,
1512 mapsize_bytes: Option<u64>,
1513) -> Result<Arc<SocialGraphStore>> {
1514 let db_dir = data_dir.join("socialgraph");
1515 open_social_graph_store_at_path_with_storage_and_env_flags(
1516 &db_dir,
1517 store,
1518 mapsize_bytes,
1519 EnvFlags::NO_LOCK,
1520 )
1521}
1522
1523pub fn open_social_graph_store_at_path(
1524 db_dir: &Path,
1525 mapsize_bytes: Option<u64>,
1526) -> Result<Arc<SocialGraphStore>> {
1527 let config = hashtree_config::Config::load_or_default();
1528 let backend = &config.storage.backend;
1529 let local_store = Arc::new(
1530 LocalStore::new_with_lmdb_map_size(db_dir.join("blobs"), backend, mapsize_bytes)
1531 .map_err(|err| anyhow::anyhow!("Failed to create social graph blob store: {err}"))?,
1532 );
1533 let store = Arc::new(StorageRouter::new(local_store));
1534 open_social_graph_store_at_path_with_storage(db_dir, store, mapsize_bytes)
1535}
1536
1537pub fn open_embedded_social_graph_store_at_path(
1538 db_dir: &Path,
1539 mapsize_bytes: Option<u64>,
1540) -> Result<Arc<SocialGraphStore>> {
1541 let local_store = Arc::new(
1542 LocalStore::new_with_lmdb_map_size(
1543 db_dir.join("blobs"),
1544 &hashtree_config::StorageBackend::Fs,
1545 mapsize_bytes,
1546 )
1547 .map_err(|err| anyhow::anyhow!("Failed to create social graph blob store: {err}"))?,
1548 );
1549 let store = Arc::new(StorageRouter::new(local_store));
1550 open_social_graph_store_at_path_with_storage_and_env_flags(
1551 db_dir,
1552 store,
1553 mapsize_bytes,
1554 EnvFlags::NO_LOCK,
1555 )
1556}
1557
1558pub fn open_social_graph_store_at_path_with_storage(
1559 db_dir: &Path,
1560 store: Arc<StorageRouter>,
1561 mapsize_bytes: Option<u64>,
1562) -> Result<Arc<SocialGraphStore>> {
1563 open_social_graph_store_at_path_with_storage_and_env_flags(
1564 db_dir,
1565 store,
1566 mapsize_bytes,
1567 EnvFlags::empty(),
1568 )
1569}
1570
1571fn open_social_graph_store_at_path_with_storage_and_env_flags(
1572 db_dir: &Path,
1573 store: Arc<StorageRouter>,
1574 mapsize_bytes: Option<u64>,
1575 env_flags: EnvFlags,
1576) -> Result<Arc<SocialGraphStore>> {
1577 let ambient_backend = store.local_store().backend();
1578 let ambient_local = Arc::new(
1579 LocalStore::new_with_lmdb_map_size(
1580 db_dir.join(AMBIENT_EVENTS_BLOB_DIR),
1581 &ambient_backend,
1582 mapsize_bytes,
1583 )
1584 .map_err(|err| {
1585 anyhow::anyhow!("Failed to create social graph ambient blob store: {err}")
1586 })?,
1587 );
1588 let ambient_store = Arc::new(StorageRouter::new(ambient_local));
1589 open_social_graph_store_at_path_with_storage_split_and_env_flags(
1590 db_dir,
1591 store,
1592 ambient_store,
1593 mapsize_bytes,
1594 env_flags,
1595 )
1596}
1597
1598pub fn open_social_graph_store_at_path_with_storage_split(
1599 db_dir: &Path,
1600 public_store: Arc<StorageRouter>,
1601 ambient_store: Arc<StorageRouter>,
1602 mapsize_bytes: Option<u64>,
1603) -> Result<Arc<SocialGraphStore>> {
1604 open_social_graph_store_at_path_with_storage_split_and_env_flags(
1605 db_dir,
1606 public_store,
1607 ambient_store,
1608 mapsize_bytes,
1609 EnvFlags::empty(),
1610 )
1611}
1612
1613fn open_social_graph_store_at_path_with_storage_split_and_env_flags(
1614 db_dir: &Path,
1615 public_store: Arc<StorageRouter>,
1616 ambient_store: Arc<StorageRouter>,
1617 mapsize_bytes: Option<u64>,
1618 env_flags: EnvFlags,
1619) -> Result<Arc<SocialGraphStore>> {
1620 std::fs::create_dir_all(db_dir)?;
1621 let _root_transaction = acquire_profile_root_pair_lock(
1622 &db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE),
1623 ProfileRootPairLockMode::Exclusive,
1624 true,
1625 )?;
1626 if incomplete_profile_repair_intent_path(&db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE))?.is_none() {
1627 recover_profile_root_pair_commit_locked(db_dir)?;
1628 }
1629 if let Some(size) = mapsize_bytes {
1630 ensure_social_graph_mapsize_with_env_flags(db_dir, size, env_flags)?;
1631 }
1632 let graph_map_size = social_graph_map_size(mapsize_bytes)?;
1633 let graph = unsafe {
1634 HeedSocialGraph::open_with_env_flags_and_map_size(
1635 db_dir,
1636 DEFAULT_ROOT_HEX,
1637 env_flags,
1638 graph_map_size,
1639 )
1640 }
1641 .context("open nostr-social-graph heed backend")?;
1642 let mut lifecycle_options = heed::EnvOpenOptions::new();
1643 lifecycle_options
1644 .map_size(graph_map_size)
1645 .max_dbs(SOCIALGRAPH_MAX_DBS);
1646 unsafe {
1647 lifecycle_options.flags(env_flags);
1648 }
1649 let graph_env_lifecycle = unsafe { ManagedEnv::open(&lifecycle_options, db_dir) }
1650 .context("manage nostr-social-graph heed backend lifecycle")?;
1651
1652 let graph_store = Arc::new(SocialGraphStore {
1653 graph: StdMutex::new(graph),
1654 _graph_env_lifecycle: graph_env_lifecycle,
1655 ambient_store: Arc::clone(&ambient_store),
1656 distance_cache: StdMutex::new(None),
1657 public_events: EventIndexBucket {
1658 event_store: NostrEventStore::new(Arc::clone(&public_store)),
1659 root_path: db_dir.join(EVENTS_ROOT_FILE),
1660 },
1661 ambient_events: EventIndexBucket {
1662 event_store: NostrEventStore::new(ambient_store),
1663 root_path: db_dir.join(AMBIENT_EVENTS_ROOT_FILE),
1664 },
1665 profile_index: ProfileIndexBucket {
1666 store: Arc::clone(&public_store),
1667 tree: HashTree::new(HashTreeConfig::new(Arc::clone(&public_store))),
1668 index: BTree::new(
1669 public_store,
1670 hashtree_index::BTreeOptions {
1671 order: Some(PROFILE_SEARCH_INDEX_ORDER),
1672 },
1673 ),
1674 by_pubkey_root_path: db_dir.join(PROFILES_BY_PUBKEY_ROOT_FILE),
1675 search_root_path: db_dir.join(PROFILE_SEARCH_ROOT_FILE),
1676 root_pair_commit_path: db_dir.join(PROFILE_ROOT_PAIR_COMMIT_FILE),
1677 root_pair_lock_path: db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE),
1678 },
1679 profile_index_overmute_threshold: StdMutex::new(1.0),
1680 });
1681 graph_store.recover_pending_profile_projection_locked()?;
1682 Ok(graph_store)
1683}
1684
1685pub fn set_social_graph_root(store: &SocialGraphStore, pk_bytes: &[u8; 32]) {
1686 if let Err(err) = store.set_root(pk_bytes) {
1687 tracing::warn!("Failed to set social graph root: {err}");
1688 }
1689}
1690
1691pub fn get_follow_distance(
1692 backend: &(impl SocialGraphBackend + ?Sized),
1693 pk_bytes: &[u8; 32],
1694) -> Option<u32> {
1695 backend.follow_distance(pk_bytes).ok().flatten()
1696}
1697
1698pub fn get_follows(
1699 backend: &(impl SocialGraphBackend + ?Sized),
1700 pk_bytes: &[u8; 32],
1701) -> Vec<[u8; 32]> {
1702 match backend.followed_targets(pk_bytes) {
1703 Ok(set) => set.into_iter().collect(),
1704 Err(_) => Vec::new(),
1705 }
1706}
1707
1708pub fn is_overmuted(
1709 backend: &(impl SocialGraphBackend + ?Sized),
1710 _root_pk: &[u8; 32],
1711 user_pk: &[u8; 32],
1712 threshold: f64,
1713) -> bool {
1714 backend
1715 .is_overmuted_user(user_pk, threshold)
1716 .unwrap_or(false)
1717}
1718
1719pub fn ingest_event(backend: &(impl SocialGraphBackend + ?Sized), _sub_id: &str, event_json: &str) {
1720 let event = match Event::from_json(event_json) {
1721 Ok(event) => event,
1722 Err(_) => return,
1723 };
1724
1725 if let Err(err) = backend.ingest_event(&event) {
1726 tracing::warn!("Failed to ingest social graph event: {err}");
1727 }
1728}
1729
1730pub fn ingest_parsed_event(
1731 backend: &(impl SocialGraphBackend + ?Sized),
1732 event: &Event,
1733) -> Result<()> {
1734 backend.ingest_event(event)
1735}
1736
1737pub fn ingest_parsed_event_with_storage_class(
1738 backend: &(impl SocialGraphBackend + ?Sized),
1739 event: &Event,
1740 storage_class: EventStorageClass,
1741) -> Result<()> {
1742 backend.ingest_event_with_storage_class(event, storage_class)
1743}
1744
1745pub fn ingest_parsed_events(
1746 backend: &(impl SocialGraphBackend + ?Sized),
1747 events: &[Event],
1748) -> Result<()> {
1749 backend.ingest_events(events)
1750}
1751
1752pub fn ingest_parsed_events_with_storage_class(
1753 backend: &(impl SocialGraphBackend + ?Sized),
1754 events: &[Event],
1755 storage_class: EventStorageClass,
1756) -> Result<()> {
1757 backend.ingest_events_with_storage_class(events, storage_class)
1758}
1759
1760pub fn ingest_graph_parsed_events(
1761 backend: &(impl SocialGraphBackend + ?Sized),
1762 events: &[Event],
1763) -> Result<()> {
1764 backend.ingest_graph_events(events)
1765}
1766
1767pub fn query_events(
1768 backend: &(impl SocialGraphBackend + ?Sized),
1769 filter: &Filter,
1770 limit: usize,
1771) -> Vec<Event> {
1772 backend.query_events(filter, limit).unwrap_or_default()
1773}
1774
1775impl SocialGraphStore {
1776 pub fn force_sync(&self) -> Result<()> {
1781 self._graph_env_lifecycle
1782 .force_sync()
1783 .context("force-sync social graph database")?;
1784 self.ambient_store
1785 .force_sync()
1786 .map_err(|err| anyhow::anyhow!("force-sync ambient event storage: {err}"))
1787 }
1788
1789 pub fn set_profile_index_overmute_threshold(&self, threshold: f64) {
1790 *self
1791 .profile_index_overmute_threshold
1792 .lock()
1793 .expect("profile index overmute threshold") = threshold;
1794 }
1795
1796 fn profile_index_overmute_threshold(&self) -> f64 {
1797 *self
1798 .profile_index_overmute_threshold
1799 .lock()
1800 .expect("profile index overmute threshold")
1801 }
1802
1803 fn invalidate_distance_cache(&self) {
1804 *self.distance_cache.lock().unwrap() = None;
1805 }
1806
1807 fn build_distance_cache(state: nostr_social_graph::SocialGraphState) -> Result<DistanceCache> {
1808 let unique_ids = state
1809 .unique_ids
1810 .into_iter()
1811 .map(|(pubkey, id)| decode_pubkey(&pubkey).map(|decoded| (id, decoded)))
1812 .collect::<Result<HashMap<_, _>>>()?;
1813
1814 let mut users_by_distance = BTreeMap::new();
1815 let mut size_by_distance = BTreeMap::new();
1816 for (distance, users) in state.users_by_follow_distance {
1817 let decoded = users
1818 .into_iter()
1819 .filter_map(|id| unique_ids.get(&id).copied())
1820 .collect::<Vec<_>>();
1821 size_by_distance.insert(distance, decoded.len());
1822 users_by_distance.insert(distance, decoded);
1823 }
1824
1825 let total_follows = state
1826 .followed_by_user
1827 .iter()
1828 .map(|(_, targets)| targets.len())
1829 .sum::<usize>();
1830 let total_users = size_by_distance.values().copied().sum();
1831 let max_depth = size_by_distance.keys().copied().max().unwrap_or_default();
1832
1833 Ok(DistanceCache {
1834 stats: SocialGraphStats {
1835 total_users,
1836 root: Some(state.root),
1837 total_follows,
1838 max_depth,
1839 size_by_distance,
1840 enabled: true,
1841 },
1842 users_by_distance,
1843 })
1844 }
1845
1846 fn load_distance_cache(&self) -> Result<DistanceCache> {
1847 if let Some(cache) = self.distance_cache.lock().unwrap().clone() {
1848 return Ok(cache);
1849 }
1850
1851 let state = {
1852 let graph = self.graph.lock().unwrap();
1853 graph.export_state().context("export social graph state")?
1854 };
1855 let cache = Self::build_distance_cache(state)?;
1856 *self.distance_cache.lock().unwrap() = Some(cache.clone());
1857 Ok(cache)
1858 }
1859
1860 fn set_root(&self, root: &[u8; 32]) -> Result<()> {
1861 let _transaction = self
1862 .profile_index
1863 .acquire_exclusive_root_pair_transaction()?;
1864 require_no_incomplete_profile_repair_for_root_write(
1865 &self.profile_index.root_pair_lock_path,
1866 )?;
1867 self.recover_profile_transactions_locked()?;
1868 let root_hex = hex::encode(root);
1869 {
1870 let mut graph = self.graph.lock().unwrap();
1871 if should_replace_placeholder_root(&graph)? {
1872 let fresh = SocialGraph::new(&root_hex);
1873 graph
1874 .replace_state(&fresh.export_state())
1875 .context("replace placeholder social graph root")?;
1876 } else {
1877 graph
1878 .set_root(&root_hex)
1879 .context("set nostr-social-graph root")?;
1880 }
1881 }
1882 self.invalidate_distance_cache();
1883 Ok(())
1884 }
1885
1886 fn stats(&self) -> Result<SocialGraphStats> {
1887 Ok(self.load_distance_cache()?.stats)
1888 }
1889
1890 fn follow_distance(&self, pk_bytes: &[u8; 32]) -> Result<Option<u32>> {
1891 let graph = self.graph.lock().unwrap();
1892 let distance = graph
1893 .get_follow_distance(&hex::encode(pk_bytes))
1894 .context("read social graph follow distance")?;
1895 Ok((distance != UNKNOWN_FOLLOW_DISTANCE).then_some(distance))
1896 }
1897
1898 fn users_by_follow_distance(&self, distance: u32) -> Result<Vec<[u8; 32]>> {
1899 Ok(self
1900 .load_distance_cache()?
1901 .users_by_distance
1902 .get(&distance)
1903 .cloned()
1904 .unwrap_or_default())
1905 }
1906
1907 fn follow_list_created_at(&self, owner: &[u8; 32]) -> Result<Option<u64>> {
1908 let graph = self.graph.lock().unwrap();
1909 graph
1910 .get_follow_list_created_at(&hex::encode(owner))
1911 .context("read social graph follow list timestamp")
1912 }
1913
1914 fn followed_targets(&self, owner: &[u8; 32]) -> Result<UserSet> {
1915 let graph = self.graph.lock().unwrap();
1916 decode_pubkey_set(
1917 graph
1918 .get_followed_by_user(&hex::encode(owner))
1919 .context("read followed targets")?,
1920 )
1921 }
1922
1923 fn is_overmuted_user(&self, user_pk: &[u8; 32], threshold: f64) -> Result<bool> {
1924 if threshold <= 0.0 {
1925 return Ok(false);
1926 }
1927 let graph = self.graph.lock().unwrap();
1928 graph
1929 .is_overmuted(&hex::encode(user_pk), threshold)
1930 .context("check social graph overmute")
1931 }
1932
1933 fn recovered_profile_index_roots(&self) -> Result<(Option<Cid>, Option<Cid>)> {
1934 let _transaction = self
1935 .profile_index
1936 .acquire_exclusive_root_pair_transaction()?;
1937 self.recover_profile_transactions_locked()?;
1938 self.profile_index.roots_locked()
1939 }
1940
1941 #[cfg_attr(not(test), allow(dead_code))]
1942 pub fn profile_search_root(&self) -> Result<Option<Cid>> {
1943 Ok(self.recovered_profile_index_roots()?.1)
1944 }
1945
1946 #[cfg_attr(not(test), allow(dead_code))]
1947 pub fn profiles_by_pubkey_root(&self) -> Result<Option<Cid>> {
1948 Ok(self.recovered_profile_index_roots()?.0)
1949 }
1950
1951 pub fn public_events_root(&self) -> Result<Option<Cid>> {
1952 self.public_events.events_root()
1953 }
1954
1955 #[cfg_attr(test, allow(dead_code))]
1956 pub(crate) fn public_events_root_for_write(&self) -> Result<Option<Cid>> {
1957 let _transaction = self
1958 .profile_index
1959 .acquire_exclusive_root_pair_transaction()?;
1960 require_no_incomplete_profile_repair_for_root_write(
1961 &self.profile_index.root_pair_lock_path,
1962 )?;
1963 self.recover_profile_transactions_locked()?;
1964 self.public_events.events_root_for_write()
1965 }
1966
1967 #[cfg(test)]
1968 pub(crate) fn write_public_events_root(&self, root: Option<&Cid>) -> Result<()> {
1969 self.public_events.write_events_root(root)
1970 }
1971
1972 fn pending_profile_projection_path(&self) -> PathBuf {
1973 self.profile_index
1974 .root_pair_lock_path
1975 .with_file_name(PROFILE_PROJECTION_PENDING_FILE)
1976 }
1977
1978 fn force_sync_event_storage(&self, storage_class: EventStorageClass) -> Result<()> {
1979 let store = match storage_class {
1980 EventStorageClass::Public => &self.profile_index.store,
1981 EventStorageClass::Ambient => &self.ambient_store,
1982 };
1983 store
1984 .force_sync()
1985 .map_err(|error| anyhow::anyhow!("force-sync derived event blocks: {error}"))
1986 }
1987
1988 fn force_sync_graph_projection_for_events(&self, events: &[Event]) -> Result<()> {
1989 if events.iter().any(|event| is_social_graph_event(event.kind)) {
1990 self._graph_env_lifecycle
1991 .force_sync()
1992 .context("force-sync derived social graph projection")?;
1993 }
1994 Ok(())
1995 }
1996
1997 fn retained_derived_events_at_root(
1998 &self,
1999 bucket: &EventIndexBucket,
2000 root: &Cid,
2001 events: &[Event],
2002 ) -> Result<Vec<Event>> {
2003 let mut retained = Vec::new();
2004 for event in events
2005 .iter()
2006 .filter(|event| is_derived_projection_event(event.kind))
2007 {
2008 event
2009 .verify()
2010 .with_context(|| format!("verify derived event {} before projection", event.id))?;
2011 match bucket.load_event_by_id(root, &event.id.to_hex())? {
2012 Some(stored) if same_unsigned_event(&stored, event) => retained.push(stored),
2013 Some(_) => {
2014 anyhow::bail!(
2015 "derived event {} resolved to different unsigned fields in candidate root",
2016 event.id
2017 )
2018 }
2019 None => {}
2020 }
2021 }
2022 Ok(retained)
2023 }
2024
2025 fn load_full_derived_events_at_root(
2026 &self,
2027 bucket: &EventIndexBucket,
2028 root: &Cid,
2029 ) -> Result<Vec<Event>> {
2030 block_on(bucket.event_store.validate_index_root(Some(root)))
2031 .map_err(map_event_store_error)
2032 .context("validate full derived-projection event root")?;
2033 let mut events = Vec::new();
2034 for kind in [Kind::ContactList, Kind::MuteList, Kind::Metadata] {
2035 let stored = block_on(bucket.event_store.list_by_kind(
2036 Some(root),
2037 kind.as_u16() as u32,
2038 ListEventsOptions::default(),
2039 ))
2040 .map_err(map_event_store_error)?;
2041 events.extend(
2042 stored
2043 .into_iter()
2044 .map(stored_event_to_nostr_event)
2045 .collect::<Result<Vec<_>>>()?,
2046 );
2047 }
2048 Ok(events)
2049 }
2050
2051 fn persist_pending_profile_projection_locked(
2052 &self,
2053 projection: &PendingProfileProjection,
2054 ) -> Result<()> {
2055 require_no_incomplete_profile_repair_for_root_write(
2056 &self.profile_index.root_pair_lock_path,
2057 )?;
2058 let path = self.pending_profile_projection_path();
2059 replace_file_durable(
2060 &path,
2061 &pending_profile_projection_bytes(projection)?,
2062 "pending profile projection",
2063 )?;
2064 #[cfg(test)]
2065 run_pending_profile_projection_persisted_probe(&path)?;
2066 Ok(())
2067 }
2068
2069 fn clear_pending_profile_projection_locked(&self) -> Result<()> {
2070 remove_file_durable(&self.pending_profile_projection_path())
2071 }
2072
2073 fn recover_profile_transactions_locked(&self) -> Result<()> {
2074 if incomplete_profile_repair_intent_path(&self.profile_index.root_pair_lock_path)?.is_some()
2075 {
2076 return Ok(());
2077 }
2078 self.profile_index
2079 .recover_pending_root_pair_commit_locked()?;
2080 self.recover_pending_profile_projection_locked()
2081 }
2082
2083 fn recover_profile_transactions_locked_for_repair(
2084 &self,
2085 authority: &ProfileIndexRepairAuthority,
2086 ) -> Result<()> {
2087 let db_dir = self
2088 .profile_index
2089 .root_pair_lock_path
2090 .parent()
2091 .context("profile root-pair lock has no database parent")?;
2092 require_no_pending_profile_projection(db_dir)?;
2093 self.profile_index
2094 .recover_pending_root_pair_commit_locked_for_repair(authority)
2095 }
2096
2097 fn recover_pending_profile_projection_locked(&self) -> Result<()> {
2098 if incomplete_profile_repair_intent_path(&self.profile_index.root_pair_lock_path)?.is_some()
2099 {
2100 return Ok(());
2101 }
2102 let path = self.pending_profile_projection_path();
2103 let Some(projection) = load_pending_profile_projection(&path)? else {
2104 return Ok(());
2105 };
2106 let storage_class = EventStorageClass::from(projection.storage_class);
2107 let bucket = self.bucket(storage_class);
2108 let current_root = bucket.events_root()?;
2109 let (old_root, new_root) = match &projection.projection {
2110 PendingProfileProjectionMode::Incremental {
2111 old_root, new_root, ..
2112 }
2113 | PendingProfileProjectionMode::RebuildPublicRoot { old_root, new_root } => (
2114 old_root.clone().map(cid_from_stored),
2115 cid_from_stored(new_root.clone()),
2116 ),
2117 };
2118
2119 if current_root.as_ref() != Some(&new_root) {
2120 if current_root == old_root {
2121 return self.clear_pending_profile_projection_locked();
2122 }
2123 anyhow::bail!(
2124 "event root does not match the pre- or post-publication state required by pending profile projection {}",
2125 path.display()
2126 );
2127 }
2128
2129 match projection.projection {
2130 PendingProfileProjectionMode::Incremental { events, .. } => {
2131 block_on(bucket.event_store.validate_index_root(Some(&new_root)))
2132 .map_err(map_event_store_error)
2133 .with_context(|| {
2134 format!(
2135 "validate published event root required by pending derived projection {}",
2136 path.display()
2137 )
2138 })?;
2139 if events.is_empty() {
2140 anyhow::bail!(
2141 "pending incremental derived projection {} contains no events",
2142 path.display()
2143 );
2144 }
2145 let events = events
2146 .into_iter()
2147 .map(|json| {
2148 Event::from_json(json).context("decode pending derived projection event")
2149 })
2150 .collect::<Result<Vec<_>>>()?;
2151 let mut canonical_events = Vec::with_capacity(events.len());
2152 for event in events {
2153 if !is_derived_projection_event(event.kind) {
2154 anyhow::bail!(
2155 "pending derived projection {} contains unsupported event kind {}",
2156 path.display(),
2157 event.kind.as_u16()
2158 );
2159 }
2160 event.verify().with_context(|| {
2161 format!(
2162 "verify pending derived event {} from {}",
2163 event.id,
2164 path.display()
2165 )
2166 })?;
2167 let stored = bucket
2168 .load_event_by_id(&new_root, &event.id.to_hex())?
2169 .with_context(|| {
2170 format!(
2171 "pending derived event {} is absent from its published event root",
2172 event.id
2173 )
2174 })?;
2175 if !same_unsigned_event(&stored, &event) {
2176 anyhow::bail!(
2177 "pending derived event {} resolved to different unsigned fields in {}",
2178 event.id,
2179 path.display()
2180 );
2181 }
2182 canonical_events.push(stored);
2183 }
2184 self.apply_graph_events_only_locked(&canonical_events)?;
2185 self.update_profile_index_for_events_locked(&canonical_events)?;
2186 self.force_sync_graph_projection_for_events(&canonical_events)?;
2187 }
2188 PendingProfileProjectionMode::RebuildPublicRoot { .. } => {
2189 if storage_class != EventStorageClass::Public {
2190 anyhow::bail!(
2191 "pending full profile rebuild {} must target the public event root",
2192 path.display()
2193 );
2194 }
2195 let events = self.load_full_derived_events_at_root(bucket, &new_root)?;
2196 self.apply_graph_events_only_locked(&events)?;
2197 self.rebuild_profile_index_for_events_locked(&events)?;
2198 self.force_sync_graph_projection_for_events(&events)?;
2199 }
2200 }
2201 self.clear_pending_profile_projection_locked()
2202 }
2203
2204 pub(crate) fn apply_public_events_root_and_projections(
2205 &self,
2206 expected_old_root: Option<&Cid>,
2207 root: Option<&Cid>,
2208 events: &[Event],
2209 rebuild_profile_index: bool,
2210 ) -> Result<PublicEventsRootApplyOutcome> {
2211 let _transaction = self
2212 .profile_index
2213 .acquire_exclusive_root_pair_transaction()?;
2214 require_no_incomplete_profile_repair_for_root_write(
2215 &self.profile_index.root_pair_lock_path,
2216 )?;
2217 self.recover_profile_transactions_locked()?;
2218 let old_root = self.public_events.events_root_for_write()?;
2219 if old_root.as_ref() != expected_old_root {
2220 return Ok(PublicEventsRootApplyOutcome::Conflict {
2221 current_root: old_root,
2222 });
2223 }
2224 let projection_events = match root {
2225 Some(root) if rebuild_profile_index => {
2226 self.load_full_derived_events_at_root(&self.public_events, root)?
2227 }
2228 Some(root) => {
2229 self.retained_derived_events_at_root(&self.public_events, root, events)?
2230 }
2231 None => Vec::new(),
2232 };
2233 let profile_projection = if rebuild_profile_index || !projection_events.is_empty() {
2234 let new_root = root.context("derived projection requires a public event root")?;
2235 self.force_sync_event_storage(EventStorageClass::Public)?;
2236 Some(PendingProfileProjection {
2237 version: PROFILE_PROJECTION_PENDING_VERSION,
2238 storage_class: StoredEventStorageClass::Public,
2239 projection: if rebuild_profile_index {
2240 PendingProfileProjectionMode::RebuildPublicRoot {
2241 old_root: old_root.as_ref().map(stored_cid),
2242 new_root: stored_cid(new_root),
2243 }
2244 } else {
2245 PendingProfileProjectionMode::Incremental {
2246 old_root: old_root.as_ref().map(stored_cid),
2247 new_root: stored_cid(new_root),
2248 events: projection_events.iter().map(JsonUtil::as_json).collect(),
2249 }
2250 },
2251 })
2252 } else {
2253 None
2254 };
2255 if let Some(projection) = profile_projection.as_ref() {
2256 self.persist_pending_profile_projection_locked(projection)?;
2257 self.public_events.write_events_root_durable(root)?;
2258 } else {
2259 self.public_events.write_events_root(root)?;
2260 }
2261 self.apply_graph_events_only_locked(&projection_events)?;
2262 if profile_projection.is_some() {
2263 if rebuild_profile_index {
2264 self.rebuild_profile_index_for_events_locked(&projection_events)?;
2265 } else {
2266 self.update_profile_index_for_events_locked(&projection_events)?;
2267 }
2268 self.force_sync_graph_projection_for_events(&projection_events)?;
2269 self.clear_pending_profile_projection_locked()?;
2270 }
2271 Ok(PublicEventsRootApplyOutcome::Applied)
2272 }
2273
2274 #[cfg_attr(not(test), allow(dead_code))]
2275 pub fn latest_profile_event(&self, pubkey_hex: &str) -> Result<Option<Event>> {
2276 let (root, _) = self.recovered_profile_index_roots()?;
2277 self.profile_index
2278 .profile_event_for_pubkey_at_root(root.as_ref(), pubkey_hex)
2279 }
2280
2281 #[cfg_attr(not(test), allow(dead_code))]
2282 pub fn profile_search_entries_for_prefix(
2283 &self,
2284 prefix: &str,
2285 ) -> Result<Vec<(String, StoredProfileSearchEntry)>> {
2286 let (_, root) = self.recovered_profile_index_roots()?;
2287 let Some(root) = root else {
2288 return Ok(Vec::new());
2289 };
2290 self.profile_index
2291 .search_entries_for_prefix_at_root(&root, prefix)
2292 }
2293
2294 pub fn validate_profile_indexes_for_event(
2302 &self,
2303 event: &Event,
2304 ) -> Result<StoredProfileSearchEntry> {
2305 if event.kind != Kind::Metadata {
2306 anyhow::bail!("profile index validation requires a kind-0 metadata event");
2307 }
2308
2309 let pubkey = event.pubkey.to_hex();
2310 let (by_pubkey_root, search_root) = self.recovered_profile_index_roots()?;
2311 let by_pubkey_root = by_pubkey_root.context("profile-by-pubkey root is missing")?;
2312 let search_root = search_root.context("profile-search root is missing")?;
2313 let mirrored_cid = block_on(
2314 self.profile_index
2315 .index
2316 .get_link(Some(&by_pubkey_root), &pubkey),
2317 )
2318 .context("query profile-by-pubkey root")?
2319 .with_context(|| format!("profile-by-pubkey omitted {pubkey}"))?;
2320 let mirrored = self
2321 .profile_index
2322 .load_profile_event(&mirrored_cid)?
2323 .with_context(|| format!("mirrored profile blob for {pubkey} is missing"))?;
2324 if mirrored.id != event.id {
2325 anyhow::bail!(
2326 "profile-by-pubkey returned event {} instead of {} for {pubkey}",
2327 mirrored.id,
2328 event.id
2329 );
2330 }
2331
2332 let term = profile_search_terms_for_event(event)
2333 .into_iter()
2334 .next()
2335 .with_context(|| format!("profile {pubkey} did not produce a search term"))?;
2336 let exact_key = format!("{PROFILE_SEARCH_PREFIX}{term}:{pubkey}");
2337 let encoded = block_on(self.profile_index.index.get(Some(&search_root), &exact_key))
2338 .context("query profile-search root")?
2339 .with_context(|| format!("profile-search omitted exact key {exact_key}"))?;
2340 let entry: StoredProfileSearchEntry =
2341 serde_json::from_str(&encoded).context("decode stored profile search entry JSON")?;
2342 let expected_nhash = nhash_encode_full(&NHashData {
2343 hash: mirrored_cid.hash,
2344 decrypt_key: mirrored_cid.key,
2345 })
2346 .context("encode mirrored profile event nhash")?;
2347 if entry.pubkey != pubkey
2348 || entry.created_at != event.created_at.as_secs()
2349 || entry.event_nhash != expected_nhash
2350 {
2351 anyhow::bail!(
2352 "profile-search entry for {exact_key} does not match its profile-by-pubkey event"
2353 );
2354 }
2355 Ok(entry)
2356 }
2357
2358 pub fn sync_profile_index_for_events(&self, events: &[Event]) -> Result<()> {
2359 self.update_profile_index_for_events(events)
2360 }
2361
2362 pub fn sync_profile_index_for_events_with_frozen_distances(
2366 &self,
2367 events: &[Event],
2368 decisions: &BTreeMap<String, Option<u32>>,
2369 ) -> Result<()> {
2370 self.update_profile_index_for_events_with(events, true, |event| {
2371 let pubkey = event.pubkey.to_hex();
2372 match decisions.get(&pubkey) {
2373 Some(Some(distance)) => Ok((Some(*distance), false)),
2374 Some(None) => Ok((None, true)),
2375 None => {
2376 anyhow::bail!("frozen profile rank decisions omitted metadata author {pubkey}")
2377 }
2378 }
2379 })
2380 }
2381
2382 pub fn build_unpublished_profile_index_repair_with_frozen_distances(
2390 &self,
2391 events: &[Event],
2392 decisions: &BTreeMap<String, Option<u32>>,
2393 ) -> Result<PreparedProfileIndexRepair> {
2394 if events.is_empty() {
2395 anyhow::bail!("profile-index repair requires retained kind-0 winners");
2396 }
2397 let _transaction = self
2398 .profile_index
2399 .acquire_exclusive_root_pair_transaction()?;
2400 self.recover_profile_transactions_locked()?;
2401 let old_roots = self.profile_index_roots_locked()?;
2402 let latest_by_pubkey = latest_metadata_events_by_pubkey(events);
2403 if latest_by_pubkey.len() != events.len() {
2404 anyhow::bail!(
2405 "profile-index repair inputs must contain exactly one kind-0 winner per pubkey"
2406 );
2407 }
2408 for (pubkey, event) in &latest_by_pubkey {
2409 if event.pubkey.to_hex() != *pubkey || event.kind != Kind::Metadata {
2410 anyhow::bail!("profile-index repair input for {pubkey} is not canonical metadata");
2411 }
2412 event.verify().with_context(|| {
2413 format!("verify retained profile-index repair event for {pubkey}")
2414 })?;
2415 match decisions.get(pubkey) {
2416 Some(Some(_)) => {}
2417 Some(None) => {
2418 anyhow::bail!("profile-index repair retained excluded metadata author {pubkey}")
2419 }
2420 None => anyhow::bail!(
2421 "profile-index repair rank decisions omitted metadata author {pubkey}"
2422 ),
2423 }
2424 }
2425 let (by_pubkey, search) = self
2426 .profile_index
2427 .rebuild_profile_events_with_distances_locked(
2428 latest_by_pubkey.into_values(),
2429 |event| {
2430 decisions
2431 .get(&event.pubkey.to_hex())
2432 .copied()
2433 .flatten()
2434 .map(Some)
2435 .context("eligible profile-index repair rank disappeared")
2436 },
2437 )?;
2438 let by_pubkey = by_pubkey.context("profile-index repair built an empty by-pubkey root")?;
2439 let search = search.context("profile-index repair built an empty search root")?;
2440 self.profile_index
2441 .store
2442 .force_sync()
2443 .map_err(|error| anyhow::anyhow!("force-sync unpublished profile repair: {error}"))?;
2444 let new_roots = ProfileIndexRoots {
2445 by_pubkey_file_sha256: Some(profile_index_root_file_sha256(&by_pubkey)?),
2446 search_file_sha256: Some(profile_index_root_file_sha256(&search)?),
2447 by_pubkey: Some(by_pubkey),
2448 search: Some(search),
2449 };
2450 Ok(PreparedProfileIndexRepair {
2451 old_roots,
2452 new_roots,
2453 })
2454 }
2455
2456 #[cfg(test)]
2457 pub(crate) fn crash_after_prepared_profile_root_pair_intent(
2458 &self,
2459 prepared: &PreparedProfileIndexRepair,
2460 ) -> Result<()> {
2461 let current = self.recovered_profile_index_roots()?;
2462 let current = ProfileIndexRoots {
2463 by_pubkey_file_sha256: current
2464 .0
2465 .as_ref()
2466 .map(profile_index_root_file_sha256)
2467 .transpose()?,
2468 search_file_sha256: current
2469 .1
2470 .as_ref()
2471 .map(profile_index_root_file_sha256)
2472 .transpose()?,
2473 by_pubkey: current.0,
2474 search: current.1,
2475 };
2476 if current != prepared.old_roots {
2477 anyhow::bail!("generated crash requires the exact prepared old root pair");
2478 }
2479 self.profile_index.write_roots_interrupted_after_intent(
2480 prepared.new_roots.by_pubkey.as_ref(),
2481 prepared.new_roots.search.as_ref(),
2482 )
2483 }
2484
2485 pub fn commit_prepared_profile_index_repair(
2490 &self,
2491 prepared: &PreparedProfileIndexRepair,
2492 authority: ProfileIndexRepairAuthority,
2493 ) -> Result<ProfileIndexRepairCommitOutcome> {
2494 Ok(self
2495 .commit_prepared_profile_index_repair_held(prepared, authority)?
2496 .outcome())
2497 }
2498
2499 pub fn authorize_prepared_profile_index_repair(
2502 &self,
2503 prepared: &PreparedProfileIndexRepair,
2504 validated_intent_bytes: &[u8],
2505 ) -> Result<ProfileIndexRepairAuthority> {
2506 load_profile_index_repair_authority(
2507 &self.profile_index.root_pair_lock_path,
2508 prepared,
2509 ProfileIndexRepairAuthorityPhase::Commit,
2510 Some(validated_intent_bytes),
2511 None,
2512 )
2513 }
2514
2515 pub fn authorize_completed_profile_index_repair(
2518 &self,
2519 prepared: &PreparedProfileIndexRepair,
2520 validated_intent_bytes: &[u8],
2521 validated_receipt_bytes: &[u8],
2522 ) -> Result<ProfileIndexRepairAuthority> {
2523 load_profile_index_repair_authority(
2524 &self.profile_index.root_pair_lock_path,
2525 prepared,
2526 ProfileIndexRepairAuthorityPhase::Completion,
2527 Some(validated_intent_bytes),
2528 Some(validated_receipt_bytes),
2529 )
2530 }
2531
2532 pub fn commit_prepared_profile_index_repair_held(
2537 &self,
2538 prepared: &PreparedProfileIndexRepair,
2539 authority: ProfileIndexRepairAuthority,
2540 ) -> Result<ProfileIndexRepairPublicationGuard> {
2541 self.commit_prepared_profile_index_repair_held_with(prepared, || Ok(authority))
2542 }
2543
2544 pub fn commit_prepared_profile_index_repair_held_with<F>(
2549 &self,
2550 prepared: &PreparedProfileIndexRepair,
2551 before_commit: F,
2552 ) -> Result<ProfileIndexRepairPublicationGuard>
2553 where
2554 F: FnOnce() -> Result<ProfileIndexRepairAuthority>,
2555 {
2556 let transaction = self
2557 .profile_index
2558 .acquire_exclusive_root_pair_transaction()?;
2559 let intent_preexisting =
2560 incomplete_profile_repair_intent_path(&self.profile_index.root_pair_lock_path)?
2561 .is_some();
2562 if !intent_preexisting {
2563 self.recover_profile_transactions_locked()?;
2564 let current = self.profile_index_roots_locked()?;
2565 if current != prepared.old_roots {
2566 anyhow::bail!(
2567 "published profile roots changed after repair preparation; refusing non-CAS commit"
2568 );
2569 }
2570 }
2571 let authority = before_commit()?;
2572 revalidate_profile_index_repair_authority(
2573 &authority,
2574 &self.profile_index.root_pair_lock_path,
2575 prepared,
2576 ProfileIndexRepairAuthorityPhase::Commit,
2577 )?;
2578 self.recover_profile_transactions_locked_for_repair(&authority)?;
2579 let current = self.profile_index_roots_locked()?;
2580 if current != prepared.old_roots && current != prepared.new_roots {
2581 anyhow::bail!(
2582 "published profile roots changed after repair preparation; refusing non-CAS commit"
2583 );
2584 }
2585 let outcome = if current == prepared.new_roots {
2586 ProfileIndexRepairCommitOutcome::AlreadyApplied
2587 } else {
2588 self.profile_index
2589 .write_roots_with_hooks_locked_for_repair(
2590 &authority,
2591 prepared.new_roots.by_pubkey.as_ref(),
2592 prepared.new_roots.search.as_ref(),
2593 || Ok(()),
2594 || Ok(()),
2595 )?;
2596 ProfileIndexRepairCommitOutcome::Applied
2597 };
2598 let installed = self.profile_index_roots_locked()?;
2599 if installed != prepared.new_roots {
2600 anyhow::bail!("profile-index repair commit did not install the exact prepared pair");
2601 }
2602 Ok(ProfileIndexRepairPublicationGuard {
2603 by_pubkey_root_path: self.profile_index.by_pubkey_root_path.clone(),
2604 search_root_path: self.profile_index.search_root_path.clone(),
2605 installed_roots: installed,
2606 outcome,
2607 _transaction: transaction,
2608 })
2609 }
2610
2611 pub fn hold_completed_profile_index_repair(
2616 &self,
2617 prepared: &PreparedProfileIndexRepair,
2618 authority: ProfileIndexRepairAuthority,
2619 ) -> Result<ProfileIndexRepairPublicationGuard> {
2620 let transaction = self
2621 .profile_index
2622 .acquire_exclusive_root_pair_transaction()?;
2623 revalidate_profile_index_repair_authority(
2624 &authority,
2625 &self.profile_index.root_pair_lock_path,
2626 prepared,
2627 ProfileIndexRepairAuthorityPhase::Completion,
2628 )?;
2629 let db_dir = self
2630 .profile_index
2631 .root_pair_lock_path
2632 .parent()
2633 .context("profile root-pair lock has no database parent")?;
2634 require_no_pending_profile_root_pair_commit(db_dir)?;
2635 require_no_pending_profile_projection(db_dir)?;
2636 let installed = self.profile_index_roots_locked()?;
2637 if installed != prepared.new_roots {
2638 anyhow::bail!(
2639 "completed profile repair roots differ from the exact installed repair pair"
2640 );
2641 }
2642 Ok(ProfileIndexRepairPublicationGuard {
2643 by_pubkey_root_path: self.profile_index.by_pubkey_root_path.clone(),
2644 search_root_path: self.profile_index.search_root_path.clone(),
2645 installed_roots: installed,
2646 outcome: ProfileIndexRepairCommitOutcome::AlreadyApplied,
2647 _transaction: transaction,
2648 })
2649 }
2650
2651 fn profile_index_roots_locked(&self) -> Result<ProfileIndexRoots> {
2652 let (by_pubkey, by_pubkey_file_sha256) =
2653 read_root_file_snapshot(&self.profile_index.by_pubkey_root_path)?;
2654 let (search, search_file_sha256) =
2655 read_root_file_snapshot(&self.profile_index.search_root_path)?;
2656 Ok(ProfileIndexRoots {
2657 by_pubkey,
2658 search,
2659 by_pubkey_file_sha256,
2660 search_file_sha256,
2661 })
2662 }
2663
2664 #[cfg_attr(not(test), allow(dead_code))]
2665 pub(crate) fn rebuild_profile_index_for_events(&self, events: &[Event]) -> Result<()> {
2666 let _transaction = self
2667 .profile_index
2668 .acquire_exclusive_root_pair_transaction()?;
2669 self.recover_profile_transactions_locked()?;
2670 self.rebuild_profile_index_for_events_locked(events)
2671 }
2672
2673 fn rebuild_profile_index_for_events_locked(&self, events: &[Event]) -> Result<()> {
2674 let latest_by_pubkey = self.filtered_latest_metadata_events_by_pubkey(events)?;
2675 self.profile_index
2676 .rebuild_profile_events_and_commit_with_distances_locked(
2677 latest_by_pubkey.into_values(),
2678 |event| self.follow_distance(&event.pubkey.to_bytes()),
2679 )
2680 }
2681
2682 async fn rebuild_profile_index_for_events_async_locked(&self, events: &[Event]) -> Result<()> {
2683 let latest_by_pubkey = self.filtered_latest_metadata_events_by_pubkey(events)?;
2684 self.profile_index
2685 .rebuild_profile_events_async_and_commit_with_distances_locked(
2686 latest_by_pubkey.into_values(),
2687 |event| self.follow_distance(&event.pubkey.to_bytes()),
2688 )
2689 .await
2690 }
2691
2692 pub fn rebuild_profile_index_from_stored_events(&self) -> Result<usize> {
2693 let _transaction = self
2694 .profile_index
2695 .acquire_exclusive_root_pair_transaction()?;
2696 self.rebuild_profile_index_from_stored_events_locked()
2697 }
2698
2699 fn rebuild_profile_index_from_stored_events_locked(&self) -> Result<usize> {
2700 self.recover_profile_transactions_locked()?;
2701 let public_events_root = self.public_events.events_root()?;
2702 let ambient_events_root = self.ambient_events.events_root()?;
2703 if public_events_root.is_none() && ambient_events_root.is_none() {
2704 self.profile_index
2705 .write_roots_with_hooks_locked(None, None, || Ok(()), || Ok(()))?;
2706 return Ok(0);
2707 }
2708
2709 let mut events = Vec::new();
2710 for (bucket, root) in [
2711 (&self.public_events, public_events_root),
2712 (&self.ambient_events, ambient_events_root),
2713 ] {
2714 let Some(root) = root else {
2715 continue;
2716 };
2717 let stored = block_on(bucket.event_store.list_by_kind_lossy(
2718 Some(&root),
2719 Kind::Metadata.as_u16() as u32,
2720 ListEventsOptions::default(),
2721 ))
2722 .map_err(map_event_store_error)?;
2723 events.extend(
2724 stored
2725 .into_iter()
2726 .map(stored_event_to_nostr_event)
2727 .collect::<Result<Vec<_>>>()?,
2728 );
2729 }
2730
2731 let latest_count = self
2732 .filtered_latest_metadata_events_by_pubkey(&events)?
2733 .len();
2734 self.rebuild_profile_index_for_events_locked(&events)?;
2735 Ok(latest_count)
2736 }
2737
2738 pub async fn rebuild_profile_index_from_stored_events_async(&self) -> Result<usize> {
2739 let _transaction = self
2740 .profile_index
2741 .acquire_exclusive_root_pair_transaction_async()
2742 .await?;
2743 self.rebuild_profile_index_from_stored_events_async_locked()
2744 .await
2745 }
2746
2747 async fn rebuild_profile_index_from_stored_events_async_locked(&self) -> Result<usize> {
2748 self.recover_profile_transactions_locked()?;
2749 let public_events_root = self.public_events.events_root()?;
2750 let ambient_events_root = self.ambient_events.events_root()?;
2751 if public_events_root.is_none() && ambient_events_root.is_none() {
2752 self.profile_index
2753 .write_roots_with_hooks_locked(None, None, || Ok(()), || Ok(()))?;
2754 return Ok(0);
2755 }
2756
2757 let mut events = Vec::new();
2758 for (bucket, root) in [
2759 (&self.public_events, public_events_root),
2760 (&self.ambient_events, ambient_events_root),
2761 ] {
2762 let Some(root) = root else {
2763 continue;
2764 };
2765 let stored = bucket
2766 .event_store
2767 .list_by_kind_lossy(
2768 Some(&root),
2769 Kind::Metadata.as_u16() as u32,
2770 ListEventsOptions::default(),
2771 )
2772 .await
2773 .map_err(map_event_store_error)?;
2774 events.extend(
2775 stored
2776 .into_iter()
2777 .map(stored_event_to_nostr_event)
2778 .collect::<Result<Vec<_>>>()?,
2779 );
2780 }
2781
2782 let latest_count = self
2783 .filtered_latest_metadata_events_by_pubkey(&events)?
2784 .len();
2785 self.rebuild_profile_index_for_events_async_locked(&events)
2786 .await?;
2787 Ok(latest_count)
2788 }
2789
2790 pub fn rebuild_event_indexes_from_stored_events(&self) -> Result<(usize, usize)> {
2791 let _transaction = self
2792 .profile_index
2793 .acquire_exclusive_root_pair_transaction()?;
2794 require_no_incomplete_profile_repair_for_root_write(
2795 &self.profile_index.root_pair_lock_path,
2796 )?;
2797 self.recover_profile_transactions_locked()?;
2798 let public_count =
2799 self.rebuild_event_index_bucket_from_stored_events(&self.public_events)?;
2800 let ambient_count =
2801 self.rebuild_event_index_bucket_from_stored_events(&self.ambient_events)?;
2802 self.rebuild_profile_index_from_stored_events_locked()?;
2803 Ok((public_count, ambient_count))
2804 }
2805
2806 pub async fn rebuild_event_indexes_from_stored_events_async(&self) -> Result<(usize, usize)> {
2807 let _transaction = self
2808 .profile_index
2809 .acquire_exclusive_root_pair_transaction_async()
2810 .await?;
2811 require_no_incomplete_profile_repair_for_root_write(
2812 &self.profile_index.root_pair_lock_path,
2813 )?;
2814 self.recover_profile_transactions_locked()?;
2815 let public_count = self
2816 .rebuild_event_index_bucket_from_stored_events_async(&self.public_events)
2817 .await?;
2818 let ambient_count = self
2819 .rebuild_event_index_bucket_from_stored_events_async(&self.ambient_events)
2820 .await?;
2821 self.rebuild_profile_index_from_stored_events_async_locked()
2822 .await?;
2823 Ok((public_count, ambient_count))
2824 }
2825
2826 fn rebuild_event_index_bucket_from_stored_events(
2827 &self,
2828 bucket: &EventIndexBucket,
2829 ) -> Result<usize> {
2830 let Some(root) = bucket.events_root()? else {
2831 bucket.write_events_root(None)?;
2832 return Ok(0);
2833 };
2834
2835 let manifest = match block_on(bucket.event_store.get_manifest(Some(&root))) {
2836 Ok(manifest) => manifest,
2837 Err(err) => {
2838 tracing::warn!(
2839 "Clearing invalid social graph event index root {} before rebuild: {}",
2840 hex::encode(root.hash),
2841 err
2842 );
2843 bucket.write_events_root(None)?;
2844 return Ok(0);
2845 }
2846 };
2847 if manifest.by_kind_time_author.is_none() {
2848 let next_root = block_on(bucket.event_store.upgrade_manifest_indexes(Some(&root)))
2849 .map_err(map_event_store_error)?;
2850 if next_root.as_ref() != Some(&root) {
2851 bucket.write_events_root(next_root.as_ref())?;
2852 return Ok(0);
2853 }
2854 }
2855
2856 let stored = block_on(
2857 bucket
2858 .event_store
2859 .list_recent_lossy(Some(&root), ListEventsOptions::default()),
2860 )
2861 .map_err(map_event_store_error)?;
2862 let count = stored.len();
2863 let next_root =
2864 block_on(bucket.event_store.build(None, stored)).map_err(map_event_store_error)?;
2865 bucket.write_events_root(next_root.as_ref())?;
2866 Ok(count)
2867 }
2868
2869 async fn rebuild_event_index_bucket_from_stored_events_async(
2870 &self,
2871 bucket: &EventIndexBucket,
2872 ) -> Result<usize> {
2873 let Some(root) = bucket.events_root()? else {
2874 bucket.write_events_root(None)?;
2875 return Ok(0);
2876 };
2877
2878 let manifest = match bucket.event_store.get_manifest(Some(&root)).await {
2879 Ok(manifest) => manifest,
2880 Err(err) => {
2881 tracing::warn!(
2882 "Clearing invalid social graph event index root {} before rebuild: {}",
2883 hex::encode(root.hash),
2884 err
2885 );
2886 bucket.write_events_root(None)?;
2887 return Ok(0);
2888 }
2889 };
2890 if manifest.by_kind_time_author.is_none() {
2891 let next_root = bucket
2892 .event_store
2893 .upgrade_manifest_indexes(Some(&root))
2894 .await
2895 .map_err(map_event_store_error)?;
2896 if next_root.as_ref() != Some(&root) {
2897 bucket.write_events_root(next_root.as_ref())?;
2898 return Ok(0);
2899 }
2900 }
2901
2902 let stored = bucket
2903 .event_store
2904 .list_recent_lossy(Some(&root), ListEventsOptions::default())
2905 .await
2906 .map_err(map_event_store_error)?;
2907 let count = stored.len();
2908 let next_root = bucket
2909 .event_store
2910 .build(None, stored)
2911 .await
2912 .map_err(map_event_store_error)?;
2913 bucket.write_events_root(next_root.as_ref())?;
2914 Ok(count)
2915 }
2916
2917 fn update_profile_index_for_events(&self, events: &[Event]) -> Result<()> {
2918 if !events.iter().any(|event| event.kind == Kind::Metadata) {
2919 return Ok(());
2920 }
2921 let _transaction = self
2922 .profile_index
2923 .acquire_exclusive_root_pair_transaction()?;
2924 self.recover_profile_transactions_locked()?;
2925 self.update_profile_index_for_events_locked(events)
2926 }
2927
2928 fn update_profile_index_for_events_locked(&self, events: &[Event]) -> Result<()> {
2929 let threshold = self.profile_index_overmute_threshold();
2930 self.update_profile_index_for_events_with_locked(events, false, |event| {
2931 let overmuted = self.is_overmuted_user(&event.pubkey.to_bytes(), threshold)?;
2932 let follow_distance = if overmuted {
2933 None
2934 } else {
2935 self.follow_distance(&event.pubkey.to_bytes())?
2936 };
2937 Ok((follow_distance, overmuted))
2938 })
2939 }
2940
2941 fn update_profile_index_for_events_with<F>(
2942 &self,
2943 events: &[Event],
2944 force_existing_search_value: bool,
2945 classify: F,
2946 ) -> Result<()>
2947 where
2948 F: FnMut(&Event) -> Result<(Option<u32>, bool)>,
2949 {
2950 if !events.iter().any(|event| event.kind == Kind::Metadata) {
2951 return Ok(());
2952 }
2953 let _transaction = self
2954 .profile_index
2955 .acquire_exclusive_root_pair_transaction()?;
2956 self.recover_profile_transactions_locked()?;
2957 self.update_profile_index_for_events_with_locked(
2958 events,
2959 force_existing_search_value,
2960 classify,
2961 )
2962 }
2963
2964 fn update_profile_index_for_events_with_locked<F>(
2965 &self,
2966 events: &[Event],
2967 force_existing_search_value: bool,
2968 mut classify: F,
2969 ) -> Result<()>
2970 where
2971 F: FnMut(&Event) -> Result<(Option<u32>, bool)>,
2972 {
2973 let latest_by_pubkey = latest_metadata_events_by_pubkey(events);
2974 if latest_by_pubkey.is_empty() {
2975 return Ok(());
2976 }
2977
2978 let mut updates = Vec::with_capacity(latest_by_pubkey.len());
2979 for event in latest_by_pubkey.into_values() {
2980 let (follow_distance, remove) = classify(event)?;
2981 updates.push((event, follow_distance, remove, force_existing_search_value));
2982 }
2983
2984 self.profile_index
2985 .update_profile_events_and_commit_locked(&updates)?;
2986 Ok(())
2987 }
2988
2989 fn filtered_latest_metadata_events_by_pubkey<'a>(
2990 &self,
2991 events: &'a [Event],
2992 ) -> Result<BTreeMap<String, &'a Event>> {
2993 let threshold = self.profile_index_overmute_threshold();
2994 let mut latest_by_pubkey = BTreeMap::<String, &Event>::new();
2995 for event in events.iter().filter(|event| event.kind == Kind::Metadata) {
2996 if self.is_overmuted_user(&event.pubkey.to_bytes(), threshold)? {
2997 continue;
2998 }
2999 let pubkey = event.pubkey.to_hex();
3000 match latest_by_pubkey.get(&pubkey) {
3001 Some(current) if compare_nostr_events(event, current).is_le() => {}
3002 _ => {
3003 latest_by_pubkey.insert(pubkey, event);
3004 }
3005 }
3006 }
3007 Ok(latest_by_pubkey)
3008 }
3009
3010 fn snapshot_chunks(&self, root: &[u8; 32], options: &BinaryBudget) -> Result<Vec<Bytes>> {
3011 let state = {
3012 let graph = self.graph.lock().unwrap();
3013 graph.export_state().context("export social graph state")?
3014 };
3015 let mut graph = SocialGraph::from_state(state).context("rebuild social graph state")?;
3016 let root_hex = hex::encode(root);
3017 if graph.get_root() != root_hex {
3018 graph
3019 .set_root(&root_hex)
3020 .context("set snapshot social graph root")?;
3021 }
3022 let chunks = graph
3023 .to_binary_chunks_with_budget(*options)
3024 .context("encode social graph snapshot")?;
3025 Ok(chunks.into_iter().map(Bytes::from).collect())
3026 }
3027
3028 fn ingest_event(&self, event: &Event) -> Result<()> {
3029 self.ingest_event_with_storage_class(event, self.default_storage_class_for(event)?)
3030 }
3031
3032 fn ingest_events(&self, events: &[Event]) -> Result<()> {
3033 if events.is_empty() {
3034 return Ok(());
3035 }
3036
3037 let mut public = Vec::new();
3038 let mut ambient = Vec::new();
3039 for event in events {
3040 match self.default_storage_class_for(event)? {
3041 EventStorageClass::Public => public.push(event.clone()),
3042 EventStorageClass::Ambient => ambient.push(event.clone()),
3043 }
3044 }
3045
3046 if !public.is_empty() {
3047 self.ingest_events_with_storage_class(&public, EventStorageClass::Public)?;
3048 }
3049 if !ambient.is_empty() {
3050 self.ingest_events_with_storage_class(&ambient, EventStorageClass::Ambient)?;
3051 }
3052
3053 Ok(())
3054 }
3055
3056 fn apply_graph_events_only(&self, events: &[Event]) -> Result<()> {
3057 if !events.iter().any(|event| is_social_graph_event(event.kind)) {
3058 return Ok(());
3059 }
3060 let _transaction = self
3061 .profile_index
3062 .acquire_exclusive_root_pair_transaction()?;
3063 require_no_incomplete_profile_repair_for_root_write(
3064 &self.profile_index.root_pair_lock_path,
3065 )?;
3066 self.recover_profile_transactions_locked()?;
3067 self.apply_graph_events_only_locked(events)
3068 }
3069
3070 fn apply_graph_events_only_locked(&self, events: &[Event]) -> Result<()> {
3071 let graph_events = events
3072 .iter()
3073 .filter(|event| is_social_graph_event(event.kind))
3074 .collect::<Vec<_>>();
3075 if graph_events.is_empty() {
3076 return Ok(());
3077 }
3078
3079 {
3080 let mut graph = self.graph.lock().unwrap();
3081 let mut snapshot = SocialGraph::from_state(
3082 graph
3083 .export_state()
3084 .context("export social graph state for graph-only ingest")?,
3085 )
3086 .context("rebuild social graph state for graph-only ingest")?;
3087 for event in graph_events {
3088 snapshot.handle_event(&graph_event_from_nostr(event), true, 0.0);
3089 }
3090 graph
3091 .replace_state(&snapshot.export_state())
3092 .context("replace graph-only social graph state")?;
3093 }
3094 self.invalidate_distance_cache();
3095 Ok(())
3096 }
3097
3098 fn query_events(&self, filter: &Filter, limit: usize) -> Result<Vec<Event>> {
3099 self.query_events_in_scope(filter, limit, EventQueryScope::All)
3100 }
3101
3102 fn default_storage_class_for(&self, event: &Event) -> Result<EventStorageClass> {
3103 let graph = self.graph.lock().unwrap();
3104 let root_hex = graph.get_root().context("read social graph root")?;
3105 if root_hex != DEFAULT_ROOT_HEX && root_hex == event.pubkey.to_hex() {
3106 return Ok(EventStorageClass::Public);
3107 }
3108 Ok(EventStorageClass::Ambient)
3109 }
3110
3111 fn bucket(&self, storage_class: EventStorageClass) -> &EventIndexBucket {
3112 match storage_class {
3113 EventStorageClass::Public => &self.public_events,
3114 EventStorageClass::Ambient => &self.ambient_events,
3115 }
3116 }
3117
3118 fn ingest_event_with_storage_class(
3119 &self,
3120 event: &Event,
3121 storage_class: EventStorageClass,
3122 ) -> Result<()> {
3123 self.ingest_event_with_storage_class_and_lock_timeout(
3124 event,
3125 storage_class,
3126 PROFILE_ROOT_PAIR_LOCK_TIMEOUT,
3127 )
3128 }
3129
3130 fn ingest_event_with_storage_class_and_lock_timeout(
3131 &self,
3132 event: &Event,
3133 storage_class: EventStorageClass,
3134 lock_timeout: Duration,
3135 ) -> Result<()> {
3136 let _transaction = self
3137 .profile_index
3138 .acquire_exclusive_root_pair_transaction_with_timeout(lock_timeout)?;
3139 require_no_incomplete_profile_repair_for_root_write(
3140 &self.profile_index.root_pair_lock_path,
3141 )?;
3142 self.recover_profile_transactions_locked()?;
3143 let bucket = self.bucket(storage_class);
3144 let current_root = bucket.events_root_for_write()?;
3145 let next_root = bucket.store_event(current_root.as_ref(), event)?;
3146 let projection_events =
3147 self.retained_derived_events_at_root(bucket, &next_root, std::slice::from_ref(event))?;
3148 let derived_projection =
3149 (!projection_events.is_empty()).then(|| PendingProfileProjection {
3150 version: PROFILE_PROJECTION_PENDING_VERSION,
3151 storage_class: storage_class.into(),
3152 projection: PendingProfileProjectionMode::Incremental {
3153 old_root: current_root.as_ref().map(stored_cid),
3154 new_root: stored_cid(&next_root),
3155 events: projection_events.iter().map(JsonUtil::as_json).collect(),
3156 },
3157 });
3158 if let Some(projection) = derived_projection.as_ref() {
3159 self.force_sync_event_storage(storage_class)?;
3160 self.persist_pending_profile_projection_locked(projection)?;
3161 bucket.write_events_root_durable(Some(&next_root))?;
3162 } else {
3163 bucket.write_events_root(Some(&next_root))?;
3164 }
3165
3166 if derived_projection.is_some() {
3167 self.apply_graph_events_only_locked(&projection_events)?;
3168 self.update_profile_index_for_events_locked(&projection_events)?;
3169 self.force_sync_graph_projection_for_events(&projection_events)?;
3170 self.clear_pending_profile_projection_locked()?;
3171 }
3172
3173 Ok(())
3174 }
3175
3176 fn ingest_events_with_storage_class(
3177 &self,
3178 events: &[Event],
3179 storage_class: EventStorageClass,
3180 ) -> Result<()> {
3181 if events.is_empty() {
3182 return Ok(());
3183 }
3184
3185 let _transaction = self
3186 .profile_index
3187 .acquire_exclusive_root_pair_transaction()?;
3188 require_no_incomplete_profile_repair_for_root_write(
3189 &self.profile_index.root_pair_lock_path,
3190 )?;
3191 self.recover_profile_transactions_locked()?;
3192 let bucket = self.bucket(storage_class);
3193 let current_root = bucket.events_root_for_write()?;
3194 let stored_events = events
3195 .iter()
3196 .map(stored_event_from_nostr_sdk_event)
3197 .collect::<Vec<_>>();
3198 let next_root = block_on(
3199 bucket
3200 .event_store
3201 .build(current_root.as_ref(), stored_events),
3202 )
3203 .map_err(map_event_store_error)?;
3204 let projection_events = match next_root.as_ref() {
3205 Some(root) => self.retained_derived_events_at_root(bucket, root, events)?,
3206 None => Vec::new(),
3207 };
3208 let derived_projection = if projection_events.is_empty() {
3209 None
3210 } else {
3211 let next_root = next_root
3212 .as_ref()
3213 .context("derived event batch did not produce an event root")?;
3214 Some(PendingProfileProjection {
3215 version: PROFILE_PROJECTION_PENDING_VERSION,
3216 storage_class: storage_class.into(),
3217 projection: PendingProfileProjectionMode::Incremental {
3218 old_root: current_root.as_ref().map(stored_cid),
3219 new_root: stored_cid(next_root),
3220 events: projection_events.iter().map(JsonUtil::as_json).collect(),
3221 },
3222 })
3223 };
3224 if let Some(projection) = derived_projection.as_ref() {
3225 self.force_sync_event_storage(storage_class)?;
3226 self.persist_pending_profile_projection_locked(projection)?;
3227 bucket.write_events_root_durable(next_root.as_ref())?;
3228 } else {
3229 bucket.write_events_root(next_root.as_ref())?;
3230 }
3231
3232 if derived_projection.is_some() {
3233 self.apply_graph_events_only_locked(&projection_events)?;
3234 self.update_profile_index_for_events_locked(&projection_events)?;
3235 self.force_sync_graph_projection_for_events(&projection_events)?;
3236 self.clear_pending_profile_projection_locked()?;
3237 }
3238
3239 Ok(())
3240 }
3241
3242 pub(crate) fn query_events_in_scope(
3243 &self,
3244 filter: &Filter,
3245 limit: usize,
3246 scope: EventQueryScope,
3247 ) -> Result<Vec<Event>> {
3248 if limit == 0 {
3249 return Ok(Vec::new());
3250 }
3251
3252 let buckets: &[&EventIndexBucket] = match scope {
3253 EventQueryScope::PublicOnly => &[&self.public_events],
3254 EventQueryScope::AmbientOnly => &[&self.ambient_events],
3255 EventQueryScope::All => &[&self.public_events, &self.ambient_events],
3256 };
3257
3258 let mut candidates = Vec::new();
3259 for bucket in buckets {
3260 candidates.extend(bucket.query_events(filter, limit)?);
3261 }
3262
3263 let mut deduped = dedupe_events(candidates);
3264 deduped.retain(|event| filter.match_event(event, Default::default()));
3265 deduped.truncate(limit);
3266 Ok(deduped)
3267 }
3268}
3269
3270impl SocialGraphBackend for SocialGraphStore {
3271 fn stats(&self) -> Result<SocialGraphStats> {
3272 SocialGraphStore::stats(self)
3273 }
3274
3275 fn users_by_follow_distance(&self, distance: u32) -> Result<Vec<[u8; 32]>> {
3276 SocialGraphStore::users_by_follow_distance(self, distance)
3277 }
3278
3279 fn follow_distance(&self, pk_bytes: &[u8; 32]) -> Result<Option<u32>> {
3280 SocialGraphStore::follow_distance(self, pk_bytes)
3281 }
3282
3283 fn follow_list_created_at(&self, owner: &[u8; 32]) -> Result<Option<u64>> {
3284 SocialGraphStore::follow_list_created_at(self, owner)
3285 }
3286
3287 fn followed_targets(&self, owner: &[u8; 32]) -> Result<UserSet> {
3288 SocialGraphStore::followed_targets(self, owner)
3289 }
3290
3291 fn is_overmuted_user(&self, user_pk: &[u8; 32], threshold: f64) -> Result<bool> {
3292 SocialGraphStore::is_overmuted_user(self, user_pk, threshold)
3293 }
3294
3295 fn profile_search_root(&self) -> Result<Option<Cid>> {
3296 SocialGraphStore::profile_search_root(self)
3297 }
3298
3299 fn snapshot_chunks(&self, root: &[u8; 32], options: &BinaryBudget) -> Result<Vec<Bytes>> {
3300 SocialGraphStore::snapshot_chunks(self, root, options)
3301 }
3302
3303 fn ingest_event(&self, event: &Event) -> Result<()> {
3304 SocialGraphStore::ingest_event(self, event)
3305 }
3306
3307 fn ingest_event_with_storage_class(
3308 &self,
3309 event: &Event,
3310 storage_class: EventStorageClass,
3311 ) -> Result<()> {
3312 SocialGraphStore::ingest_event_with_storage_class(self, event, storage_class)
3313 }
3314
3315 fn ingest_events(&self, events: &[Event]) -> Result<()> {
3316 SocialGraphStore::ingest_events(self, events)
3317 }
3318
3319 fn ingest_events_with_storage_class(
3320 &self,
3321 events: &[Event],
3322 storage_class: EventStorageClass,
3323 ) -> Result<()> {
3324 SocialGraphStore::ingest_events_with_storage_class(self, events, storage_class)
3325 }
3326
3327 fn ingest_graph_events(&self, events: &[Event]) -> Result<()> {
3328 SocialGraphStore::apply_graph_events_only(self, events)
3329 }
3330
3331 fn query_events(&self, filter: &Filter, limit: usize) -> Result<Vec<Event>> {
3332 SocialGraphStore::query_events(self, filter, limit)
3333 }
3334}
3335
3336impl NostrSocialGraphBackend for SocialGraphStore {
3337 type Error = UpstreamGraphBackendError;
3338
3339 fn get_root(&self) -> std::result::Result<String, Self::Error> {
3340 let graph = self.graph.lock().unwrap();
3341 graph
3342 .get_root()
3343 .context("read social graph root")
3344 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3345 }
3346
3347 fn set_root(&mut self, root: &str) -> std::result::Result<(), Self::Error> {
3348 let root_bytes =
3349 decode_pubkey(root).map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3350 SocialGraphStore::set_root(self, &root_bytes)
3351 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3352 }
3353
3354 fn handle_event(
3355 &mut self,
3356 event: &GraphEvent,
3357 allow_unknown_authors: bool,
3358 overmute_threshold: f64,
3359 ) -> std::result::Result<(), Self::Error> {
3360 let _transaction = self
3361 .profile_index
3362 .acquire_exclusive_root_pair_transaction()
3363 .map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3364 require_no_incomplete_profile_repair_for_root_write(
3365 &self.profile_index.root_pair_lock_path,
3366 )
3367 .map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3368 self.recover_profile_transactions_locked()
3369 .map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3370 {
3371 let mut graph = self.graph.lock().unwrap();
3372 graph
3373 .handle_event(event, allow_unknown_authors, overmute_threshold)
3374 .context("ingest social graph event into heed backend")
3375 .map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3376 }
3377 self.invalidate_distance_cache();
3378 Ok(())
3379 }
3380
3381 fn get_follow_distance(&self, user: &str) -> std::result::Result<u32, Self::Error> {
3382 let graph = self.graph.lock().unwrap();
3383 graph
3384 .get_follow_distance(user)
3385 .context("read social graph follow distance")
3386 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3387 }
3388
3389 fn is_following(
3390 &self,
3391 follower: &str,
3392 followed_user: &str,
3393 ) -> std::result::Result<bool, Self::Error> {
3394 let graph = self.graph.lock().unwrap();
3395 graph
3396 .is_following(follower, followed_user)
3397 .context("read social graph following edge")
3398 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3399 }
3400
3401 fn get_followed_by_user(&self, user: &str) -> std::result::Result<Vec<String>, Self::Error> {
3402 let graph = self.graph.lock().unwrap();
3403 graph
3404 .get_followed_by_user(user)
3405 .context("read followed-by-user list")
3406 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3407 }
3408
3409 fn get_followers_by_user(&self, user: &str) -> std::result::Result<Vec<String>, Self::Error> {
3410 let graph = self.graph.lock().unwrap();
3411 graph
3412 .get_followers_by_user(user)
3413 .context("read followers-by-user list")
3414 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3415 }
3416
3417 fn get_muted_by_user(&self, user: &str) -> std::result::Result<Vec<String>, Self::Error> {
3418 let graph = self.graph.lock().unwrap();
3419 graph
3420 .get_muted_by_user(user)
3421 .context("read muted-by-user list")
3422 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3423 }
3424
3425 fn get_user_muted_by(&self, user: &str) -> std::result::Result<Vec<String>, Self::Error> {
3426 let graph = self.graph.lock().unwrap();
3427 graph
3428 .get_user_muted_by(user)
3429 .context("read user-muted-by list")
3430 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3431 }
3432
3433 fn get_follow_list_created_at(
3434 &self,
3435 user: &str,
3436 ) -> std::result::Result<Option<u64>, Self::Error> {
3437 let graph = self.graph.lock().unwrap();
3438 graph
3439 .get_follow_list_created_at(user)
3440 .context("read social graph follow list timestamp")
3441 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3442 }
3443
3444 fn get_mute_list_created_at(
3445 &self,
3446 user: &str,
3447 ) -> std::result::Result<Option<u64>, Self::Error> {
3448 let graph = self.graph.lock().unwrap();
3449 graph
3450 .get_mute_list_created_at(user)
3451 .context("read social graph mute list timestamp")
3452 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3453 }
3454
3455 fn is_overmuted(&self, user: &str, threshold: f64) -> std::result::Result<bool, Self::Error> {
3456 let graph = self.graph.lock().unwrap();
3457 graph
3458 .is_overmuted(user, threshold)
3459 .context("check social graph overmute")
3460 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3461 }
3462}
3463
3464impl<T> SocialGraphBackend for Arc<T>
3465where
3466 T: SocialGraphBackend + ?Sized,
3467{
3468 fn stats(&self) -> Result<SocialGraphStats> {
3469 self.as_ref().stats()
3470 }
3471
3472 fn users_by_follow_distance(&self, distance: u32) -> Result<Vec<[u8; 32]>> {
3473 self.as_ref().users_by_follow_distance(distance)
3474 }
3475
3476 fn follow_distance(&self, pk_bytes: &[u8; 32]) -> Result<Option<u32>> {
3477 self.as_ref().follow_distance(pk_bytes)
3478 }
3479
3480 fn follow_list_created_at(&self, owner: &[u8; 32]) -> Result<Option<u64>> {
3481 self.as_ref().follow_list_created_at(owner)
3482 }
3483
3484 fn followed_targets(&self, owner: &[u8; 32]) -> Result<UserSet> {
3485 self.as_ref().followed_targets(owner)
3486 }
3487
3488 fn is_overmuted_user(&self, user_pk: &[u8; 32], threshold: f64) -> Result<bool> {
3489 self.as_ref().is_overmuted_user(user_pk, threshold)
3490 }
3491
3492 fn profile_search_root(&self) -> Result<Option<Cid>> {
3493 self.as_ref().profile_search_root()
3494 }
3495
3496 fn snapshot_chunks(&self, root: &[u8; 32], options: &BinaryBudget) -> Result<Vec<Bytes>> {
3497 self.as_ref().snapshot_chunks(root, options)
3498 }
3499
3500 fn ingest_event(&self, event: &Event) -> Result<()> {
3501 self.as_ref().ingest_event(event)
3502 }
3503
3504 fn ingest_event_with_storage_class(
3505 &self,
3506 event: &Event,
3507 storage_class: EventStorageClass,
3508 ) -> Result<()> {
3509 self.as_ref()
3510 .ingest_event_with_storage_class(event, storage_class)
3511 }
3512
3513 fn ingest_events(&self, events: &[Event]) -> Result<()> {
3514 self.as_ref().ingest_events(events)
3515 }
3516
3517 fn ingest_events_with_storage_class(
3518 &self,
3519 events: &[Event],
3520 storage_class: EventStorageClass,
3521 ) -> Result<()> {
3522 self.as_ref()
3523 .ingest_events_with_storage_class(events, storage_class)
3524 }
3525
3526 fn ingest_graph_events(&self, events: &[Event]) -> Result<()> {
3527 self.as_ref().ingest_graph_events(events)
3528 }
3529
3530 fn query_events(&self, filter: &Filter, limit: usize) -> Result<Vec<Event>> {
3531 self.as_ref().query_events(filter, limit)
3532 }
3533}
3534
3535fn should_replace_placeholder_root(graph: &HeedSocialGraph) -> Result<bool> {
3536 if graph.get_root().context("read current social graph root")? != DEFAULT_ROOT_HEX {
3537 return Ok(false);
3538 }
3539
3540 let GraphStats {
3541 users,
3542 follows,
3543 mutes,
3544 ..
3545 } = graph.size().context("size social graph")?;
3546 Ok(users <= 1 && follows == 0 && mutes == 0)
3547}
3548
3549fn decode_pubkey_set(values: Vec<String>) -> Result<UserSet> {
3550 let mut set = UserSet::new();
3551 for value in values {
3552 set.insert(decode_pubkey(&value)?);
3553 }
3554 Ok(set)
3555}
3556
3557fn decode_pubkey(value: &str) -> Result<[u8; 32]> {
3558 let mut bytes = [0u8; 32];
3559 hex::decode_to_slice(value, &mut bytes)
3560 .with_context(|| format!("decode social graph pubkey {value}"))?;
3561 Ok(bytes)
3562}
3563
3564fn is_social_graph_event(kind: Kind) -> bool {
3565 kind == Kind::ContactList || kind == Kind::MuteList
3566}
3567
3568fn is_derived_projection_event(kind: Kind) -> bool {
3569 kind == Kind::Metadata || is_social_graph_event(kind)
3570}
3571
3572fn same_unsigned_event(left: &Event, right: &Event) -> bool {
3573 left.id == right.id
3574 && left.pubkey == right.pubkey
3575 && left.created_at == right.created_at
3576 && left.kind == right.kind
3577 && left.tags == right.tags
3578 && left.content == right.content
3579}
3580
3581fn graph_event_from_nostr(event: &Event) -> GraphEvent {
3582 GraphEvent {
3583 created_at: event.created_at.as_secs(),
3584 content: event.content.clone(),
3585 tags: event
3586 .tags
3587 .iter()
3588 .map(|tag| tag.as_slice().to_vec())
3589 .collect(),
3590 kind: event.kind.as_u16() as u32,
3591 pubkey: event.pubkey.to_hex(),
3592 id: event.id.to_hex(),
3593 sig: event.sig.to_string(),
3594 }
3595}
3596
3597pub(crate) fn stored_event_to_nostr_event(event: StoredNostrEvent) -> Result<Event> {
3598 Ok(event.to_nostr_sdk_event()?)
3599}
3600
3601fn encode_cid(cid: &Cid) -> Result<Vec<u8>> {
3602 rmp_serde::to_vec_named(&StoredCid {
3603 hash: cid.hash,
3604 key: cid.key,
3605 })
3606 .context("encode social graph events root")
3607}
3608
3609fn decode_cid(bytes: &[u8]) -> Result<Option<Cid>> {
3610 let stored: StoredCid =
3611 rmp_serde::from_slice(bytes).context("decode social graph events root")?;
3612 Ok(Some(cid_from_stored(stored)))
3613}
3614
3615fn cid_from_stored(stored: StoredCid) -> Cid {
3616 Cid {
3617 hash: stored.hash,
3618 key: stored.key,
3619 }
3620}
3621
3622fn stored_cid(cid: &Cid) -> StoredCid {
3623 StoredCid {
3624 hash: cid.hash,
3625 key: cid.key,
3626 }
3627}
3628
3629fn read_root_file(path: &Path) -> Result<Option<Cid>> {
3630 match std::fs::read(path) {
3631 Ok(bytes) => decode_cid(&bytes),
3632 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3633 Err(error) => {
3634 Err(error).with_context(|| format!("read profile root file {}", path.display()))
3635 }
3636 }
3637}
3638
3639fn read_root_file_snapshot(path: &Path) -> Result<(Option<Cid>, Option<String>)> {
3640 match std::fs::read(path) {
3641 Ok(bytes) => {
3642 let digest = to_hex(&sha256(&bytes));
3643 Ok((decode_cid(&bytes)?, Some(digest)))
3644 }
3645 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok((None, None)),
3646 Err(error) => {
3647 Err(error).with_context(|| format!("read profile root file {}", path.display()))
3648 }
3649 }
3650}
3651
3652fn write_root_file(path: &Path, root: Option<&Cid>) -> Result<()> {
3653 let Some(root) = root else {
3654 if path.exists() {
3655 std::fs::remove_file(path)?;
3656 }
3657 return Ok(());
3658 };
3659
3660 let encoded = encode_cid(root)?;
3661 let tmp_path = path.with_extension("tmp");
3662 std::fs::write(&tmp_path, encoded)?;
3663 std::fs::rename(tmp_path, path)?;
3664 Ok(())
3665}
3666
3667fn write_root_file_durable(path: &Path, root: Option<&Cid>) -> Result<()> {
3668 let Some(root) = root else {
3669 return remove_file_durable(path);
3670 };
3671
3672 let encoded = encode_cid(root)?;
3673 replace_file_durable(path, &encoded, "durable social graph root")?;
3674 Ok(())
3675}
3676
3677#[cfg(not(windows))]
3678fn fsync_parent(path: &Path) -> Result<()> {
3679 let parent = path
3680 .parent()
3681 .with_context(|| format!("{} has no parent directory", path.display()))?;
3682 File::open(parent)
3683 .with_context(|| format!("open {} for fsync", parent.display()))?
3684 .sync_all()
3685 .with_context(|| format!("fsync {}", parent.display()))
3686}
3687
3688fn replace_file_durable(path: &Path, bytes: &[u8], label: &str) -> Result<()> {
3689 let parent = path
3690 .parent()
3691 .with_context(|| format!("{} has no parent directory", path.display()))?;
3692 std::fs::create_dir_all(parent)
3693 .with_context(|| format!("create {} parent {}", label, parent.display()))?;
3694 let file_name = path
3695 .file_name()
3696 .and_then(|value| value.to_str())
3697 .with_context(|| format!("{} path is not valid UTF-8", label))?;
3698 let pending = path.with_file_name(format!(".{file_name}.pending"));
3699 let mut file = OpenOptions::new()
3700 .create(true)
3701 .truncate(true)
3702 .write(true)
3703 .open(&pending)
3704 .with_context(|| format!("open pending {} {}", label, pending.display()))?;
3705 file.write_all(bytes)
3706 .with_context(|| format!("write pending {} {}", label, pending.display()))?;
3707 file.sync_all()
3708 .with_context(|| format!("fsync pending {} {}", label, pending.display()))?;
3709 drop(file);
3710 #[cfg(windows)]
3711 durable_file::rename(&pending, path)
3712 .with_context(|| format!("replace {} {}", label, path.display()))?;
3713 #[cfg(not(windows))]
3714 {
3715 std::fs::rename(&pending, path)
3716 .with_context(|| format!("replace {} {}", label, path.display()))?;
3717 fsync_parent(path)?;
3718 }
3719 Ok(())
3720}
3721
3722fn remove_file_durable(path: &Path) -> Result<()> {
3723 #[cfg(windows)]
3724 {
3725 durable_file::remove(path).with_context(|| format!("remove {}", path.display()))
3726 }
3727 #[cfg(not(windows))]
3728 match std::fs::remove_file(path) {
3729 Ok(()) => fsync_parent(path),
3730 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3731 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
3732 }
3733}
3734
3735fn profile_root_pair_commit_bytes(commit: &ProfileRootPairCommit) -> Result<Vec<u8>> {
3736 let mut bytes =
3737 serde_json::to_vec(commit).context("encode canonical profile root-pair commit")?;
3738 bytes.push(b'\n');
3739 Ok(bytes)
3740}
3741
3742fn pending_profile_projection_bytes(projection: &PendingProfileProjection) -> Result<Vec<u8>> {
3743 let mut bytes =
3744 serde_json::to_vec(projection).context("encode canonical pending profile projection")?;
3745 bytes.push(b'\n');
3746 Ok(bytes)
3747}
3748
3749fn load_pending_profile_projection(path: &Path) -> Result<Option<PendingProfileProjection>> {
3750 let bytes = match std::fs::read(path) {
3751 Ok(bytes) => bytes,
3752 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3753 Err(error) => {
3754 return Err(error)
3755 .with_context(|| format!("read pending profile projection {}", path.display()))
3756 }
3757 };
3758 let projection: PendingProfileProjection = serde_json::from_slice(&bytes)
3759 .with_context(|| format!("parse pending profile projection {}", path.display()))?;
3760 if projection.version != PROFILE_PROJECTION_PENDING_VERSION {
3761 anyhow::bail!(
3762 "unsupported pending profile projection version {} in {}",
3763 projection.version,
3764 path.display()
3765 );
3766 }
3767 if pending_profile_projection_bytes(&projection)? != bytes {
3768 anyhow::bail!(
3769 "pending profile projection {} is not canonical",
3770 path.display()
3771 );
3772 }
3773 Ok(Some(projection))
3774}
3775
3776fn load_profile_root_pair_commit(path: &Path) -> Result<Option<ProfileRootPairCommit>> {
3777 let bytes = match std::fs::read(path) {
3778 Ok(bytes) => bytes,
3779 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3780 Err(error) => {
3781 return Err(error)
3782 .with_context(|| format!("read profile root-pair commit {}", path.display()))
3783 }
3784 };
3785 let commit: ProfileRootPairCommit = serde_json::from_slice(&bytes)
3786 .with_context(|| format!("parse profile root-pair commit {}", path.display()))?;
3787 if commit.version != PROFILE_ROOT_PAIR_COMMIT_VERSION {
3788 anyhow::bail!(
3789 "unsupported profile root-pair commit version {} in {}",
3790 commit.version,
3791 path.display()
3792 );
3793 }
3794 if profile_root_pair_commit_bytes(&commit)? != bytes {
3795 anyhow::bail!(
3796 "profile root-pair commit {} is not canonical",
3797 path.display()
3798 );
3799 }
3800 Ok(Some(commit))
3801}
3802
3803fn install_profile_root_pair_commit_with<F>(
3804 by_pubkey_path: &Path,
3805 search_path: &Path,
3806 commit_path: &Path,
3807 commit: &ProfileRootPairCommit,
3808 after_search: F,
3809) -> Result<()>
3810where
3811 F: FnOnce() -> Result<()>,
3812{
3813 let current_by_pubkey = read_root_file(by_pubkey_path)?;
3814 let current_search = read_root_file(search_path)?;
3815 let old_by_pubkey = commit.old_by_pubkey.clone().map(cid_from_stored);
3816 let old_search = commit.old_search.clone().map(cid_from_stored);
3817 let new_by_pubkey = commit.new_by_pubkey.clone().map(cid_from_stored);
3818 let new_search = commit.new_search.clone().map(cid_from_stored);
3819 let old_pair = current_by_pubkey == old_by_pubkey && current_search == old_search;
3820 let search_first_pair = current_by_pubkey == old_by_pubkey && current_search == new_search;
3821 let new_pair = current_by_pubkey == new_by_pubkey && current_search == new_search;
3822 if !old_pair && !search_first_pair && !new_pair {
3823 anyhow::bail!(
3824 "profile root-pair files do not match an allowed forward state for {}",
3825 commit_path.display()
3826 );
3827 }
3828
3829 write_root_file_durable(search_path, new_search.as_ref())?;
3833 after_search()?;
3834 write_root_file_durable(by_pubkey_path, new_by_pubkey.as_ref())?;
3835 remove_file_durable(commit_path)
3836}
3837
3838fn require_no_pending_profile_root_pair_commit(db_dir: &Path) -> Result<()> {
3839 let commit_path = db_dir.join(PROFILE_ROOT_PAIR_COMMIT_FILE);
3840 if load_profile_root_pair_commit(&commit_path)?.is_some() {
3841 anyhow::bail!(
3842 "profile root-pair commit {} is pending; open the writable social graph store to recover it before read-only audit",
3843 commit_path.display()
3844 );
3845 }
3846 Ok(())
3847}
3848
3849fn require_no_pending_profile_projection(db_dir: &Path) -> Result<()> {
3850 let path = db_dir.join(PROFILE_PROJECTION_PENDING_FILE);
3851 if load_pending_profile_projection(&path)?.is_some() {
3852 anyhow::bail!(
3853 "profile projection {} is pending; open the writable social graph store to recover it before read-only audit",
3854 path.display()
3855 );
3856 }
3857 Ok(())
3858}
3859
3860fn recover_profile_root_pair_commit_locked(db_dir: &Path) -> Result<()> {
3861 require_no_incomplete_profile_repair_for_root_write(&db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE))?;
3862 let commit_path = db_dir.join(PROFILE_ROOT_PAIR_COMMIT_FILE);
3863 let Some(commit) = load_profile_root_pair_commit(&commit_path)? else {
3864 return Ok(());
3865 };
3866 install_profile_root_pair_commit_with(
3867 &db_dir.join(PROFILES_BY_PUBKEY_ROOT_FILE),
3868 &db_dir.join(PROFILE_SEARCH_ROOT_FILE),
3869 &commit_path,
3870 &commit,
3871 || Ok(()),
3872 )
3873 .with_context(|| {
3874 format!(
3875 "recover interrupted profile root-pair commit {}",
3876 commit_path.display()
3877 )
3878 })
3879}
3880
3881fn normalize_profile_name(value: &serde_json::Value) -> Option<String> {
3882 let raw = value.as_str()?;
3883 let trimmed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
3884 if trimmed.is_empty() {
3885 return None;
3886 }
3887 Some(trimmed.chars().take(PROFILE_NAME_MAX_LENGTH).collect())
3888}
3889
3890fn extract_profile_names(profile: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
3891 let mut names = Vec::new();
3892 let mut seen = HashSet::new();
3893
3894 for key in ["display_name", "displayName", "name", "username"] {
3895 let Some(value) = profile.get(key).and_then(normalize_profile_name) else {
3896 continue;
3897 };
3898 let lowered = value.to_lowercase();
3899 if seen.insert(lowered) {
3900 names.push(value);
3901 }
3902 }
3903
3904 names
3905}
3906
3907fn should_reject_profile_nip05(local_part: &str, primary_name: &str) -> bool {
3908 if local_part.len() == 1 || local_part.starts_with("npub1") {
3909 return true;
3910 }
3911
3912 primary_name
3913 .to_lowercase()
3914 .split_whitespace()
3915 .collect::<String>()
3916 .contains(local_part)
3917}
3918
3919fn normalize_profile_nip05(
3920 profile: &serde_json::Map<String, serde_json::Value>,
3921 primary_name: Option<&str>,
3922) -> Option<String> {
3923 let raw = profile.get("nip05")?.as_str()?;
3924 let local_part = raw.split('@').next()?.trim().to_lowercase();
3925 if local_part.is_empty() {
3926 return None;
3927 }
3928 let truncated: String = local_part.chars().take(PROFILE_NAME_MAX_LENGTH).collect();
3929 if truncated.is_empty() {
3930 return None;
3931 }
3932 if primary_name.is_some_and(|name| should_reject_profile_nip05(&truncated, name)) {
3933 return None;
3934 }
3935 Some(truncated)
3936}
3937
3938fn is_search_stop_word(word: &str) -> bool {
3939 matches!(
3940 word,
3941 "a" | "an"
3942 | "the"
3943 | "and"
3944 | "or"
3945 | "but"
3946 | "in"
3947 | "on"
3948 | "at"
3949 | "to"
3950 | "for"
3951 | "of"
3952 | "with"
3953 | "by"
3954 | "from"
3955 | "is"
3956 | "it"
3957 | "as"
3958 | "be"
3959 | "was"
3960 | "are"
3961 | "this"
3962 | "that"
3963 | "these"
3964 | "those"
3965 | "i"
3966 | "you"
3967 | "he"
3968 | "she"
3969 | "we"
3970 | "they"
3971 | "my"
3972 | "your"
3973 | "his"
3974 | "her"
3975 | "its"
3976 | "our"
3977 | "their"
3978 | "what"
3979 | "which"
3980 | "who"
3981 | "whom"
3982 | "how"
3983 | "when"
3984 | "where"
3985 | "why"
3986 | "will"
3987 | "would"
3988 | "could"
3989 | "should"
3990 | "can"
3991 | "may"
3992 | "might"
3993 | "must"
3994 | "have"
3995 | "has"
3996 | "had"
3997 | "do"
3998 | "does"
3999 | "did"
4000 | "been"
4001 | "being"
4002 | "get"
4003 | "got"
4004 | "just"
4005 | "now"
4006 | "then"
4007 | "so"
4008 | "if"
4009 | "not"
4010 | "no"
4011 | "yes"
4012 | "all"
4013 | "any"
4014 | "some"
4015 | "more"
4016 | "most"
4017 | "other"
4018 | "into"
4019 | "over"
4020 | "after"
4021 | "before"
4022 | "about"
4023 | "up"
4024 | "down"
4025 | "out"
4026 | "off"
4027 | "through"
4028 | "during"
4029 | "under"
4030 | "again"
4031 | "further"
4032 | "once"
4033 )
4034}
4035
4036fn is_pure_search_number(word: &str) -> bool {
4037 if !word.chars().all(|ch| ch.is_ascii_digit()) {
4038 return false;
4039 }
4040 !(word.len() == 4
4041 && word
4042 .parse::<u16>()
4043 .is_ok_and(|year| (1900..=2099).contains(&year)))
4044}
4045
4046fn split_compound_search_word(word: &str) -> Vec<String> {
4047 let mut parts = Vec::new();
4048 let mut current = String::new();
4049 let chars: Vec<char> = word.chars().collect();
4050
4051 for (index, ch) in chars.iter().copied().enumerate() {
4052 let split_before = current.chars().last().is_some_and(|prev| {
4053 (prev.is_lowercase() && ch.is_uppercase())
4054 || (prev.is_ascii_digit() && ch.is_alphabetic())
4055 || (prev.is_alphabetic() && ch.is_ascii_digit())
4056 || (prev.is_uppercase()
4057 && ch.is_uppercase()
4058 && chars.get(index + 1).is_some_and(|next| next.is_lowercase()))
4059 });
4060
4061 if split_before && !current.is_empty() {
4062 parts.push(std::mem::take(&mut current));
4063 }
4064
4065 current.push(ch);
4066 }
4067
4068 if !current.is_empty() {
4069 parts.push(current);
4070 }
4071
4072 parts
4073}
4074
4075fn parse_search_keywords(text: &str) -> Vec<String> {
4076 let mut keywords = Vec::new();
4077 let mut seen = HashSet::new();
4078
4079 for word in text
4080 .split(|ch: char| !ch.is_alphanumeric())
4081 .filter(|word| !word.is_empty())
4082 {
4083 let mut variants = Vec::with_capacity(1 + word.len() / 4);
4084 variants.push(word.to_lowercase());
4085 variants.extend(
4086 split_compound_search_word(word)
4087 .into_iter()
4088 .map(|part| part.to_lowercase()),
4089 );
4090
4091 for lowered in variants {
4092 if lowered.chars().count() < 2
4093 || is_search_stop_word(&lowered)
4094 || is_pure_search_number(&lowered)
4095 {
4096 continue;
4097 }
4098 if seen.insert(lowered.clone()) {
4099 keywords.push(lowered);
4100 }
4101 }
4102 }
4103
4104 keywords
4105}
4106
4107#[doc(hidden)]
4108pub fn profile_search_terms_for_event(event: &Event) -> Vec<String> {
4109 let profile = match serde_json::from_str::<serde_json::Value>(&event.content) {
4110 Ok(serde_json::Value::Object(profile)) => profile,
4111 _ => serde_json::Map::new(),
4112 };
4113 let names = extract_profile_names(&profile);
4114 let primary_name = names.first().map(String::as_str);
4115 let mut parts = Vec::new();
4116 if let Some(name) = primary_name {
4117 parts.push(name.to_string());
4118 }
4119 if let Some(nip05) = normalize_profile_nip05(&profile, primary_name) {
4120 parts.push(nip05);
4121 }
4122 parts.push(event.pubkey.to_hex());
4123 if names.len() > 1 {
4124 parts.extend(names.into_iter().skip(1));
4125 }
4126 parse_search_keywords(&parts.join(" "))
4127}
4128
4129#[doc(hidden)]
4130pub fn profile_search_keys_for_event(event: &Event) -> Vec<String> {
4131 let pubkey = event.pubkey.to_hex();
4132 profile_search_terms_for_event(event)
4133 .into_iter()
4134 .map(|term| format!("{PROFILE_SEARCH_PREFIX}{term}:{pubkey}"))
4135 .collect()
4136}
4137
4138#[doc(hidden)]
4144pub fn stored_profile_search_entry_for_event(
4145 event: &Event,
4146 mirrored_cid: &Cid,
4147 follow_distance: Option<u32>,
4148) -> Result<StoredProfileSearchEntry> {
4149 index_buckets::build_profile_search_entry(event, mirrored_cid, follow_distance)
4150}
4151
4152#[doc(hidden)]
4158pub fn profile_follow_distance_seal_v2(distances: &BTreeMap<String, Option<u32>>) -> String {
4159 let mut digest = Sha256::new();
4160 digest.update(b"hashtree-profile-follow-distance-seal-v2\0");
4161 digest.update((distances.len() as u64).to_be_bytes());
4162 for (pubkey, distance) in distances {
4163 digest.update((pubkey.len() as u64).to_be_bytes());
4164 digest.update(pubkey.as_bytes());
4165 match distance {
4166 Some(distance) => {
4167 digest.update([1]);
4168 digest.update(distance.to_be_bytes());
4169 }
4170 None => digest.update([0]),
4171 }
4172 }
4173 hex::encode(digest.finalize())
4174}
4175
4176fn compare_nostr_events(left: &Event, right: &Event) -> std::cmp::Ordering {
4177 left.created_at
4178 .as_secs()
4179 .cmp(&right.created_at.as_secs())
4180 .then_with(|| left.id.to_hex().cmp(&right.id.to_hex()))
4181}
4182
4183fn map_event_store_error(err: NostrEventStoreError) -> anyhow::Error {
4184 anyhow::anyhow!("nostr event store error: {err}")
4185}
4186
4187#[cfg(test)]
4188fn ensure_social_graph_mapsize(db_dir: &Path, requested_bytes: u64) -> Result<()> {
4189 ensure_social_graph_mapsize_with_env_flags(db_dir, requested_bytes, EnvFlags::empty())
4190}
4191
4192fn ensure_social_graph_mapsize_with_env_flags(
4193 db_dir: &Path,
4194 requested_bytes: u64,
4195 env_flags: EnvFlags,
4196) -> Result<()> {
4197 let map_size = social_graph_map_size(Some(requested_bytes))?;
4198
4199 let mut options = heed::EnvOpenOptions::new();
4200 options.map_size(map_size).max_dbs(SOCIALGRAPH_MAX_DBS);
4201 unsafe {
4202 options.flags(env_flags);
4203 }
4204 let env = unsafe { ManagedEnv::open(&options, db_dir) }
4205 .context("open social graph LMDB env for resize")?;
4206 if env.info().map_size < map_size {
4207 unsafe { env.resize(map_size) }.context("resize social graph LMDB env")?;
4208 }
4209
4210 Ok(())
4211}
4212
4213fn social_graph_map_size(requested_bytes: Option<u64>) -> Result<usize> {
4214 let requested = match requested_bytes {
4215 Some(bytes) => bytes.max(MIN_SOCIALGRAPH_MAP_SIZE_BYTES),
4216 None => DEFAULT_SOCIALGRAPH_MAP_SIZE_BYTES,
4217 };
4218 let page_size = page_size_bytes() as u64;
4219 let rounded = requested
4220 .checked_add(page_size.saturating_sub(1))
4221 .map(|size| size / page_size * page_size)
4222 .unwrap_or(requested);
4223 usize::try_from(rounded).context("social graph mapsize exceeds usize")
4224}
4225
4226fn page_size_bytes() -> usize {
4227 page_size::get_granularity()
4228}
4229
4230#[cfg(test)]
4231mod profile_lock_tests;
4232#[cfg(test)]
4233mod tests;