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
13mod index_buckets;
14
15use index_buckets::{
16 dedupe_events, latest_metadata_events_by_pubkey, EventIndexBucket, ProfileIndexBucket,
17};
18
19use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
20use std::fs::{File, OpenOptions};
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
24use std::time::{Duration, Instant};
25
26#[cfg(unix)]
27use std::os::fd::AsRawFd;
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 #[cfg(unix)]
924 unsafe {
925 libc::flock(self.file.as_raw_fd(), libc::LOCK_UN);
926 }
927 }
928}
929
930fn profile_root_pair_process_locks(
931) -> &'static StdMutex<HashMap<PathBuf, Weak<tokio::sync::RwLock<()>>>> {
932 static LOCKS: OnceLock<StdMutex<HashMap<PathBuf, Weak<tokio::sync::RwLock<()>>>>> =
933 OnceLock::new();
934 LOCKS.get_or_init(|| StdMutex::new(HashMap::new()))
935}
936
937fn profile_root_pair_process_lock(
938 root_pair_lock_path: &Path,
939) -> Result<Arc<tokio::sync::RwLock<()>>> {
940 let db_dir = root_pair_lock_path
941 .parent()
942 .with_context(|| format!("{} has no parent directory", root_pair_lock_path.display()))?;
943 let canonical_db_dir = std::fs::canonicalize(db_dir)
944 .with_context(|| format!("canonicalize profile index directory {}", db_dir.display()))?;
945 let lock_file_name = root_pair_lock_path.file_name().with_context(|| {
946 format!(
947 "{} has no profile transaction lock file name",
948 root_pair_lock_path.display()
949 )
950 })?;
951 let canonical_lock_path = canonical_db_dir.join(lock_file_name);
952 let mut locks = profile_root_pair_process_locks()
953 .lock()
954 .map_err(|_| anyhow::anyhow!("profile root-pair process lock registry was poisoned"))?;
955 locks.retain(|_, lock| lock.strong_count() > 0);
956 if let Some(lock) = locks.get(&canonical_lock_path).and_then(Weak::upgrade) {
957 return Ok(lock);
958 }
959 let lock = Arc::new(tokio::sync::RwLock::new(()));
960 locks.insert(canonical_lock_path, Arc::downgrade(&lock));
961 Ok(lock)
962}
963
964#[cfg(test)]
965type ProfileRootPairTransactionProbe =
966 Arc<dyn Fn(&Path, ProfileRootPairLockMode) + Send + Sync + 'static>;
967
968#[cfg(test)]
969fn profile_root_pair_transaction_probe(
970) -> &'static StdMutex<Option<ProfileRootPairTransactionProbe>> {
971 static PROBE: OnceLock<StdMutex<Option<ProfileRootPairTransactionProbe>>> = OnceLock::new();
972 PROBE.get_or_init(|| StdMutex::new(None))
973}
974
975#[cfg(test)]
976struct ProfileRootPairTransactionProbeGuard;
977
978#[cfg(test)]
979impl Drop for ProfileRootPairTransactionProbeGuard {
980 fn drop(&mut self) {
981 if let Ok(mut probe) = profile_root_pair_transaction_probe().lock() {
982 *probe = None;
983 }
984 }
985}
986
987#[cfg(test)]
988fn install_profile_root_pair_transaction_probe(
989 probe: ProfileRootPairTransactionProbe,
990) -> ProfileRootPairTransactionProbeGuard {
991 *profile_root_pair_transaction_probe()
992 .lock()
993 .expect("profile root-pair transaction probe lock poisoned") = Some(probe);
994 ProfileRootPairTransactionProbeGuard
995}
996
997#[cfg(test)]
998fn run_profile_root_pair_transaction_probe(path: &Path, mode: ProfileRootPairLockMode) {
999 let probe = profile_root_pair_transaction_probe()
1000 .lock()
1001 .expect("profile root-pair transaction probe lock poisoned")
1002 .clone();
1003 if let Some(probe) = probe {
1004 probe(path, mode);
1005 }
1006}
1007
1008#[cfg(test)]
1009type PendingProfileProjectionPersistedProbe =
1010 Arc<dyn Fn(&Path) -> Result<()> + Send + Sync + 'static>;
1011
1012#[cfg(test)]
1013fn pending_profile_projection_persisted_probe(
1014) -> &'static StdMutex<Option<PendingProfileProjectionPersistedProbe>> {
1015 static PROBE: OnceLock<StdMutex<Option<PendingProfileProjectionPersistedProbe>>> =
1016 OnceLock::new();
1017 PROBE.get_or_init(|| StdMutex::new(None))
1018}
1019
1020#[cfg(test)]
1021struct PendingProfileProjectionPersistedProbeGuard;
1022
1023#[cfg(test)]
1024impl Drop for PendingProfileProjectionPersistedProbeGuard {
1025 fn drop(&mut self) {
1026 if let Ok(mut probe) = pending_profile_projection_persisted_probe().lock() {
1027 *probe = None;
1028 }
1029 }
1030}
1031
1032#[cfg(test)]
1033fn install_pending_profile_projection_persisted_probe(
1034 probe: PendingProfileProjectionPersistedProbe,
1035) -> PendingProfileProjectionPersistedProbeGuard {
1036 *pending_profile_projection_persisted_probe()
1037 .lock()
1038 .expect("pending profile projection probe lock poisoned") = Some(probe);
1039 PendingProfileProjectionPersistedProbeGuard
1040}
1041
1042#[cfg(test)]
1043fn run_pending_profile_projection_persisted_probe(path: &Path) -> Result<()> {
1044 let probe = pending_profile_projection_persisted_probe()
1045 .lock()
1046 .map_err(|_| anyhow::anyhow!("pending profile projection probe lock poisoned"))?
1047 .clone();
1048 if let Some(probe) = probe {
1049 probe(path)?;
1050 }
1051 Ok(())
1052}
1053
1054fn try_open_and_lock_profile_root_pair_file(
1055 root_pair_lock_path: &Path,
1056 mode: ProfileRootPairLockMode,
1057 create: bool,
1058) -> Result<Option<File>> {
1059 let mut options = OpenOptions::new();
1060 options.read(true);
1061 if create {
1062 options.write(true).create(true).truncate(false);
1063 }
1064 let file = options.open(root_pair_lock_path).with_context(|| {
1065 format!(
1066 "open {} profile root-pair transaction lock {}",
1067 if create { "writable" } else { "existing" },
1068 root_pair_lock_path.display()
1069 )
1070 })?;
1071
1072 #[cfg(unix)]
1073 {
1074 let operation = match mode {
1075 ProfileRootPairLockMode::Shared => libc::LOCK_SH | libc::LOCK_NB,
1076 ProfileRootPairLockMode::Exclusive => libc::LOCK_EX | libc::LOCK_NB,
1077 };
1078 let result = unsafe { libc::flock(file.as_raw_fd(), operation) };
1079 if result != 0 {
1080 let error = std::io::Error::last_os_error();
1081 if error.kind() == std::io::ErrorKind::WouldBlock {
1082 return Ok(None);
1083 }
1084 return Err(error).with_context(|| {
1085 format!(
1086 "lock profile root-pair transaction ({:?}) at {}",
1087 mode,
1088 root_pair_lock_path.display()
1089 )
1090 });
1091 }
1092 }
1093 #[cfg(not(unix))]
1094 {
1095 let _ = mode;
1096 anyhow::bail!(
1097 "profile root-pair transactions require an operating-system advisory file lock"
1098 );
1099 }
1100
1101 Ok(Some(file))
1102}
1103
1104fn profile_root_pair_lock_timeout_error(
1105 root_pair_lock_path: &Path,
1106 mode: ProfileRootPairLockMode,
1107 timeout: Duration,
1108) -> anyhow::Error {
1109 anyhow::anyhow!(
1110 "timed out after {} ms waiting for profile root-pair transaction ({:?}) at {}",
1111 timeout.as_millis(),
1112 mode,
1113 root_pair_lock_path.display()
1114 )
1115}
1116
1117fn try_acquire_profile_root_pair_lock_once(
1118 process_lock: &Arc<tokio::sync::RwLock<()>>,
1119 root_pair_lock_path: &Path,
1120 mode: ProfileRootPairLockMode,
1121 create: bool,
1122) -> Result<Option<ProfileRootPairTransactionGuard>> {
1123 let (process_read, process_write) = match mode {
1124 ProfileRootPairLockMode::Shared => {
1125 let Ok(guard) = Arc::clone(process_lock).try_read_owned() else {
1126 return Ok(None);
1127 };
1128 (Some(guard), None)
1129 }
1130 ProfileRootPairLockMode::Exclusive => {
1131 let Ok(guard) = Arc::clone(process_lock).try_write_owned() else {
1132 return Ok(None);
1133 };
1134 (None, Some(guard))
1135 }
1136 };
1137 let Some(file) = try_open_and_lock_profile_root_pair_file(root_pair_lock_path, mode, create)?
1138 else {
1139 return Ok(None);
1140 };
1141 let guard = ProfileRootPairTransactionGuard {
1142 _process_read: process_read,
1143 _process_write: process_write,
1144 file,
1145 };
1146 #[cfg(test)]
1147 run_profile_root_pair_transaction_probe(root_pair_lock_path, mode);
1148 Ok(Some(guard))
1149}
1150
1151fn acquire_profile_root_pair_lock_with_timeout(
1152 root_pair_lock_path: &Path,
1153 mode: ProfileRootPairLockMode,
1154 create: bool,
1155 timeout: Duration,
1156) -> Result<ProfileRootPairTransactionGuard> {
1157 let process_lock = profile_root_pair_process_lock(root_pair_lock_path)?;
1158 let started = Instant::now();
1159 loop {
1160 if let Some(guard) = try_acquire_profile_root_pair_lock_once(
1161 &process_lock,
1162 root_pair_lock_path,
1163 mode,
1164 create,
1165 )? {
1166 return Ok(guard);
1167 }
1168 let elapsed = started.elapsed();
1169 if elapsed >= timeout {
1170 return Err(profile_root_pair_lock_timeout_error(
1171 root_pair_lock_path,
1172 mode,
1173 timeout,
1174 ));
1175 }
1176 std::thread::sleep(
1177 PROFILE_ROOT_PAIR_LOCK_RETRY_INTERVAL.min(timeout.saturating_sub(elapsed)),
1178 );
1179 }
1180}
1181
1182fn acquire_profile_root_pair_lock(
1183 root_pair_lock_path: &Path,
1184 mode: ProfileRootPairLockMode,
1185 create: bool,
1186) -> Result<ProfileRootPairTransactionGuard> {
1187 acquire_profile_root_pair_lock_with_timeout(
1188 root_pair_lock_path,
1189 mode,
1190 create,
1191 PROFILE_ROOT_PAIR_LOCK_TIMEOUT,
1192 )
1193}
1194
1195async fn acquire_profile_root_pair_lock_async_with_timeout(
1196 root_pair_lock_path: &Path,
1197 mode: ProfileRootPairLockMode,
1198 create: bool,
1199 timeout: Duration,
1200) -> Result<ProfileRootPairTransactionGuard> {
1201 let process_lock = profile_root_pair_process_lock(root_pair_lock_path)?;
1202 let started = Instant::now();
1203 loop {
1204 if let Some(guard) = try_acquire_profile_root_pair_lock_once(
1205 &process_lock,
1206 root_pair_lock_path,
1207 mode,
1208 create,
1209 )? {
1210 return Ok(guard);
1211 }
1212 let elapsed = started.elapsed();
1213 if elapsed >= timeout {
1214 return Err(profile_root_pair_lock_timeout_error(
1215 root_pair_lock_path,
1216 mode,
1217 timeout,
1218 ));
1219 }
1220 tokio::time::sleep(
1221 PROFILE_ROOT_PAIR_LOCK_RETRY_INTERVAL.min(timeout.saturating_sub(elapsed)),
1222 )
1223 .await;
1224 }
1225}
1226
1227async fn acquire_profile_root_pair_lock_async(
1228 root_pair_lock_path: &Path,
1229 mode: ProfileRootPairLockMode,
1230 create: bool,
1231) -> Result<ProfileRootPairTransactionGuard> {
1232 acquire_profile_root_pair_lock_async_with_timeout(
1233 root_pair_lock_path,
1234 mode,
1235 create,
1236 PROFILE_ROOT_PAIR_LOCK_TIMEOUT,
1237 )
1238 .await
1239}
1240
1241#[derive(Debug, Clone, Default, serde::Serialize)]
1242pub struct SocialGraphStats {
1243 pub total_users: usize,
1244 pub root: Option<String>,
1245 pub total_follows: usize,
1246 pub max_depth: u32,
1247 pub size_by_distance: BTreeMap<u32, usize>,
1248 pub enabled: bool,
1249}
1250
1251#[derive(Debug, Clone)]
1252struct DistanceCache {
1253 stats: SocialGraphStats,
1254 users_by_distance: BTreeMap<u32, Vec<[u8; 32]>>,
1255}
1256
1257#[derive(Debug, thiserror::Error)]
1258#[error("{0}")]
1259pub struct UpstreamGraphBackendError(String);
1260
1261pub struct SocialGraphStore {
1262 graph: StdMutex<HeedSocialGraph>,
1263 _graph_env_lifecycle: ManagedEnv,
1266 ambient_store: Arc<StorageRouter>,
1267 distance_cache: StdMutex<Option<DistanceCache>>,
1268 public_events: EventIndexBucket,
1269 ambient_events: EventIndexBucket,
1270 profile_index: ProfileIndexBucket,
1271 profile_index_overmute_threshold: StdMutex<f64>,
1272}
1273
1274pub trait SocialGraphBackend: Send + Sync {
1275 fn stats(&self) -> Result<SocialGraphStats>;
1276 fn users_by_follow_distance(&self, distance: u32) -> Result<Vec<[u8; 32]>>;
1277 fn follow_distance(&self, pk_bytes: &[u8; 32]) -> Result<Option<u32>>;
1278 fn follow_list_created_at(&self, owner: &[u8; 32]) -> Result<Option<u64>>;
1279 fn followed_targets(&self, owner: &[u8; 32]) -> Result<UserSet>;
1280 fn is_overmuted_user(&self, user_pk: &[u8; 32], threshold: f64) -> Result<bool>;
1281 fn profile_search_root(&self) -> Result<Option<Cid>> {
1282 Ok(None)
1283 }
1284 fn snapshot_chunks(&self, root: &[u8; 32], options: &BinaryBudget) -> Result<Vec<Bytes>>;
1285 fn ingest_event(&self, event: &Event) -> Result<()>;
1286 fn ingest_event_with_storage_class(
1287 &self,
1288 event: &Event,
1289 storage_class: EventStorageClass,
1290 ) -> Result<()> {
1291 let _ = storage_class;
1292 self.ingest_event(event)
1293 }
1294 fn ingest_events(&self, events: &[Event]) -> Result<()> {
1295 for event in events {
1296 self.ingest_event(event)?;
1297 }
1298 Ok(())
1299 }
1300 fn ingest_events_with_storage_class(
1301 &self,
1302 events: &[Event],
1303 storage_class: EventStorageClass,
1304 ) -> Result<()> {
1305 for event in events {
1306 self.ingest_event_with_storage_class(event, storage_class)?;
1307 }
1308 Ok(())
1309 }
1310 fn ingest_graph_events(&self, events: &[Event]) -> Result<()> {
1311 self.ingest_events(events)
1312 }
1313 fn query_events(&self, filter: &Filter, limit: usize) -> Result<Vec<Event>>;
1314}
1315
1316#[cfg(test)]
1317pub type TestLockGuard = tokio::sync::MutexGuard<'static, ()>;
1318
1319#[cfg(test)]
1320static NDB_TEST_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1321
1322#[cfg(test)]
1323fn test_mutex() -> &'static tokio::sync::Mutex<()> {
1324 NDB_TEST_LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
1325}
1326
1327#[cfg(test)]
1328pub async fn test_lock() -> TestLockGuard {
1329 test_mutex().lock().await
1330}
1331
1332#[cfg(test)]
1333pub fn test_lock_blocking() -> TestLockGuard {
1334 test_mutex().blocking_lock()
1335}
1336
1337pub fn open_social_graph_store(data_dir: &Path) -> Result<Arc<SocialGraphStore>> {
1338 open_social_graph_store_with_mapsize(data_dir, None)
1339}
1340
1341pub fn read_profile_index_roots(data_dir: &Path) -> Result<ProfileIndexRoots> {
1344 read_profile_index_roots_with_timeout(data_dir, PROFILE_ROOT_PAIR_LOCK_TIMEOUT)
1345}
1346
1347pub fn profile_index_root_file_sha256(root: &Cid) -> Result<String> {
1348 Ok(to_hex(&sha256(&encode_cid(root)?)))
1349}
1350
1351fn read_profile_index_roots_with_timeout(
1352 data_dir: &Path,
1353 timeout: Duration,
1354) -> Result<ProfileIndexRoots> {
1355 let db_dir = data_dir.join("socialgraph");
1356 match std::fs::symlink_metadata(&db_dir) {
1357 Ok(_) => {}
1358 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1359 return Ok(ProfileIndexRoots {
1360 by_pubkey: None,
1361 search: None,
1362 by_pubkey_file_sha256: None,
1363 search_file_sha256: None,
1364 });
1365 }
1366 Err(error) => {
1367 return Err(error)
1368 .with_context(|| format!("inspect profile index directory {}", db_dir.display()));
1369 }
1370 }
1371 let _transaction = acquire_profile_root_pair_lock_with_timeout(
1372 &db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE),
1373 ProfileRootPairLockMode::Shared,
1374 false,
1375 timeout,
1376 )?;
1377 require_no_pending_profile_root_pair_commit(&db_dir)?;
1378 require_no_pending_profile_projection(&db_dir)?;
1379 let (by_pubkey, by_pubkey_file_sha256) =
1380 read_root_file_snapshot(&db_dir.join(PROFILES_BY_PUBKEY_ROOT_FILE))?;
1381 let (search, search_file_sha256) =
1382 read_root_file_snapshot(&db_dir.join(PROFILE_SEARCH_ROOT_FILE))?;
1383 Ok(ProfileIndexRoots {
1384 by_pubkey,
1385 search,
1386 by_pubkey_file_sha256,
1387 search_file_sha256,
1388 })
1389}
1390
1391pub async fn validate_profile_indexes_read_only<S: Store>(
1394 data_dir: &Path,
1395 store: Arc<S>,
1396 event: &Event,
1397) -> Result<StoredProfileSearchEntry> {
1398 let roots = read_profile_index_roots(data_dir)?;
1399 validate_profile_indexes_at_roots(store, &roots, event).await
1400}
1401
1402pub async fn validate_profile_indexes_at_roots<S: Store>(
1406 store: Arc<S>,
1407 roots: &ProfileIndexRoots,
1408 event: &Event,
1409) -> Result<StoredProfileSearchEntry> {
1410 if event.kind != Kind::Metadata {
1411 anyhow::bail!("profile index validation requires a kind-0 metadata event");
1412 }
1413 let by_pubkey_root = roots
1414 .by_pubkey
1415 .clone()
1416 .context("profile-by-pubkey root is missing")?;
1417 let search_root = roots
1418 .search
1419 .clone()
1420 .context("profile-search root is missing")?;
1421 let index = BTree::new(
1422 Arc::clone(&store),
1423 hashtree_index::BTreeOptions {
1424 order: Some(PROFILE_SEARCH_INDEX_ORDER),
1425 },
1426 );
1427 let pubkey = event.pubkey.to_hex();
1428 let mirrored_cid = index
1429 .get_link(Some(&by_pubkey_root), &pubkey)
1430 .await
1431 .context("query profile-by-pubkey root")?
1432 .with_context(|| format!("profile-by-pubkey omitted {pubkey}"))?;
1433 let tree = HashTree::new(HashTreeConfig::new(store));
1434 let mirrored_bytes = tree
1435 .get(&mirrored_cid, None)
1436 .await
1437 .context("read mirrored profile event")?
1438 .with_context(|| format!("mirrored profile blob for {pubkey} is missing"))?;
1439 let mirrored = Event::from_json(
1440 String::from_utf8(mirrored_bytes).context("decode mirrored profile event as utf-8")?,
1441 )
1442 .context("decode mirrored profile event json")?;
1443 if mirrored != *event {
1444 anyhow::bail!(
1445 "profile-by-pubkey returned event {} with different bytes than {} for {pubkey}",
1446 mirrored.id,
1447 event.id
1448 );
1449 }
1450
1451 let term = profile_search_terms_for_event(event)
1452 .into_iter()
1453 .next()
1454 .with_context(|| format!("profile {pubkey} did not produce a search term"))?;
1455 let exact_key = format!("{PROFILE_SEARCH_PREFIX}{term}:{pubkey}");
1456 let encoded = index
1457 .get(Some(&search_root), &exact_key)
1458 .await
1459 .context("query profile-search root")?
1460 .with_context(|| format!("profile-search omitted exact key {exact_key}"))?;
1461 let entry: StoredProfileSearchEntry =
1462 serde_json::from_str(&encoded).context("decode stored profile search entry JSON")?;
1463 let expected_nhash = nhash_encode_full(&NHashData {
1464 hash: mirrored_cid.hash,
1465 decrypt_key: mirrored_cid.key,
1466 })
1467 .context("encode mirrored profile event nhash")?;
1468 if entry.pubkey != pubkey
1469 || entry.created_at != event.created_at.as_secs()
1470 || entry.event_nhash != expected_nhash
1471 {
1472 anyhow::bail!(
1473 "profile-search entry for {exact_key} does not match its profile-by-pubkey event"
1474 );
1475 }
1476 Ok(entry)
1477}
1478
1479pub fn open_social_graph_store_with_mapsize(
1480 data_dir: &Path,
1481 mapsize_bytes: Option<u64>,
1482) -> Result<Arc<SocialGraphStore>> {
1483 let db_dir = data_dir.join("socialgraph");
1484 open_social_graph_store_at_path(&db_dir, mapsize_bytes)
1485}
1486
1487pub fn open_social_graph_store_with_storage(
1488 data_dir: &Path,
1489 store: Arc<StorageRouter>,
1490 mapsize_bytes: Option<u64>,
1491) -> Result<Arc<SocialGraphStore>> {
1492 let db_dir = data_dir.join("socialgraph");
1493 open_social_graph_store_at_path_with_storage(&db_dir, store, mapsize_bytes)
1494}
1495
1496#[cfg(test)]
1497pub fn open_test_social_graph_store(data_dir: &Path) -> Result<Arc<SocialGraphStore>> {
1498 open_test_social_graph_store_with_mapsize(data_dir, None)
1499}
1500
1501#[cfg(test)]
1502pub fn open_test_social_graph_store_with_mapsize(
1503 data_dir: &Path,
1504 mapsize_bytes: Option<u64>,
1505) -> Result<Arc<SocialGraphStore>> {
1506 open_test_social_graph_store_at_path(&data_dir.join("socialgraph"), mapsize_bytes)
1507}
1508
1509#[cfg(test)]
1510pub fn open_test_social_graph_store_with_storage(
1511 data_dir: &Path,
1512 store: Arc<StorageRouter>,
1513 mapsize_bytes: Option<u64>,
1514) -> Result<Arc<SocialGraphStore>> {
1515 open_embedded_social_graph_store_with_storage(data_dir, store, mapsize_bytes)
1516}
1517
1518#[cfg(test)]
1519pub fn open_test_social_graph_store_at_path(
1520 db_dir: &Path,
1521 mapsize_bytes: Option<u64>,
1522) -> Result<Arc<SocialGraphStore>> {
1523 open_embedded_social_graph_store_at_path(db_dir, mapsize_bytes)
1524}
1525
1526pub fn open_embedded_social_graph_store_with_storage(
1527 data_dir: &Path,
1528 store: Arc<StorageRouter>,
1529 mapsize_bytes: Option<u64>,
1530) -> Result<Arc<SocialGraphStore>> {
1531 let db_dir = data_dir.join("socialgraph");
1532 open_social_graph_store_at_path_with_storage_and_env_flags(
1533 &db_dir,
1534 store,
1535 mapsize_bytes,
1536 EnvFlags::NO_LOCK,
1537 )
1538}
1539
1540pub fn open_social_graph_store_at_path(
1541 db_dir: &Path,
1542 mapsize_bytes: Option<u64>,
1543) -> Result<Arc<SocialGraphStore>> {
1544 let config = hashtree_config::Config::load_or_default();
1545 let backend = &config.storage.backend;
1546 let local_store = Arc::new(
1547 LocalStore::new_with_lmdb_map_size(db_dir.join("blobs"), backend, mapsize_bytes)
1548 .map_err(|err| anyhow::anyhow!("Failed to create social graph blob store: {err}"))?,
1549 );
1550 let store = Arc::new(StorageRouter::new(local_store));
1551 open_social_graph_store_at_path_with_storage(db_dir, store, mapsize_bytes)
1552}
1553
1554pub fn open_embedded_social_graph_store_at_path(
1555 db_dir: &Path,
1556 mapsize_bytes: Option<u64>,
1557) -> Result<Arc<SocialGraphStore>> {
1558 let local_store = Arc::new(
1559 LocalStore::new_with_lmdb_map_size(
1560 db_dir.join("blobs"),
1561 &hashtree_config::StorageBackend::Fs,
1562 mapsize_bytes,
1563 )
1564 .map_err(|err| anyhow::anyhow!("Failed to create social graph blob store: {err}"))?,
1565 );
1566 let store = Arc::new(StorageRouter::new(local_store));
1567 open_social_graph_store_at_path_with_storage_and_env_flags(
1568 db_dir,
1569 store,
1570 mapsize_bytes,
1571 EnvFlags::NO_LOCK,
1572 )
1573}
1574
1575pub fn open_social_graph_store_at_path_with_storage(
1576 db_dir: &Path,
1577 store: Arc<StorageRouter>,
1578 mapsize_bytes: Option<u64>,
1579) -> Result<Arc<SocialGraphStore>> {
1580 open_social_graph_store_at_path_with_storage_and_env_flags(
1581 db_dir,
1582 store,
1583 mapsize_bytes,
1584 EnvFlags::empty(),
1585 )
1586}
1587
1588fn open_social_graph_store_at_path_with_storage_and_env_flags(
1589 db_dir: &Path,
1590 store: Arc<StorageRouter>,
1591 mapsize_bytes: Option<u64>,
1592 env_flags: EnvFlags,
1593) -> Result<Arc<SocialGraphStore>> {
1594 let ambient_backend = store.local_store().backend();
1595 let ambient_local = Arc::new(
1596 LocalStore::new_with_lmdb_map_size(
1597 db_dir.join(AMBIENT_EVENTS_BLOB_DIR),
1598 &ambient_backend,
1599 mapsize_bytes,
1600 )
1601 .map_err(|err| {
1602 anyhow::anyhow!("Failed to create social graph ambient blob store: {err}")
1603 })?,
1604 );
1605 let ambient_store = Arc::new(StorageRouter::new(ambient_local));
1606 open_social_graph_store_at_path_with_storage_split_and_env_flags(
1607 db_dir,
1608 store,
1609 ambient_store,
1610 mapsize_bytes,
1611 env_flags,
1612 )
1613}
1614
1615pub fn open_social_graph_store_at_path_with_storage_split(
1616 db_dir: &Path,
1617 public_store: Arc<StorageRouter>,
1618 ambient_store: Arc<StorageRouter>,
1619 mapsize_bytes: Option<u64>,
1620) -> Result<Arc<SocialGraphStore>> {
1621 open_social_graph_store_at_path_with_storage_split_and_env_flags(
1622 db_dir,
1623 public_store,
1624 ambient_store,
1625 mapsize_bytes,
1626 EnvFlags::empty(),
1627 )
1628}
1629
1630fn open_social_graph_store_at_path_with_storage_split_and_env_flags(
1631 db_dir: &Path,
1632 public_store: Arc<StorageRouter>,
1633 ambient_store: Arc<StorageRouter>,
1634 mapsize_bytes: Option<u64>,
1635 env_flags: EnvFlags,
1636) -> Result<Arc<SocialGraphStore>> {
1637 std::fs::create_dir_all(db_dir)?;
1638 let _root_transaction = acquire_profile_root_pair_lock(
1639 &db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE),
1640 ProfileRootPairLockMode::Exclusive,
1641 true,
1642 )?;
1643 if incomplete_profile_repair_intent_path(&db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE))?.is_none() {
1644 recover_profile_root_pair_commit_locked(db_dir)?;
1645 }
1646 if let Some(size) = mapsize_bytes {
1647 ensure_social_graph_mapsize_with_env_flags(db_dir, size, env_flags)?;
1648 }
1649 let graph_map_size = social_graph_map_size(mapsize_bytes)?;
1650 let graph = unsafe {
1651 HeedSocialGraph::open_with_env_flags_and_map_size(
1652 db_dir,
1653 DEFAULT_ROOT_HEX,
1654 env_flags,
1655 graph_map_size,
1656 )
1657 }
1658 .context("open nostr-social-graph heed backend")?;
1659 let mut lifecycle_options = heed::EnvOpenOptions::new();
1660 lifecycle_options
1661 .map_size(graph_map_size)
1662 .max_dbs(SOCIALGRAPH_MAX_DBS);
1663 unsafe {
1664 lifecycle_options.flags(env_flags);
1665 }
1666 let graph_env_lifecycle = unsafe { ManagedEnv::open(&lifecycle_options, db_dir) }
1667 .context("manage nostr-social-graph heed backend lifecycle")?;
1668
1669 let graph_store = Arc::new(SocialGraphStore {
1670 graph: StdMutex::new(graph),
1671 _graph_env_lifecycle: graph_env_lifecycle,
1672 ambient_store: Arc::clone(&ambient_store),
1673 distance_cache: StdMutex::new(None),
1674 public_events: EventIndexBucket {
1675 event_store: NostrEventStore::new(Arc::clone(&public_store)),
1676 root_path: db_dir.join(EVENTS_ROOT_FILE),
1677 },
1678 ambient_events: EventIndexBucket {
1679 event_store: NostrEventStore::new(ambient_store),
1680 root_path: db_dir.join(AMBIENT_EVENTS_ROOT_FILE),
1681 },
1682 profile_index: ProfileIndexBucket {
1683 store: Arc::clone(&public_store),
1684 tree: HashTree::new(HashTreeConfig::new(Arc::clone(&public_store))),
1685 index: BTree::new(
1686 public_store,
1687 hashtree_index::BTreeOptions {
1688 order: Some(PROFILE_SEARCH_INDEX_ORDER),
1689 },
1690 ),
1691 by_pubkey_root_path: db_dir.join(PROFILES_BY_PUBKEY_ROOT_FILE),
1692 search_root_path: db_dir.join(PROFILE_SEARCH_ROOT_FILE),
1693 root_pair_commit_path: db_dir.join(PROFILE_ROOT_PAIR_COMMIT_FILE),
1694 root_pair_lock_path: db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE),
1695 },
1696 profile_index_overmute_threshold: StdMutex::new(1.0),
1697 });
1698 graph_store.recover_pending_profile_projection_locked()?;
1699 Ok(graph_store)
1700}
1701
1702pub fn set_social_graph_root(store: &SocialGraphStore, pk_bytes: &[u8; 32]) {
1703 if let Err(err) = store.set_root(pk_bytes) {
1704 tracing::warn!("Failed to set social graph root: {err}");
1705 }
1706}
1707
1708pub fn get_follow_distance(
1709 backend: &(impl SocialGraphBackend + ?Sized),
1710 pk_bytes: &[u8; 32],
1711) -> Option<u32> {
1712 backend.follow_distance(pk_bytes).ok().flatten()
1713}
1714
1715pub fn get_follows(
1716 backend: &(impl SocialGraphBackend + ?Sized),
1717 pk_bytes: &[u8; 32],
1718) -> Vec<[u8; 32]> {
1719 match backend.followed_targets(pk_bytes) {
1720 Ok(set) => set.into_iter().collect(),
1721 Err(_) => Vec::new(),
1722 }
1723}
1724
1725pub fn is_overmuted(
1726 backend: &(impl SocialGraphBackend + ?Sized),
1727 _root_pk: &[u8; 32],
1728 user_pk: &[u8; 32],
1729 threshold: f64,
1730) -> bool {
1731 backend
1732 .is_overmuted_user(user_pk, threshold)
1733 .unwrap_or(false)
1734}
1735
1736pub fn ingest_event(backend: &(impl SocialGraphBackend + ?Sized), _sub_id: &str, event_json: &str) {
1737 let event = match Event::from_json(event_json) {
1738 Ok(event) => event,
1739 Err(_) => return,
1740 };
1741
1742 if let Err(err) = backend.ingest_event(&event) {
1743 tracing::warn!("Failed to ingest social graph event: {err}");
1744 }
1745}
1746
1747pub fn ingest_parsed_event(
1748 backend: &(impl SocialGraphBackend + ?Sized),
1749 event: &Event,
1750) -> Result<()> {
1751 backend.ingest_event(event)
1752}
1753
1754pub fn ingest_parsed_event_with_storage_class(
1755 backend: &(impl SocialGraphBackend + ?Sized),
1756 event: &Event,
1757 storage_class: EventStorageClass,
1758) -> Result<()> {
1759 backend.ingest_event_with_storage_class(event, storage_class)
1760}
1761
1762pub fn ingest_parsed_events(
1763 backend: &(impl SocialGraphBackend + ?Sized),
1764 events: &[Event],
1765) -> Result<()> {
1766 backend.ingest_events(events)
1767}
1768
1769pub fn ingest_parsed_events_with_storage_class(
1770 backend: &(impl SocialGraphBackend + ?Sized),
1771 events: &[Event],
1772 storage_class: EventStorageClass,
1773) -> Result<()> {
1774 backend.ingest_events_with_storage_class(events, storage_class)
1775}
1776
1777pub fn ingest_graph_parsed_events(
1778 backend: &(impl SocialGraphBackend + ?Sized),
1779 events: &[Event],
1780) -> Result<()> {
1781 backend.ingest_graph_events(events)
1782}
1783
1784pub fn query_events(
1785 backend: &(impl SocialGraphBackend + ?Sized),
1786 filter: &Filter,
1787 limit: usize,
1788) -> Vec<Event> {
1789 backend.query_events(filter, limit).unwrap_or_default()
1790}
1791
1792impl SocialGraphStore {
1793 pub fn force_sync(&self) -> Result<()> {
1798 self._graph_env_lifecycle
1799 .force_sync()
1800 .context("force-sync social graph database")?;
1801 self.ambient_store
1802 .force_sync()
1803 .map_err(|err| anyhow::anyhow!("force-sync ambient event storage: {err}"))
1804 }
1805
1806 pub fn set_profile_index_overmute_threshold(&self, threshold: f64) {
1807 *self
1808 .profile_index_overmute_threshold
1809 .lock()
1810 .expect("profile index overmute threshold") = threshold;
1811 }
1812
1813 fn profile_index_overmute_threshold(&self) -> f64 {
1814 *self
1815 .profile_index_overmute_threshold
1816 .lock()
1817 .expect("profile index overmute threshold")
1818 }
1819
1820 fn invalidate_distance_cache(&self) {
1821 *self.distance_cache.lock().unwrap() = None;
1822 }
1823
1824 fn build_distance_cache(state: nostr_social_graph::SocialGraphState) -> Result<DistanceCache> {
1825 let unique_ids = state
1826 .unique_ids
1827 .into_iter()
1828 .map(|(pubkey, id)| decode_pubkey(&pubkey).map(|decoded| (id, decoded)))
1829 .collect::<Result<HashMap<_, _>>>()?;
1830
1831 let mut users_by_distance = BTreeMap::new();
1832 let mut size_by_distance = BTreeMap::new();
1833 for (distance, users) in state.users_by_follow_distance {
1834 let decoded = users
1835 .into_iter()
1836 .filter_map(|id| unique_ids.get(&id).copied())
1837 .collect::<Vec<_>>();
1838 size_by_distance.insert(distance, decoded.len());
1839 users_by_distance.insert(distance, decoded);
1840 }
1841
1842 let total_follows = state
1843 .followed_by_user
1844 .iter()
1845 .map(|(_, targets)| targets.len())
1846 .sum::<usize>();
1847 let total_users = size_by_distance.values().copied().sum();
1848 let max_depth = size_by_distance.keys().copied().max().unwrap_or_default();
1849
1850 Ok(DistanceCache {
1851 stats: SocialGraphStats {
1852 total_users,
1853 root: Some(state.root),
1854 total_follows,
1855 max_depth,
1856 size_by_distance,
1857 enabled: true,
1858 },
1859 users_by_distance,
1860 })
1861 }
1862
1863 fn load_distance_cache(&self) -> Result<DistanceCache> {
1864 if let Some(cache) = self.distance_cache.lock().unwrap().clone() {
1865 return Ok(cache);
1866 }
1867
1868 let state = {
1869 let graph = self.graph.lock().unwrap();
1870 graph.export_state().context("export social graph state")?
1871 };
1872 let cache = Self::build_distance_cache(state)?;
1873 *self.distance_cache.lock().unwrap() = Some(cache.clone());
1874 Ok(cache)
1875 }
1876
1877 fn set_root(&self, root: &[u8; 32]) -> Result<()> {
1878 let _transaction = self
1879 .profile_index
1880 .acquire_exclusive_root_pair_transaction()?;
1881 require_no_incomplete_profile_repair_for_root_write(
1882 &self.profile_index.root_pair_lock_path,
1883 )?;
1884 self.recover_profile_transactions_locked()?;
1885 let root_hex = hex::encode(root);
1886 {
1887 let mut graph = self.graph.lock().unwrap();
1888 if should_replace_placeholder_root(&graph)? {
1889 let fresh = SocialGraph::new(&root_hex);
1890 graph
1891 .replace_state(&fresh.export_state())
1892 .context("replace placeholder social graph root")?;
1893 } else {
1894 graph
1895 .set_root(&root_hex)
1896 .context("set nostr-social-graph root")?;
1897 }
1898 }
1899 self.invalidate_distance_cache();
1900 Ok(())
1901 }
1902
1903 fn stats(&self) -> Result<SocialGraphStats> {
1904 Ok(self.load_distance_cache()?.stats)
1905 }
1906
1907 fn follow_distance(&self, pk_bytes: &[u8; 32]) -> Result<Option<u32>> {
1908 let graph = self.graph.lock().unwrap();
1909 let distance = graph
1910 .get_follow_distance(&hex::encode(pk_bytes))
1911 .context("read social graph follow distance")?;
1912 Ok((distance != UNKNOWN_FOLLOW_DISTANCE).then_some(distance))
1913 }
1914
1915 fn users_by_follow_distance(&self, distance: u32) -> Result<Vec<[u8; 32]>> {
1916 Ok(self
1917 .load_distance_cache()?
1918 .users_by_distance
1919 .get(&distance)
1920 .cloned()
1921 .unwrap_or_default())
1922 }
1923
1924 fn follow_list_created_at(&self, owner: &[u8; 32]) -> Result<Option<u64>> {
1925 let graph = self.graph.lock().unwrap();
1926 graph
1927 .get_follow_list_created_at(&hex::encode(owner))
1928 .context("read social graph follow list timestamp")
1929 }
1930
1931 fn followed_targets(&self, owner: &[u8; 32]) -> Result<UserSet> {
1932 let graph = self.graph.lock().unwrap();
1933 decode_pubkey_set(
1934 graph
1935 .get_followed_by_user(&hex::encode(owner))
1936 .context("read followed targets")?,
1937 )
1938 }
1939
1940 fn is_overmuted_user(&self, user_pk: &[u8; 32], threshold: f64) -> Result<bool> {
1941 if threshold <= 0.0 {
1942 return Ok(false);
1943 }
1944 let graph = self.graph.lock().unwrap();
1945 graph
1946 .is_overmuted(&hex::encode(user_pk), threshold)
1947 .context("check social graph overmute")
1948 }
1949
1950 fn recovered_profile_index_roots(&self) -> Result<(Option<Cid>, Option<Cid>)> {
1951 let _transaction = self
1952 .profile_index
1953 .acquire_exclusive_root_pair_transaction()?;
1954 self.recover_profile_transactions_locked()?;
1955 self.profile_index.roots_locked()
1956 }
1957
1958 #[cfg_attr(not(test), allow(dead_code))]
1959 pub fn profile_search_root(&self) -> Result<Option<Cid>> {
1960 Ok(self.recovered_profile_index_roots()?.1)
1961 }
1962
1963 #[cfg_attr(not(test), allow(dead_code))]
1964 pub fn profiles_by_pubkey_root(&self) -> Result<Option<Cid>> {
1965 Ok(self.recovered_profile_index_roots()?.0)
1966 }
1967
1968 pub fn public_events_root(&self) -> Result<Option<Cid>> {
1969 self.public_events.events_root()
1970 }
1971
1972 #[cfg_attr(test, allow(dead_code))]
1973 pub(crate) fn public_events_root_for_write(&self) -> Result<Option<Cid>> {
1974 let _transaction = self
1975 .profile_index
1976 .acquire_exclusive_root_pair_transaction()?;
1977 require_no_incomplete_profile_repair_for_root_write(
1978 &self.profile_index.root_pair_lock_path,
1979 )?;
1980 self.recover_profile_transactions_locked()?;
1981 self.public_events.events_root_for_write()
1982 }
1983
1984 #[cfg(test)]
1985 pub(crate) fn write_public_events_root(&self, root: Option<&Cid>) -> Result<()> {
1986 self.public_events.write_events_root(root)
1987 }
1988
1989 fn pending_profile_projection_path(&self) -> PathBuf {
1990 self.profile_index
1991 .root_pair_lock_path
1992 .with_file_name(PROFILE_PROJECTION_PENDING_FILE)
1993 }
1994
1995 fn force_sync_event_storage(&self, storage_class: EventStorageClass) -> Result<()> {
1996 let store = match storage_class {
1997 EventStorageClass::Public => &self.profile_index.store,
1998 EventStorageClass::Ambient => &self.ambient_store,
1999 };
2000 store
2001 .force_sync()
2002 .map_err(|error| anyhow::anyhow!("force-sync derived event blocks: {error}"))
2003 }
2004
2005 fn force_sync_graph_projection_for_events(&self, events: &[Event]) -> Result<()> {
2006 if events.iter().any(|event| is_social_graph_event(event.kind)) {
2007 self._graph_env_lifecycle
2008 .force_sync()
2009 .context("force-sync derived social graph projection")?;
2010 }
2011 Ok(())
2012 }
2013
2014 fn retained_derived_events_at_root(
2015 &self,
2016 bucket: &EventIndexBucket,
2017 root: &Cid,
2018 events: &[Event],
2019 ) -> Result<Vec<Event>> {
2020 let mut retained = Vec::new();
2021 for event in events
2022 .iter()
2023 .filter(|event| is_derived_projection_event(event.kind))
2024 {
2025 event
2026 .verify()
2027 .with_context(|| format!("verify derived event {} before projection", event.id))?;
2028 match bucket.load_event_by_id(root, &event.id.to_hex())? {
2029 Some(stored) if same_unsigned_event(&stored, event) => retained.push(stored),
2030 Some(_) => {
2031 anyhow::bail!(
2032 "derived event {} resolved to different unsigned fields in candidate root",
2033 event.id
2034 )
2035 }
2036 None => {}
2037 }
2038 }
2039 Ok(retained)
2040 }
2041
2042 fn load_full_derived_events_at_root(
2043 &self,
2044 bucket: &EventIndexBucket,
2045 root: &Cid,
2046 ) -> Result<Vec<Event>> {
2047 block_on(bucket.event_store.validate_index_root(Some(root)))
2048 .map_err(map_event_store_error)
2049 .context("validate full derived-projection event root")?;
2050 let mut events = Vec::new();
2051 for kind in [Kind::ContactList, Kind::MuteList, Kind::Metadata] {
2052 let stored = block_on(bucket.event_store.list_by_kind(
2053 Some(root),
2054 kind.as_u16() as u32,
2055 ListEventsOptions::default(),
2056 ))
2057 .map_err(map_event_store_error)?;
2058 events.extend(
2059 stored
2060 .into_iter()
2061 .map(stored_event_to_nostr_event)
2062 .collect::<Result<Vec<_>>>()?,
2063 );
2064 }
2065 Ok(events)
2066 }
2067
2068 fn persist_pending_profile_projection_locked(
2069 &self,
2070 projection: &PendingProfileProjection,
2071 ) -> Result<()> {
2072 require_no_incomplete_profile_repair_for_root_write(
2073 &self.profile_index.root_pair_lock_path,
2074 )?;
2075 let path = self.pending_profile_projection_path();
2076 replace_file_durable(
2077 &path,
2078 &pending_profile_projection_bytes(projection)?,
2079 "pending profile projection",
2080 )?;
2081 #[cfg(test)]
2082 run_pending_profile_projection_persisted_probe(&path)?;
2083 Ok(())
2084 }
2085
2086 fn clear_pending_profile_projection_locked(&self) -> Result<()> {
2087 remove_file_durable(&self.pending_profile_projection_path())
2088 }
2089
2090 fn recover_profile_transactions_locked(&self) -> Result<()> {
2091 if incomplete_profile_repair_intent_path(&self.profile_index.root_pair_lock_path)?.is_some()
2092 {
2093 return Ok(());
2094 }
2095 self.profile_index
2096 .recover_pending_root_pair_commit_locked()?;
2097 self.recover_pending_profile_projection_locked()
2098 }
2099
2100 fn recover_profile_transactions_locked_for_repair(
2101 &self,
2102 authority: &ProfileIndexRepairAuthority,
2103 ) -> Result<()> {
2104 let db_dir = self
2105 .profile_index
2106 .root_pair_lock_path
2107 .parent()
2108 .context("profile root-pair lock has no database parent")?;
2109 require_no_pending_profile_projection(db_dir)?;
2110 self.profile_index
2111 .recover_pending_root_pair_commit_locked_for_repair(authority)
2112 }
2113
2114 fn recover_pending_profile_projection_locked(&self) -> Result<()> {
2115 if incomplete_profile_repair_intent_path(&self.profile_index.root_pair_lock_path)?.is_some()
2116 {
2117 return Ok(());
2118 }
2119 let path = self.pending_profile_projection_path();
2120 let Some(projection) = load_pending_profile_projection(&path)? else {
2121 return Ok(());
2122 };
2123 let storage_class = EventStorageClass::from(projection.storage_class);
2124 let bucket = self.bucket(storage_class);
2125 let current_root = bucket.events_root()?;
2126 let (old_root, new_root) = match &projection.projection {
2127 PendingProfileProjectionMode::Incremental {
2128 old_root, new_root, ..
2129 }
2130 | PendingProfileProjectionMode::RebuildPublicRoot { old_root, new_root } => (
2131 old_root.clone().map(cid_from_stored),
2132 cid_from_stored(new_root.clone()),
2133 ),
2134 };
2135
2136 if current_root.as_ref() != Some(&new_root) {
2137 if current_root == old_root {
2138 return self.clear_pending_profile_projection_locked();
2139 }
2140 anyhow::bail!(
2141 "event root does not match the pre- or post-publication state required by pending profile projection {}",
2142 path.display()
2143 );
2144 }
2145
2146 match projection.projection {
2147 PendingProfileProjectionMode::Incremental { events, .. } => {
2148 block_on(bucket.event_store.validate_index_root(Some(&new_root)))
2149 .map_err(map_event_store_error)
2150 .with_context(|| {
2151 format!(
2152 "validate published event root required by pending derived projection {}",
2153 path.display()
2154 )
2155 })?;
2156 if events.is_empty() {
2157 anyhow::bail!(
2158 "pending incremental derived projection {} contains no events",
2159 path.display()
2160 );
2161 }
2162 let events = events
2163 .into_iter()
2164 .map(|json| {
2165 Event::from_json(json).context("decode pending derived projection event")
2166 })
2167 .collect::<Result<Vec<_>>>()?;
2168 let mut canonical_events = Vec::with_capacity(events.len());
2169 for event in events {
2170 if !is_derived_projection_event(event.kind) {
2171 anyhow::bail!(
2172 "pending derived projection {} contains unsupported event kind {}",
2173 path.display(),
2174 event.kind.as_u16()
2175 );
2176 }
2177 event.verify().with_context(|| {
2178 format!(
2179 "verify pending derived event {} from {}",
2180 event.id,
2181 path.display()
2182 )
2183 })?;
2184 let stored = bucket
2185 .load_event_by_id(&new_root, &event.id.to_hex())?
2186 .with_context(|| {
2187 format!(
2188 "pending derived event {} is absent from its published event root",
2189 event.id
2190 )
2191 })?;
2192 if !same_unsigned_event(&stored, &event) {
2193 anyhow::bail!(
2194 "pending derived event {} resolved to different unsigned fields in {}",
2195 event.id,
2196 path.display()
2197 );
2198 }
2199 canonical_events.push(stored);
2200 }
2201 self.apply_graph_events_only_locked(&canonical_events)?;
2202 self.update_profile_index_for_events_locked(&canonical_events)?;
2203 self.force_sync_graph_projection_for_events(&canonical_events)?;
2204 }
2205 PendingProfileProjectionMode::RebuildPublicRoot { .. } => {
2206 if storage_class != EventStorageClass::Public {
2207 anyhow::bail!(
2208 "pending full profile rebuild {} must target the public event root",
2209 path.display()
2210 );
2211 }
2212 let events = self.load_full_derived_events_at_root(bucket, &new_root)?;
2213 self.apply_graph_events_only_locked(&events)?;
2214 self.rebuild_profile_index_for_events_locked(&events)?;
2215 self.force_sync_graph_projection_for_events(&events)?;
2216 }
2217 }
2218 self.clear_pending_profile_projection_locked()
2219 }
2220
2221 pub(crate) fn apply_public_events_root_and_projections(
2222 &self,
2223 expected_old_root: Option<&Cid>,
2224 root: Option<&Cid>,
2225 events: &[Event],
2226 rebuild_profile_index: bool,
2227 ) -> Result<PublicEventsRootApplyOutcome> {
2228 let _transaction = self
2229 .profile_index
2230 .acquire_exclusive_root_pair_transaction()?;
2231 require_no_incomplete_profile_repair_for_root_write(
2232 &self.profile_index.root_pair_lock_path,
2233 )?;
2234 self.recover_profile_transactions_locked()?;
2235 let old_root = self.public_events.events_root_for_write()?;
2236 if old_root.as_ref() != expected_old_root {
2237 return Ok(PublicEventsRootApplyOutcome::Conflict {
2238 current_root: old_root,
2239 });
2240 }
2241 let projection_events = match root {
2242 Some(root) if rebuild_profile_index => {
2243 self.load_full_derived_events_at_root(&self.public_events, root)?
2244 }
2245 Some(root) => {
2246 self.retained_derived_events_at_root(&self.public_events, root, events)?
2247 }
2248 None => Vec::new(),
2249 };
2250 let profile_projection = if rebuild_profile_index || !projection_events.is_empty() {
2251 let new_root = root.context("derived projection requires a public event root")?;
2252 self.force_sync_event_storage(EventStorageClass::Public)?;
2253 Some(PendingProfileProjection {
2254 version: PROFILE_PROJECTION_PENDING_VERSION,
2255 storage_class: StoredEventStorageClass::Public,
2256 projection: if rebuild_profile_index {
2257 PendingProfileProjectionMode::RebuildPublicRoot {
2258 old_root: old_root.as_ref().map(stored_cid),
2259 new_root: stored_cid(new_root),
2260 }
2261 } else {
2262 PendingProfileProjectionMode::Incremental {
2263 old_root: old_root.as_ref().map(stored_cid),
2264 new_root: stored_cid(new_root),
2265 events: projection_events.iter().map(JsonUtil::as_json).collect(),
2266 }
2267 },
2268 })
2269 } else {
2270 None
2271 };
2272 if let Some(projection) = profile_projection.as_ref() {
2273 self.persist_pending_profile_projection_locked(projection)?;
2274 self.public_events.write_events_root_durable(root)?;
2275 } else {
2276 self.public_events.write_events_root(root)?;
2277 }
2278 self.apply_graph_events_only_locked(&projection_events)?;
2279 if profile_projection.is_some() {
2280 if rebuild_profile_index {
2281 self.rebuild_profile_index_for_events_locked(&projection_events)?;
2282 } else {
2283 self.update_profile_index_for_events_locked(&projection_events)?;
2284 }
2285 self.force_sync_graph_projection_for_events(&projection_events)?;
2286 self.clear_pending_profile_projection_locked()?;
2287 }
2288 Ok(PublicEventsRootApplyOutcome::Applied)
2289 }
2290
2291 #[cfg_attr(not(test), allow(dead_code))]
2292 pub fn latest_profile_event(&self, pubkey_hex: &str) -> Result<Option<Event>> {
2293 let (root, _) = self.recovered_profile_index_roots()?;
2294 self.profile_index
2295 .profile_event_for_pubkey_at_root(root.as_ref(), pubkey_hex)
2296 }
2297
2298 #[cfg_attr(not(test), allow(dead_code))]
2299 pub fn profile_search_entries_for_prefix(
2300 &self,
2301 prefix: &str,
2302 ) -> Result<Vec<(String, StoredProfileSearchEntry)>> {
2303 let (_, root) = self.recovered_profile_index_roots()?;
2304 let Some(root) = root else {
2305 return Ok(Vec::new());
2306 };
2307 self.profile_index
2308 .search_entries_for_prefix_at_root(&root, prefix)
2309 }
2310
2311 pub fn validate_profile_indexes_for_event(
2319 &self,
2320 event: &Event,
2321 ) -> Result<StoredProfileSearchEntry> {
2322 if event.kind != Kind::Metadata {
2323 anyhow::bail!("profile index validation requires a kind-0 metadata event");
2324 }
2325
2326 let pubkey = event.pubkey.to_hex();
2327 let (by_pubkey_root, search_root) = self.recovered_profile_index_roots()?;
2328 let by_pubkey_root = by_pubkey_root.context("profile-by-pubkey root is missing")?;
2329 let search_root = search_root.context("profile-search root is missing")?;
2330 let mirrored_cid = block_on(
2331 self.profile_index
2332 .index
2333 .get_link(Some(&by_pubkey_root), &pubkey),
2334 )
2335 .context("query profile-by-pubkey root")?
2336 .with_context(|| format!("profile-by-pubkey omitted {pubkey}"))?;
2337 let mirrored = self
2338 .profile_index
2339 .load_profile_event(&mirrored_cid)?
2340 .with_context(|| format!("mirrored profile blob for {pubkey} is missing"))?;
2341 if mirrored.id != event.id {
2342 anyhow::bail!(
2343 "profile-by-pubkey returned event {} instead of {} for {pubkey}",
2344 mirrored.id,
2345 event.id
2346 );
2347 }
2348
2349 let term = profile_search_terms_for_event(event)
2350 .into_iter()
2351 .next()
2352 .with_context(|| format!("profile {pubkey} did not produce a search term"))?;
2353 let exact_key = format!("{PROFILE_SEARCH_PREFIX}{term}:{pubkey}");
2354 let encoded = block_on(self.profile_index.index.get(Some(&search_root), &exact_key))
2355 .context("query profile-search root")?
2356 .with_context(|| format!("profile-search omitted exact key {exact_key}"))?;
2357 let entry: StoredProfileSearchEntry =
2358 serde_json::from_str(&encoded).context("decode stored profile search entry JSON")?;
2359 let expected_nhash = nhash_encode_full(&NHashData {
2360 hash: mirrored_cid.hash,
2361 decrypt_key: mirrored_cid.key,
2362 })
2363 .context("encode mirrored profile event nhash")?;
2364 if entry.pubkey != pubkey
2365 || entry.created_at != event.created_at.as_secs()
2366 || entry.event_nhash != expected_nhash
2367 {
2368 anyhow::bail!(
2369 "profile-search entry for {exact_key} does not match its profile-by-pubkey event"
2370 );
2371 }
2372 Ok(entry)
2373 }
2374
2375 pub fn sync_profile_index_for_events(&self, events: &[Event]) -> Result<()> {
2376 self.update_profile_index_for_events(events)
2377 }
2378
2379 pub fn sync_profile_index_for_events_with_frozen_distances(
2383 &self,
2384 events: &[Event],
2385 decisions: &BTreeMap<String, Option<u32>>,
2386 ) -> Result<()> {
2387 self.update_profile_index_for_events_with(events, true, |event| {
2388 let pubkey = event.pubkey.to_hex();
2389 match decisions.get(&pubkey) {
2390 Some(Some(distance)) => Ok((Some(*distance), false)),
2391 Some(None) => Ok((None, true)),
2392 None => {
2393 anyhow::bail!("frozen profile rank decisions omitted metadata author {pubkey}")
2394 }
2395 }
2396 })
2397 }
2398
2399 pub fn build_unpublished_profile_index_repair_with_frozen_distances(
2407 &self,
2408 events: &[Event],
2409 decisions: &BTreeMap<String, Option<u32>>,
2410 ) -> Result<PreparedProfileIndexRepair> {
2411 if events.is_empty() {
2412 anyhow::bail!("profile-index repair requires retained kind-0 winners");
2413 }
2414 let _transaction = self
2415 .profile_index
2416 .acquire_exclusive_root_pair_transaction()?;
2417 self.recover_profile_transactions_locked()?;
2418 let old_roots = self.profile_index_roots_locked()?;
2419 let latest_by_pubkey = latest_metadata_events_by_pubkey(events);
2420 if latest_by_pubkey.len() != events.len() {
2421 anyhow::bail!(
2422 "profile-index repair inputs must contain exactly one kind-0 winner per pubkey"
2423 );
2424 }
2425 for (pubkey, event) in &latest_by_pubkey {
2426 if event.pubkey.to_hex() != *pubkey || event.kind != Kind::Metadata {
2427 anyhow::bail!("profile-index repair input for {pubkey} is not canonical metadata");
2428 }
2429 event.verify().with_context(|| {
2430 format!("verify retained profile-index repair event for {pubkey}")
2431 })?;
2432 match decisions.get(pubkey) {
2433 Some(Some(_)) => {}
2434 Some(None) => {
2435 anyhow::bail!("profile-index repair retained excluded metadata author {pubkey}")
2436 }
2437 None => anyhow::bail!(
2438 "profile-index repair rank decisions omitted metadata author {pubkey}"
2439 ),
2440 }
2441 }
2442 let (by_pubkey, search) = self
2443 .profile_index
2444 .rebuild_profile_events_with_distances_locked(
2445 latest_by_pubkey.into_values(),
2446 |event| {
2447 decisions
2448 .get(&event.pubkey.to_hex())
2449 .copied()
2450 .flatten()
2451 .map(Some)
2452 .context("eligible profile-index repair rank disappeared")
2453 },
2454 )?;
2455 let by_pubkey = by_pubkey.context("profile-index repair built an empty by-pubkey root")?;
2456 let search = search.context("profile-index repair built an empty search root")?;
2457 self.profile_index
2458 .store
2459 .force_sync()
2460 .map_err(|error| anyhow::anyhow!("force-sync unpublished profile repair: {error}"))?;
2461 let new_roots = ProfileIndexRoots {
2462 by_pubkey_file_sha256: Some(profile_index_root_file_sha256(&by_pubkey)?),
2463 search_file_sha256: Some(profile_index_root_file_sha256(&search)?),
2464 by_pubkey: Some(by_pubkey),
2465 search: Some(search),
2466 };
2467 Ok(PreparedProfileIndexRepair {
2468 old_roots,
2469 new_roots,
2470 })
2471 }
2472
2473 #[cfg(test)]
2474 pub(crate) fn crash_after_prepared_profile_root_pair_intent(
2475 &self,
2476 prepared: &PreparedProfileIndexRepair,
2477 ) -> Result<()> {
2478 let current = self.recovered_profile_index_roots()?;
2479 let current = ProfileIndexRoots {
2480 by_pubkey_file_sha256: current
2481 .0
2482 .as_ref()
2483 .map(profile_index_root_file_sha256)
2484 .transpose()?,
2485 search_file_sha256: current
2486 .1
2487 .as_ref()
2488 .map(profile_index_root_file_sha256)
2489 .transpose()?,
2490 by_pubkey: current.0,
2491 search: current.1,
2492 };
2493 if current != prepared.old_roots {
2494 anyhow::bail!("generated crash requires the exact prepared old root pair");
2495 }
2496 self.profile_index.write_roots_interrupted_after_intent(
2497 prepared.new_roots.by_pubkey.as_ref(),
2498 prepared.new_roots.search.as_ref(),
2499 )
2500 }
2501
2502 pub fn commit_prepared_profile_index_repair(
2507 &self,
2508 prepared: &PreparedProfileIndexRepair,
2509 authority: ProfileIndexRepairAuthority,
2510 ) -> Result<ProfileIndexRepairCommitOutcome> {
2511 Ok(self
2512 .commit_prepared_profile_index_repair_held(prepared, authority)?
2513 .outcome())
2514 }
2515
2516 pub fn authorize_prepared_profile_index_repair(
2519 &self,
2520 prepared: &PreparedProfileIndexRepair,
2521 validated_intent_bytes: &[u8],
2522 ) -> Result<ProfileIndexRepairAuthority> {
2523 load_profile_index_repair_authority(
2524 &self.profile_index.root_pair_lock_path,
2525 prepared,
2526 ProfileIndexRepairAuthorityPhase::Commit,
2527 Some(validated_intent_bytes),
2528 None,
2529 )
2530 }
2531
2532 pub fn authorize_completed_profile_index_repair(
2535 &self,
2536 prepared: &PreparedProfileIndexRepair,
2537 validated_intent_bytes: &[u8],
2538 validated_receipt_bytes: &[u8],
2539 ) -> Result<ProfileIndexRepairAuthority> {
2540 load_profile_index_repair_authority(
2541 &self.profile_index.root_pair_lock_path,
2542 prepared,
2543 ProfileIndexRepairAuthorityPhase::Completion,
2544 Some(validated_intent_bytes),
2545 Some(validated_receipt_bytes),
2546 )
2547 }
2548
2549 pub fn commit_prepared_profile_index_repair_held(
2554 &self,
2555 prepared: &PreparedProfileIndexRepair,
2556 authority: ProfileIndexRepairAuthority,
2557 ) -> Result<ProfileIndexRepairPublicationGuard> {
2558 self.commit_prepared_profile_index_repair_held_with(prepared, || Ok(authority))
2559 }
2560
2561 pub fn commit_prepared_profile_index_repair_held_with<F>(
2566 &self,
2567 prepared: &PreparedProfileIndexRepair,
2568 before_commit: F,
2569 ) -> Result<ProfileIndexRepairPublicationGuard>
2570 where
2571 F: FnOnce() -> Result<ProfileIndexRepairAuthority>,
2572 {
2573 let transaction = self
2574 .profile_index
2575 .acquire_exclusive_root_pair_transaction()?;
2576 let intent_preexisting =
2577 incomplete_profile_repair_intent_path(&self.profile_index.root_pair_lock_path)?
2578 .is_some();
2579 if !intent_preexisting {
2580 self.recover_profile_transactions_locked()?;
2581 let current = self.profile_index_roots_locked()?;
2582 if current != prepared.old_roots {
2583 anyhow::bail!(
2584 "published profile roots changed after repair preparation; refusing non-CAS commit"
2585 );
2586 }
2587 }
2588 let authority = before_commit()?;
2589 revalidate_profile_index_repair_authority(
2590 &authority,
2591 &self.profile_index.root_pair_lock_path,
2592 prepared,
2593 ProfileIndexRepairAuthorityPhase::Commit,
2594 )?;
2595 self.recover_profile_transactions_locked_for_repair(&authority)?;
2596 let current = self.profile_index_roots_locked()?;
2597 if current != prepared.old_roots && current != prepared.new_roots {
2598 anyhow::bail!(
2599 "published profile roots changed after repair preparation; refusing non-CAS commit"
2600 );
2601 }
2602 let outcome = if current == prepared.new_roots {
2603 ProfileIndexRepairCommitOutcome::AlreadyApplied
2604 } else {
2605 self.profile_index
2606 .write_roots_with_hooks_locked_for_repair(
2607 &authority,
2608 prepared.new_roots.by_pubkey.as_ref(),
2609 prepared.new_roots.search.as_ref(),
2610 || Ok(()),
2611 || Ok(()),
2612 )?;
2613 ProfileIndexRepairCommitOutcome::Applied
2614 };
2615 let installed = self.profile_index_roots_locked()?;
2616 if installed != prepared.new_roots {
2617 anyhow::bail!("profile-index repair commit did not install the exact prepared pair");
2618 }
2619 Ok(ProfileIndexRepairPublicationGuard {
2620 by_pubkey_root_path: self.profile_index.by_pubkey_root_path.clone(),
2621 search_root_path: self.profile_index.search_root_path.clone(),
2622 installed_roots: installed,
2623 outcome,
2624 _transaction: transaction,
2625 })
2626 }
2627
2628 pub fn hold_completed_profile_index_repair(
2633 &self,
2634 prepared: &PreparedProfileIndexRepair,
2635 authority: ProfileIndexRepairAuthority,
2636 ) -> Result<ProfileIndexRepairPublicationGuard> {
2637 let transaction = self
2638 .profile_index
2639 .acquire_exclusive_root_pair_transaction()?;
2640 revalidate_profile_index_repair_authority(
2641 &authority,
2642 &self.profile_index.root_pair_lock_path,
2643 prepared,
2644 ProfileIndexRepairAuthorityPhase::Completion,
2645 )?;
2646 let db_dir = self
2647 .profile_index
2648 .root_pair_lock_path
2649 .parent()
2650 .context("profile root-pair lock has no database parent")?;
2651 require_no_pending_profile_root_pair_commit(db_dir)?;
2652 require_no_pending_profile_projection(db_dir)?;
2653 let installed = self.profile_index_roots_locked()?;
2654 if installed != prepared.new_roots {
2655 anyhow::bail!(
2656 "completed profile repair roots differ from the exact installed repair pair"
2657 );
2658 }
2659 Ok(ProfileIndexRepairPublicationGuard {
2660 by_pubkey_root_path: self.profile_index.by_pubkey_root_path.clone(),
2661 search_root_path: self.profile_index.search_root_path.clone(),
2662 installed_roots: installed,
2663 outcome: ProfileIndexRepairCommitOutcome::AlreadyApplied,
2664 _transaction: transaction,
2665 })
2666 }
2667
2668 fn profile_index_roots_locked(&self) -> Result<ProfileIndexRoots> {
2669 let (by_pubkey, by_pubkey_file_sha256) =
2670 read_root_file_snapshot(&self.profile_index.by_pubkey_root_path)?;
2671 let (search, search_file_sha256) =
2672 read_root_file_snapshot(&self.profile_index.search_root_path)?;
2673 Ok(ProfileIndexRoots {
2674 by_pubkey,
2675 search,
2676 by_pubkey_file_sha256,
2677 search_file_sha256,
2678 })
2679 }
2680
2681 #[cfg_attr(not(test), allow(dead_code))]
2682 pub(crate) fn rebuild_profile_index_for_events(&self, events: &[Event]) -> Result<()> {
2683 let _transaction = self
2684 .profile_index
2685 .acquire_exclusive_root_pair_transaction()?;
2686 self.recover_profile_transactions_locked()?;
2687 self.rebuild_profile_index_for_events_locked(events)
2688 }
2689
2690 fn rebuild_profile_index_for_events_locked(&self, events: &[Event]) -> Result<()> {
2691 let latest_by_pubkey = self.filtered_latest_metadata_events_by_pubkey(events)?;
2692 self.profile_index
2693 .rebuild_profile_events_and_commit_with_distances_locked(
2694 latest_by_pubkey.into_values(),
2695 |event| self.follow_distance(&event.pubkey.to_bytes()),
2696 )
2697 }
2698
2699 async fn rebuild_profile_index_for_events_async_locked(&self, events: &[Event]) -> Result<()> {
2700 let latest_by_pubkey = self.filtered_latest_metadata_events_by_pubkey(events)?;
2701 self.profile_index
2702 .rebuild_profile_events_async_and_commit_with_distances_locked(
2703 latest_by_pubkey.into_values(),
2704 |event| self.follow_distance(&event.pubkey.to_bytes()),
2705 )
2706 .await
2707 }
2708
2709 pub fn rebuild_profile_index_from_stored_events(&self) -> Result<usize> {
2710 let _transaction = self
2711 .profile_index
2712 .acquire_exclusive_root_pair_transaction()?;
2713 self.rebuild_profile_index_from_stored_events_locked()
2714 }
2715
2716 fn rebuild_profile_index_from_stored_events_locked(&self) -> Result<usize> {
2717 self.recover_profile_transactions_locked()?;
2718 let public_events_root = self.public_events.events_root()?;
2719 let ambient_events_root = self.ambient_events.events_root()?;
2720 if public_events_root.is_none() && ambient_events_root.is_none() {
2721 self.profile_index
2722 .write_roots_with_hooks_locked(None, None, || Ok(()), || Ok(()))?;
2723 return Ok(0);
2724 }
2725
2726 let mut events = Vec::new();
2727 for (bucket, root) in [
2728 (&self.public_events, public_events_root),
2729 (&self.ambient_events, ambient_events_root),
2730 ] {
2731 let Some(root) = root else {
2732 continue;
2733 };
2734 let stored = block_on(bucket.event_store.list_by_kind_lossy(
2735 Some(&root),
2736 Kind::Metadata.as_u16() as u32,
2737 ListEventsOptions::default(),
2738 ))
2739 .map_err(map_event_store_error)?;
2740 events.extend(
2741 stored
2742 .into_iter()
2743 .map(stored_event_to_nostr_event)
2744 .collect::<Result<Vec<_>>>()?,
2745 );
2746 }
2747
2748 let latest_count = self
2749 .filtered_latest_metadata_events_by_pubkey(&events)?
2750 .len();
2751 self.rebuild_profile_index_for_events_locked(&events)?;
2752 Ok(latest_count)
2753 }
2754
2755 pub async fn rebuild_profile_index_from_stored_events_async(&self) -> Result<usize> {
2756 let _transaction = self
2757 .profile_index
2758 .acquire_exclusive_root_pair_transaction_async()
2759 .await?;
2760 self.rebuild_profile_index_from_stored_events_async_locked()
2761 .await
2762 }
2763
2764 async fn rebuild_profile_index_from_stored_events_async_locked(&self) -> Result<usize> {
2765 self.recover_profile_transactions_locked()?;
2766 let public_events_root = self.public_events.events_root()?;
2767 let ambient_events_root = self.ambient_events.events_root()?;
2768 if public_events_root.is_none() && ambient_events_root.is_none() {
2769 self.profile_index
2770 .write_roots_with_hooks_locked(None, None, || Ok(()), || Ok(()))?;
2771 return Ok(0);
2772 }
2773
2774 let mut events = Vec::new();
2775 for (bucket, root) in [
2776 (&self.public_events, public_events_root),
2777 (&self.ambient_events, ambient_events_root),
2778 ] {
2779 let Some(root) = root else {
2780 continue;
2781 };
2782 let stored = bucket
2783 .event_store
2784 .list_by_kind_lossy(
2785 Some(&root),
2786 Kind::Metadata.as_u16() as u32,
2787 ListEventsOptions::default(),
2788 )
2789 .await
2790 .map_err(map_event_store_error)?;
2791 events.extend(
2792 stored
2793 .into_iter()
2794 .map(stored_event_to_nostr_event)
2795 .collect::<Result<Vec<_>>>()?,
2796 );
2797 }
2798
2799 let latest_count = self
2800 .filtered_latest_metadata_events_by_pubkey(&events)?
2801 .len();
2802 self.rebuild_profile_index_for_events_async_locked(&events)
2803 .await?;
2804 Ok(latest_count)
2805 }
2806
2807 pub fn rebuild_event_indexes_from_stored_events(&self) -> Result<(usize, usize)> {
2808 let _transaction = self
2809 .profile_index
2810 .acquire_exclusive_root_pair_transaction()?;
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 =
2816 self.rebuild_event_index_bucket_from_stored_events(&self.public_events)?;
2817 let ambient_count =
2818 self.rebuild_event_index_bucket_from_stored_events(&self.ambient_events)?;
2819 self.rebuild_profile_index_from_stored_events_locked()?;
2820 Ok((public_count, ambient_count))
2821 }
2822
2823 pub async fn rebuild_event_indexes_from_stored_events_async(&self) -> Result<(usize, usize)> {
2824 let _transaction = self
2825 .profile_index
2826 .acquire_exclusive_root_pair_transaction_async()
2827 .await?;
2828 require_no_incomplete_profile_repair_for_root_write(
2829 &self.profile_index.root_pair_lock_path,
2830 )?;
2831 self.recover_profile_transactions_locked()?;
2832 let public_count = self
2833 .rebuild_event_index_bucket_from_stored_events_async(&self.public_events)
2834 .await?;
2835 let ambient_count = self
2836 .rebuild_event_index_bucket_from_stored_events_async(&self.ambient_events)
2837 .await?;
2838 self.rebuild_profile_index_from_stored_events_async_locked()
2839 .await?;
2840 Ok((public_count, ambient_count))
2841 }
2842
2843 fn rebuild_event_index_bucket_from_stored_events(
2844 &self,
2845 bucket: &EventIndexBucket,
2846 ) -> Result<usize> {
2847 let Some(root) = bucket.events_root()? else {
2848 bucket.write_events_root(None)?;
2849 return Ok(0);
2850 };
2851
2852 let manifest = match block_on(bucket.event_store.get_manifest(Some(&root))) {
2853 Ok(manifest) => manifest,
2854 Err(err) => {
2855 tracing::warn!(
2856 "Clearing invalid social graph event index root {} before rebuild: {}",
2857 hex::encode(root.hash),
2858 err
2859 );
2860 bucket.write_events_root(None)?;
2861 return Ok(0);
2862 }
2863 };
2864 if manifest.by_kind_time_author.is_none() {
2865 let next_root = block_on(bucket.event_store.upgrade_manifest_indexes(Some(&root)))
2866 .map_err(map_event_store_error)?;
2867 if next_root.as_ref() != Some(&root) {
2868 bucket.write_events_root(next_root.as_ref())?;
2869 return Ok(0);
2870 }
2871 }
2872
2873 let stored = block_on(
2874 bucket
2875 .event_store
2876 .list_recent_lossy(Some(&root), ListEventsOptions::default()),
2877 )
2878 .map_err(map_event_store_error)?;
2879 let count = stored.len();
2880 let next_root =
2881 block_on(bucket.event_store.build(None, stored)).map_err(map_event_store_error)?;
2882 bucket.write_events_root(next_root.as_ref())?;
2883 Ok(count)
2884 }
2885
2886 async fn rebuild_event_index_bucket_from_stored_events_async(
2887 &self,
2888 bucket: &EventIndexBucket,
2889 ) -> Result<usize> {
2890 let Some(root) = bucket.events_root()? else {
2891 bucket.write_events_root(None)?;
2892 return Ok(0);
2893 };
2894
2895 let manifest = match bucket.event_store.get_manifest(Some(&root)).await {
2896 Ok(manifest) => manifest,
2897 Err(err) => {
2898 tracing::warn!(
2899 "Clearing invalid social graph event index root {} before rebuild: {}",
2900 hex::encode(root.hash),
2901 err
2902 );
2903 bucket.write_events_root(None)?;
2904 return Ok(0);
2905 }
2906 };
2907 if manifest.by_kind_time_author.is_none() {
2908 let next_root = bucket
2909 .event_store
2910 .upgrade_manifest_indexes(Some(&root))
2911 .await
2912 .map_err(map_event_store_error)?;
2913 if next_root.as_ref() != Some(&root) {
2914 bucket.write_events_root(next_root.as_ref())?;
2915 return Ok(0);
2916 }
2917 }
2918
2919 let stored = bucket
2920 .event_store
2921 .list_recent_lossy(Some(&root), ListEventsOptions::default())
2922 .await
2923 .map_err(map_event_store_error)?;
2924 let count = stored.len();
2925 let next_root = bucket
2926 .event_store
2927 .build(None, stored)
2928 .await
2929 .map_err(map_event_store_error)?;
2930 bucket.write_events_root(next_root.as_ref())?;
2931 Ok(count)
2932 }
2933
2934 fn update_profile_index_for_events(&self, events: &[Event]) -> Result<()> {
2935 if !events.iter().any(|event| event.kind == Kind::Metadata) {
2936 return Ok(());
2937 }
2938 let _transaction = self
2939 .profile_index
2940 .acquire_exclusive_root_pair_transaction()?;
2941 self.recover_profile_transactions_locked()?;
2942 self.update_profile_index_for_events_locked(events)
2943 }
2944
2945 fn update_profile_index_for_events_locked(&self, events: &[Event]) -> Result<()> {
2946 let threshold = self.profile_index_overmute_threshold();
2947 self.update_profile_index_for_events_with_locked(events, false, |event| {
2948 let overmuted = self.is_overmuted_user(&event.pubkey.to_bytes(), threshold)?;
2949 let follow_distance = if overmuted {
2950 None
2951 } else {
2952 self.follow_distance(&event.pubkey.to_bytes())?
2953 };
2954 Ok((follow_distance, overmuted))
2955 })
2956 }
2957
2958 fn update_profile_index_for_events_with<F>(
2959 &self,
2960 events: &[Event],
2961 force_existing_search_value: bool,
2962 classify: F,
2963 ) -> Result<()>
2964 where
2965 F: FnMut(&Event) -> Result<(Option<u32>, bool)>,
2966 {
2967 if !events.iter().any(|event| event.kind == Kind::Metadata) {
2968 return Ok(());
2969 }
2970 let _transaction = self
2971 .profile_index
2972 .acquire_exclusive_root_pair_transaction()?;
2973 self.recover_profile_transactions_locked()?;
2974 self.update_profile_index_for_events_with_locked(
2975 events,
2976 force_existing_search_value,
2977 classify,
2978 )
2979 }
2980
2981 fn update_profile_index_for_events_with_locked<F>(
2982 &self,
2983 events: &[Event],
2984 force_existing_search_value: bool,
2985 mut classify: F,
2986 ) -> Result<()>
2987 where
2988 F: FnMut(&Event) -> Result<(Option<u32>, bool)>,
2989 {
2990 let latest_by_pubkey = latest_metadata_events_by_pubkey(events);
2991 if latest_by_pubkey.is_empty() {
2992 return Ok(());
2993 }
2994
2995 let mut updates = Vec::with_capacity(latest_by_pubkey.len());
2996 for event in latest_by_pubkey.into_values() {
2997 let (follow_distance, remove) = classify(event)?;
2998 updates.push((event, follow_distance, remove, force_existing_search_value));
2999 }
3000
3001 self.profile_index
3002 .update_profile_events_and_commit_locked(&updates)?;
3003 Ok(())
3004 }
3005
3006 fn filtered_latest_metadata_events_by_pubkey<'a>(
3007 &self,
3008 events: &'a [Event],
3009 ) -> Result<BTreeMap<String, &'a Event>> {
3010 let threshold = self.profile_index_overmute_threshold();
3011 let mut latest_by_pubkey = BTreeMap::<String, &Event>::new();
3012 for event in events.iter().filter(|event| event.kind == Kind::Metadata) {
3013 if self.is_overmuted_user(&event.pubkey.to_bytes(), threshold)? {
3014 continue;
3015 }
3016 let pubkey = event.pubkey.to_hex();
3017 match latest_by_pubkey.get(&pubkey) {
3018 Some(current) if compare_nostr_events(event, current).is_le() => {}
3019 _ => {
3020 latest_by_pubkey.insert(pubkey, event);
3021 }
3022 }
3023 }
3024 Ok(latest_by_pubkey)
3025 }
3026
3027 fn snapshot_chunks(&self, root: &[u8; 32], options: &BinaryBudget) -> Result<Vec<Bytes>> {
3028 let state = {
3029 let graph = self.graph.lock().unwrap();
3030 graph.export_state().context("export social graph state")?
3031 };
3032 let mut graph = SocialGraph::from_state(state).context("rebuild social graph state")?;
3033 let root_hex = hex::encode(root);
3034 if graph.get_root() != root_hex {
3035 graph
3036 .set_root(&root_hex)
3037 .context("set snapshot social graph root")?;
3038 }
3039 let chunks = graph
3040 .to_binary_chunks_with_budget(*options)
3041 .context("encode social graph snapshot")?;
3042 Ok(chunks.into_iter().map(Bytes::from).collect())
3043 }
3044
3045 fn ingest_event(&self, event: &Event) -> Result<()> {
3046 self.ingest_event_with_storage_class(event, self.default_storage_class_for(event)?)
3047 }
3048
3049 fn ingest_events(&self, events: &[Event]) -> Result<()> {
3050 if events.is_empty() {
3051 return Ok(());
3052 }
3053
3054 let mut public = Vec::new();
3055 let mut ambient = Vec::new();
3056 for event in events {
3057 match self.default_storage_class_for(event)? {
3058 EventStorageClass::Public => public.push(event.clone()),
3059 EventStorageClass::Ambient => ambient.push(event.clone()),
3060 }
3061 }
3062
3063 if !public.is_empty() {
3064 self.ingest_events_with_storage_class(&public, EventStorageClass::Public)?;
3065 }
3066 if !ambient.is_empty() {
3067 self.ingest_events_with_storage_class(&ambient, EventStorageClass::Ambient)?;
3068 }
3069
3070 Ok(())
3071 }
3072
3073 fn apply_graph_events_only(&self, events: &[Event]) -> Result<()> {
3074 if !events.iter().any(|event| is_social_graph_event(event.kind)) {
3075 return Ok(());
3076 }
3077 let _transaction = self
3078 .profile_index
3079 .acquire_exclusive_root_pair_transaction()?;
3080 require_no_incomplete_profile_repair_for_root_write(
3081 &self.profile_index.root_pair_lock_path,
3082 )?;
3083 self.recover_profile_transactions_locked()?;
3084 self.apply_graph_events_only_locked(events)
3085 }
3086
3087 fn apply_graph_events_only_locked(&self, events: &[Event]) -> Result<()> {
3088 let graph_events = events
3089 .iter()
3090 .filter(|event| is_social_graph_event(event.kind))
3091 .collect::<Vec<_>>();
3092 if graph_events.is_empty() {
3093 return Ok(());
3094 }
3095
3096 {
3097 let mut graph = self.graph.lock().unwrap();
3098 let mut snapshot = SocialGraph::from_state(
3099 graph
3100 .export_state()
3101 .context("export social graph state for graph-only ingest")?,
3102 )
3103 .context("rebuild social graph state for graph-only ingest")?;
3104 for event in graph_events {
3105 snapshot.handle_event(&graph_event_from_nostr(event), true, 0.0);
3106 }
3107 graph
3108 .replace_state(&snapshot.export_state())
3109 .context("replace graph-only social graph state")?;
3110 }
3111 self.invalidate_distance_cache();
3112 Ok(())
3113 }
3114
3115 fn query_events(&self, filter: &Filter, limit: usize) -> Result<Vec<Event>> {
3116 self.query_events_in_scope(filter, limit, EventQueryScope::All)
3117 }
3118
3119 fn default_storage_class_for(&self, event: &Event) -> Result<EventStorageClass> {
3120 let graph = self.graph.lock().unwrap();
3121 let root_hex = graph.get_root().context("read social graph root")?;
3122 if root_hex != DEFAULT_ROOT_HEX && root_hex == event.pubkey.to_hex() {
3123 return Ok(EventStorageClass::Public);
3124 }
3125 Ok(EventStorageClass::Ambient)
3126 }
3127
3128 fn bucket(&self, storage_class: EventStorageClass) -> &EventIndexBucket {
3129 match storage_class {
3130 EventStorageClass::Public => &self.public_events,
3131 EventStorageClass::Ambient => &self.ambient_events,
3132 }
3133 }
3134
3135 fn ingest_event_with_storage_class(
3136 &self,
3137 event: &Event,
3138 storage_class: EventStorageClass,
3139 ) -> Result<()> {
3140 self.ingest_event_with_storage_class_and_lock_timeout(
3141 event,
3142 storage_class,
3143 PROFILE_ROOT_PAIR_LOCK_TIMEOUT,
3144 )
3145 }
3146
3147 fn ingest_event_with_storage_class_and_lock_timeout(
3148 &self,
3149 event: &Event,
3150 storage_class: EventStorageClass,
3151 lock_timeout: Duration,
3152 ) -> Result<()> {
3153 let _transaction = self
3154 .profile_index
3155 .acquire_exclusive_root_pair_transaction_with_timeout(lock_timeout)?;
3156 require_no_incomplete_profile_repair_for_root_write(
3157 &self.profile_index.root_pair_lock_path,
3158 )?;
3159 self.recover_profile_transactions_locked()?;
3160 let bucket = self.bucket(storage_class);
3161 let current_root = bucket.events_root_for_write()?;
3162 let next_root = bucket.store_event(current_root.as_ref(), event)?;
3163 let projection_events =
3164 self.retained_derived_events_at_root(bucket, &next_root, std::slice::from_ref(event))?;
3165 let derived_projection =
3166 (!projection_events.is_empty()).then(|| PendingProfileProjection {
3167 version: PROFILE_PROJECTION_PENDING_VERSION,
3168 storage_class: storage_class.into(),
3169 projection: PendingProfileProjectionMode::Incremental {
3170 old_root: current_root.as_ref().map(stored_cid),
3171 new_root: stored_cid(&next_root),
3172 events: projection_events.iter().map(JsonUtil::as_json).collect(),
3173 },
3174 });
3175 if let Some(projection) = derived_projection.as_ref() {
3176 self.force_sync_event_storage(storage_class)?;
3177 self.persist_pending_profile_projection_locked(projection)?;
3178 bucket.write_events_root_durable(Some(&next_root))?;
3179 } else {
3180 bucket.write_events_root(Some(&next_root))?;
3181 }
3182
3183 if derived_projection.is_some() {
3184 self.apply_graph_events_only_locked(&projection_events)?;
3185 self.update_profile_index_for_events_locked(&projection_events)?;
3186 self.force_sync_graph_projection_for_events(&projection_events)?;
3187 self.clear_pending_profile_projection_locked()?;
3188 }
3189
3190 Ok(())
3191 }
3192
3193 fn ingest_events_with_storage_class(
3194 &self,
3195 events: &[Event],
3196 storage_class: EventStorageClass,
3197 ) -> Result<()> {
3198 if events.is_empty() {
3199 return Ok(());
3200 }
3201
3202 let _transaction = self
3203 .profile_index
3204 .acquire_exclusive_root_pair_transaction()?;
3205 require_no_incomplete_profile_repair_for_root_write(
3206 &self.profile_index.root_pair_lock_path,
3207 )?;
3208 self.recover_profile_transactions_locked()?;
3209 let bucket = self.bucket(storage_class);
3210 let current_root = bucket.events_root_for_write()?;
3211 let stored_events = events
3212 .iter()
3213 .map(stored_event_from_nostr_sdk_event)
3214 .collect::<Vec<_>>();
3215 let next_root = block_on(
3216 bucket
3217 .event_store
3218 .build(current_root.as_ref(), stored_events),
3219 )
3220 .map_err(map_event_store_error)?;
3221 let projection_events = match next_root.as_ref() {
3222 Some(root) => self.retained_derived_events_at_root(bucket, root, events)?,
3223 None => Vec::new(),
3224 };
3225 let derived_projection = if projection_events.is_empty() {
3226 None
3227 } else {
3228 let next_root = next_root
3229 .as_ref()
3230 .context("derived event batch did not produce an event root")?;
3231 Some(PendingProfileProjection {
3232 version: PROFILE_PROJECTION_PENDING_VERSION,
3233 storage_class: storage_class.into(),
3234 projection: PendingProfileProjectionMode::Incremental {
3235 old_root: current_root.as_ref().map(stored_cid),
3236 new_root: stored_cid(next_root),
3237 events: projection_events.iter().map(JsonUtil::as_json).collect(),
3238 },
3239 })
3240 };
3241 if let Some(projection) = derived_projection.as_ref() {
3242 self.force_sync_event_storage(storage_class)?;
3243 self.persist_pending_profile_projection_locked(projection)?;
3244 bucket.write_events_root_durable(next_root.as_ref())?;
3245 } else {
3246 bucket.write_events_root(next_root.as_ref())?;
3247 }
3248
3249 if derived_projection.is_some() {
3250 self.apply_graph_events_only_locked(&projection_events)?;
3251 self.update_profile_index_for_events_locked(&projection_events)?;
3252 self.force_sync_graph_projection_for_events(&projection_events)?;
3253 self.clear_pending_profile_projection_locked()?;
3254 }
3255
3256 Ok(())
3257 }
3258
3259 pub(crate) fn query_events_in_scope(
3260 &self,
3261 filter: &Filter,
3262 limit: usize,
3263 scope: EventQueryScope,
3264 ) -> Result<Vec<Event>> {
3265 if limit == 0 {
3266 return Ok(Vec::new());
3267 }
3268
3269 let buckets: &[&EventIndexBucket] = match scope {
3270 EventQueryScope::PublicOnly => &[&self.public_events],
3271 EventQueryScope::AmbientOnly => &[&self.ambient_events],
3272 EventQueryScope::All => &[&self.public_events, &self.ambient_events],
3273 };
3274
3275 let mut candidates = Vec::new();
3276 for bucket in buckets {
3277 candidates.extend(bucket.query_events(filter, limit)?);
3278 }
3279
3280 let mut deduped = dedupe_events(candidates);
3281 deduped.retain(|event| filter.match_event(event, Default::default()));
3282 deduped.truncate(limit);
3283 Ok(deduped)
3284 }
3285}
3286
3287impl SocialGraphBackend for SocialGraphStore {
3288 fn stats(&self) -> Result<SocialGraphStats> {
3289 SocialGraphStore::stats(self)
3290 }
3291
3292 fn users_by_follow_distance(&self, distance: u32) -> Result<Vec<[u8; 32]>> {
3293 SocialGraphStore::users_by_follow_distance(self, distance)
3294 }
3295
3296 fn follow_distance(&self, pk_bytes: &[u8; 32]) -> Result<Option<u32>> {
3297 SocialGraphStore::follow_distance(self, pk_bytes)
3298 }
3299
3300 fn follow_list_created_at(&self, owner: &[u8; 32]) -> Result<Option<u64>> {
3301 SocialGraphStore::follow_list_created_at(self, owner)
3302 }
3303
3304 fn followed_targets(&self, owner: &[u8; 32]) -> Result<UserSet> {
3305 SocialGraphStore::followed_targets(self, owner)
3306 }
3307
3308 fn is_overmuted_user(&self, user_pk: &[u8; 32], threshold: f64) -> Result<bool> {
3309 SocialGraphStore::is_overmuted_user(self, user_pk, threshold)
3310 }
3311
3312 fn profile_search_root(&self) -> Result<Option<Cid>> {
3313 SocialGraphStore::profile_search_root(self)
3314 }
3315
3316 fn snapshot_chunks(&self, root: &[u8; 32], options: &BinaryBudget) -> Result<Vec<Bytes>> {
3317 SocialGraphStore::snapshot_chunks(self, root, options)
3318 }
3319
3320 fn ingest_event(&self, event: &Event) -> Result<()> {
3321 SocialGraphStore::ingest_event(self, event)
3322 }
3323
3324 fn ingest_event_with_storage_class(
3325 &self,
3326 event: &Event,
3327 storage_class: EventStorageClass,
3328 ) -> Result<()> {
3329 SocialGraphStore::ingest_event_with_storage_class(self, event, storage_class)
3330 }
3331
3332 fn ingest_events(&self, events: &[Event]) -> Result<()> {
3333 SocialGraphStore::ingest_events(self, events)
3334 }
3335
3336 fn ingest_events_with_storage_class(
3337 &self,
3338 events: &[Event],
3339 storage_class: EventStorageClass,
3340 ) -> Result<()> {
3341 SocialGraphStore::ingest_events_with_storage_class(self, events, storage_class)
3342 }
3343
3344 fn ingest_graph_events(&self, events: &[Event]) -> Result<()> {
3345 SocialGraphStore::apply_graph_events_only(self, events)
3346 }
3347
3348 fn query_events(&self, filter: &Filter, limit: usize) -> Result<Vec<Event>> {
3349 SocialGraphStore::query_events(self, filter, limit)
3350 }
3351}
3352
3353impl NostrSocialGraphBackend for SocialGraphStore {
3354 type Error = UpstreamGraphBackendError;
3355
3356 fn get_root(&self) -> std::result::Result<String, Self::Error> {
3357 let graph = self.graph.lock().unwrap();
3358 graph
3359 .get_root()
3360 .context("read social graph root")
3361 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3362 }
3363
3364 fn set_root(&mut self, root: &str) -> std::result::Result<(), Self::Error> {
3365 let root_bytes =
3366 decode_pubkey(root).map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3367 SocialGraphStore::set_root(self, &root_bytes)
3368 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3369 }
3370
3371 fn handle_event(
3372 &mut self,
3373 event: &GraphEvent,
3374 allow_unknown_authors: bool,
3375 overmute_threshold: f64,
3376 ) -> std::result::Result<(), Self::Error> {
3377 let _transaction = self
3378 .profile_index
3379 .acquire_exclusive_root_pair_transaction()
3380 .map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3381 require_no_incomplete_profile_repair_for_root_write(
3382 &self.profile_index.root_pair_lock_path,
3383 )
3384 .map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3385 self.recover_profile_transactions_locked()
3386 .map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3387 {
3388 let mut graph = self.graph.lock().unwrap();
3389 graph
3390 .handle_event(event, allow_unknown_authors, overmute_threshold)
3391 .context("ingest social graph event into heed backend")
3392 .map_err(|err| UpstreamGraphBackendError(err.to_string()))?;
3393 }
3394 self.invalidate_distance_cache();
3395 Ok(())
3396 }
3397
3398 fn get_follow_distance(&self, user: &str) -> std::result::Result<u32, Self::Error> {
3399 let graph = self.graph.lock().unwrap();
3400 graph
3401 .get_follow_distance(user)
3402 .context("read social graph follow distance")
3403 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3404 }
3405
3406 fn is_following(
3407 &self,
3408 follower: &str,
3409 followed_user: &str,
3410 ) -> std::result::Result<bool, Self::Error> {
3411 let graph = self.graph.lock().unwrap();
3412 graph
3413 .is_following(follower, followed_user)
3414 .context("read social graph following edge")
3415 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3416 }
3417
3418 fn get_followed_by_user(&self, user: &str) -> std::result::Result<Vec<String>, Self::Error> {
3419 let graph = self.graph.lock().unwrap();
3420 graph
3421 .get_followed_by_user(user)
3422 .context("read followed-by-user list")
3423 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3424 }
3425
3426 fn get_followers_by_user(&self, user: &str) -> std::result::Result<Vec<String>, Self::Error> {
3427 let graph = self.graph.lock().unwrap();
3428 graph
3429 .get_followers_by_user(user)
3430 .context("read followers-by-user list")
3431 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3432 }
3433
3434 fn get_muted_by_user(&self, user: &str) -> std::result::Result<Vec<String>, Self::Error> {
3435 let graph = self.graph.lock().unwrap();
3436 graph
3437 .get_muted_by_user(user)
3438 .context("read muted-by-user list")
3439 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3440 }
3441
3442 fn get_user_muted_by(&self, user: &str) -> std::result::Result<Vec<String>, Self::Error> {
3443 let graph = self.graph.lock().unwrap();
3444 graph
3445 .get_user_muted_by(user)
3446 .context("read user-muted-by list")
3447 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3448 }
3449
3450 fn get_follow_list_created_at(
3451 &self,
3452 user: &str,
3453 ) -> std::result::Result<Option<u64>, Self::Error> {
3454 let graph = self.graph.lock().unwrap();
3455 graph
3456 .get_follow_list_created_at(user)
3457 .context("read social graph follow list timestamp")
3458 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3459 }
3460
3461 fn get_mute_list_created_at(
3462 &self,
3463 user: &str,
3464 ) -> std::result::Result<Option<u64>, Self::Error> {
3465 let graph = self.graph.lock().unwrap();
3466 graph
3467 .get_mute_list_created_at(user)
3468 .context("read social graph mute list timestamp")
3469 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3470 }
3471
3472 fn is_overmuted(&self, user: &str, threshold: f64) -> std::result::Result<bool, Self::Error> {
3473 let graph = self.graph.lock().unwrap();
3474 graph
3475 .is_overmuted(user, threshold)
3476 .context("check social graph overmute")
3477 .map_err(|err| UpstreamGraphBackendError(err.to_string()))
3478 }
3479}
3480
3481impl<T> SocialGraphBackend for Arc<T>
3482where
3483 T: SocialGraphBackend + ?Sized,
3484{
3485 fn stats(&self) -> Result<SocialGraphStats> {
3486 self.as_ref().stats()
3487 }
3488
3489 fn users_by_follow_distance(&self, distance: u32) -> Result<Vec<[u8; 32]>> {
3490 self.as_ref().users_by_follow_distance(distance)
3491 }
3492
3493 fn follow_distance(&self, pk_bytes: &[u8; 32]) -> Result<Option<u32>> {
3494 self.as_ref().follow_distance(pk_bytes)
3495 }
3496
3497 fn follow_list_created_at(&self, owner: &[u8; 32]) -> Result<Option<u64>> {
3498 self.as_ref().follow_list_created_at(owner)
3499 }
3500
3501 fn followed_targets(&self, owner: &[u8; 32]) -> Result<UserSet> {
3502 self.as_ref().followed_targets(owner)
3503 }
3504
3505 fn is_overmuted_user(&self, user_pk: &[u8; 32], threshold: f64) -> Result<bool> {
3506 self.as_ref().is_overmuted_user(user_pk, threshold)
3507 }
3508
3509 fn profile_search_root(&self) -> Result<Option<Cid>> {
3510 self.as_ref().profile_search_root()
3511 }
3512
3513 fn snapshot_chunks(&self, root: &[u8; 32], options: &BinaryBudget) -> Result<Vec<Bytes>> {
3514 self.as_ref().snapshot_chunks(root, options)
3515 }
3516
3517 fn ingest_event(&self, event: &Event) -> Result<()> {
3518 self.as_ref().ingest_event(event)
3519 }
3520
3521 fn ingest_event_with_storage_class(
3522 &self,
3523 event: &Event,
3524 storage_class: EventStorageClass,
3525 ) -> Result<()> {
3526 self.as_ref()
3527 .ingest_event_with_storage_class(event, storage_class)
3528 }
3529
3530 fn ingest_events(&self, events: &[Event]) -> Result<()> {
3531 self.as_ref().ingest_events(events)
3532 }
3533
3534 fn ingest_events_with_storage_class(
3535 &self,
3536 events: &[Event],
3537 storage_class: EventStorageClass,
3538 ) -> Result<()> {
3539 self.as_ref()
3540 .ingest_events_with_storage_class(events, storage_class)
3541 }
3542
3543 fn ingest_graph_events(&self, events: &[Event]) -> Result<()> {
3544 self.as_ref().ingest_graph_events(events)
3545 }
3546
3547 fn query_events(&self, filter: &Filter, limit: usize) -> Result<Vec<Event>> {
3548 self.as_ref().query_events(filter, limit)
3549 }
3550}
3551
3552fn should_replace_placeholder_root(graph: &HeedSocialGraph) -> Result<bool> {
3553 if graph.get_root().context("read current social graph root")? != DEFAULT_ROOT_HEX {
3554 return Ok(false);
3555 }
3556
3557 let GraphStats {
3558 users,
3559 follows,
3560 mutes,
3561 ..
3562 } = graph.size().context("size social graph")?;
3563 Ok(users <= 1 && follows == 0 && mutes == 0)
3564}
3565
3566fn decode_pubkey_set(values: Vec<String>) -> Result<UserSet> {
3567 let mut set = UserSet::new();
3568 for value in values {
3569 set.insert(decode_pubkey(&value)?);
3570 }
3571 Ok(set)
3572}
3573
3574fn decode_pubkey(value: &str) -> Result<[u8; 32]> {
3575 let mut bytes = [0u8; 32];
3576 hex::decode_to_slice(value, &mut bytes)
3577 .with_context(|| format!("decode social graph pubkey {value}"))?;
3578 Ok(bytes)
3579}
3580
3581fn is_social_graph_event(kind: Kind) -> bool {
3582 kind == Kind::ContactList || kind == Kind::MuteList
3583}
3584
3585fn is_derived_projection_event(kind: Kind) -> bool {
3586 kind == Kind::Metadata || is_social_graph_event(kind)
3587}
3588
3589fn same_unsigned_event(left: &Event, right: &Event) -> bool {
3590 left.id == right.id
3591 && left.pubkey == right.pubkey
3592 && left.created_at == right.created_at
3593 && left.kind == right.kind
3594 && left.tags == right.tags
3595 && left.content == right.content
3596}
3597
3598fn graph_event_from_nostr(event: &Event) -> GraphEvent {
3599 GraphEvent {
3600 created_at: event.created_at.as_secs(),
3601 content: event.content.clone(),
3602 tags: event
3603 .tags
3604 .iter()
3605 .map(|tag| tag.as_slice().to_vec())
3606 .collect(),
3607 kind: event.kind.as_u16() as u32,
3608 pubkey: event.pubkey.to_hex(),
3609 id: event.id.to_hex(),
3610 sig: event.sig.to_string(),
3611 }
3612}
3613
3614pub(crate) fn stored_event_to_nostr_event(event: StoredNostrEvent) -> Result<Event> {
3615 Ok(event.to_nostr_sdk_event()?)
3616}
3617
3618fn encode_cid(cid: &Cid) -> Result<Vec<u8>> {
3619 rmp_serde::to_vec_named(&StoredCid {
3620 hash: cid.hash,
3621 key: cid.key,
3622 })
3623 .context("encode social graph events root")
3624}
3625
3626fn decode_cid(bytes: &[u8]) -> Result<Option<Cid>> {
3627 let stored: StoredCid =
3628 rmp_serde::from_slice(bytes).context("decode social graph events root")?;
3629 Ok(Some(cid_from_stored(stored)))
3630}
3631
3632fn cid_from_stored(stored: StoredCid) -> Cid {
3633 Cid {
3634 hash: stored.hash,
3635 key: stored.key,
3636 }
3637}
3638
3639fn stored_cid(cid: &Cid) -> StoredCid {
3640 StoredCid {
3641 hash: cid.hash,
3642 key: cid.key,
3643 }
3644}
3645
3646fn read_root_file(path: &Path) -> Result<Option<Cid>> {
3647 match std::fs::read(path) {
3648 Ok(bytes) => decode_cid(&bytes),
3649 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3650 Err(error) => {
3651 Err(error).with_context(|| format!("read profile root file {}", path.display()))
3652 }
3653 }
3654}
3655
3656fn read_root_file_snapshot(path: &Path) -> Result<(Option<Cid>, Option<String>)> {
3657 match std::fs::read(path) {
3658 Ok(bytes) => {
3659 let digest = to_hex(&sha256(&bytes));
3660 Ok((decode_cid(&bytes)?, Some(digest)))
3661 }
3662 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok((None, None)),
3663 Err(error) => {
3664 Err(error).with_context(|| format!("read profile root file {}", path.display()))
3665 }
3666 }
3667}
3668
3669fn write_root_file(path: &Path, root: Option<&Cid>) -> Result<()> {
3670 let Some(root) = root else {
3671 if path.exists() {
3672 std::fs::remove_file(path)?;
3673 }
3674 return Ok(());
3675 };
3676
3677 let encoded = encode_cid(root)?;
3678 let tmp_path = path.with_extension("tmp");
3679 std::fs::write(&tmp_path, encoded)?;
3680 std::fs::rename(tmp_path, path)?;
3681 Ok(())
3682}
3683
3684fn write_root_file_durable(path: &Path, root: Option<&Cid>) -> Result<()> {
3685 let Some(root) = root else {
3686 return remove_file_durable(path);
3687 };
3688
3689 let encoded = encode_cid(root)?;
3690 replace_file_durable(path, &encoded, "durable social graph root")?;
3691 Ok(())
3692}
3693
3694fn fsync_parent(path: &Path) -> Result<()> {
3695 let parent = path
3696 .parent()
3697 .with_context(|| format!("{} has no parent directory", path.display()))?;
3698 File::open(parent)
3699 .with_context(|| format!("open {} for fsync", parent.display()))?
3700 .sync_all()
3701 .with_context(|| format!("fsync {}", parent.display()))
3702}
3703
3704fn replace_file_durable(path: &Path, bytes: &[u8], label: &str) -> Result<()> {
3705 let parent = path
3706 .parent()
3707 .with_context(|| format!("{} has no parent directory", path.display()))?;
3708 std::fs::create_dir_all(parent)
3709 .with_context(|| format!("create {} parent {}", label, parent.display()))?;
3710 let file_name = path
3711 .file_name()
3712 .and_then(|value| value.to_str())
3713 .with_context(|| format!("{} path is not valid UTF-8", label))?;
3714 let pending = path.with_file_name(format!(".{file_name}.pending"));
3715 let mut file = OpenOptions::new()
3716 .create(true)
3717 .truncate(true)
3718 .write(true)
3719 .open(&pending)
3720 .with_context(|| format!("open pending {} {}", label, pending.display()))?;
3721 file.write_all(bytes)
3722 .with_context(|| format!("write pending {} {}", label, pending.display()))?;
3723 file.sync_all()
3724 .with_context(|| format!("fsync pending {} {}", label, pending.display()))?;
3725 drop(file);
3726 std::fs::rename(&pending, path)
3727 .with_context(|| format!("replace {} {}", label, path.display()))?;
3728 fsync_parent(path)
3729}
3730
3731fn remove_file_durable(path: &Path) -> Result<()> {
3732 match std::fs::remove_file(path) {
3733 Ok(()) => fsync_parent(path),
3734 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3735 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
3736 }
3737}
3738
3739fn profile_root_pair_commit_bytes(commit: &ProfileRootPairCommit) -> Result<Vec<u8>> {
3740 let mut bytes =
3741 serde_json::to_vec(commit).context("encode canonical profile root-pair commit")?;
3742 bytes.push(b'\n');
3743 Ok(bytes)
3744}
3745
3746fn pending_profile_projection_bytes(projection: &PendingProfileProjection) -> Result<Vec<u8>> {
3747 let mut bytes =
3748 serde_json::to_vec(projection).context("encode canonical pending profile projection")?;
3749 bytes.push(b'\n');
3750 Ok(bytes)
3751}
3752
3753fn load_pending_profile_projection(path: &Path) -> Result<Option<PendingProfileProjection>> {
3754 let bytes = match std::fs::read(path) {
3755 Ok(bytes) => bytes,
3756 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3757 Err(error) => {
3758 return Err(error)
3759 .with_context(|| format!("read pending profile projection {}", path.display()))
3760 }
3761 };
3762 let projection: PendingProfileProjection = serde_json::from_slice(&bytes)
3763 .with_context(|| format!("parse pending profile projection {}", path.display()))?;
3764 if projection.version != PROFILE_PROJECTION_PENDING_VERSION {
3765 anyhow::bail!(
3766 "unsupported pending profile projection version {} in {}",
3767 projection.version,
3768 path.display()
3769 );
3770 }
3771 if pending_profile_projection_bytes(&projection)? != bytes {
3772 anyhow::bail!(
3773 "pending profile projection {} is not canonical",
3774 path.display()
3775 );
3776 }
3777 Ok(Some(projection))
3778}
3779
3780fn load_profile_root_pair_commit(path: &Path) -> Result<Option<ProfileRootPairCommit>> {
3781 let bytes = match std::fs::read(path) {
3782 Ok(bytes) => bytes,
3783 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3784 Err(error) => {
3785 return Err(error)
3786 .with_context(|| format!("read profile root-pair commit {}", path.display()))
3787 }
3788 };
3789 let commit: ProfileRootPairCommit = serde_json::from_slice(&bytes)
3790 .with_context(|| format!("parse profile root-pair commit {}", path.display()))?;
3791 if commit.version != PROFILE_ROOT_PAIR_COMMIT_VERSION {
3792 anyhow::bail!(
3793 "unsupported profile root-pair commit version {} in {}",
3794 commit.version,
3795 path.display()
3796 );
3797 }
3798 if profile_root_pair_commit_bytes(&commit)? != bytes {
3799 anyhow::bail!(
3800 "profile root-pair commit {} is not canonical",
3801 path.display()
3802 );
3803 }
3804 Ok(Some(commit))
3805}
3806
3807fn install_profile_root_pair_commit_with<F>(
3808 by_pubkey_path: &Path,
3809 search_path: &Path,
3810 commit_path: &Path,
3811 commit: &ProfileRootPairCommit,
3812 after_search: F,
3813) -> Result<()>
3814where
3815 F: FnOnce() -> Result<()>,
3816{
3817 let current_by_pubkey = read_root_file(by_pubkey_path)?;
3818 let current_search = read_root_file(search_path)?;
3819 let old_by_pubkey = commit.old_by_pubkey.clone().map(cid_from_stored);
3820 let old_search = commit.old_search.clone().map(cid_from_stored);
3821 let new_by_pubkey = commit.new_by_pubkey.clone().map(cid_from_stored);
3822 let new_search = commit.new_search.clone().map(cid_from_stored);
3823 let old_pair = current_by_pubkey == old_by_pubkey && current_search == old_search;
3824 let search_first_pair = current_by_pubkey == old_by_pubkey && current_search == new_search;
3825 let new_pair = current_by_pubkey == new_by_pubkey && current_search == new_search;
3826 if !old_pair && !search_first_pair && !new_pair {
3827 anyhow::bail!(
3828 "profile root-pair files do not match an allowed forward state for {}",
3829 commit_path.display()
3830 );
3831 }
3832
3833 write_root_file_durable(search_path, new_search.as_ref())?;
3837 after_search()?;
3838 write_root_file_durable(by_pubkey_path, new_by_pubkey.as_ref())?;
3839 remove_file_durable(commit_path)
3840}
3841
3842fn require_no_pending_profile_root_pair_commit(db_dir: &Path) -> Result<()> {
3843 let commit_path = db_dir.join(PROFILE_ROOT_PAIR_COMMIT_FILE);
3844 if load_profile_root_pair_commit(&commit_path)?.is_some() {
3845 anyhow::bail!(
3846 "profile root-pair commit {} is pending; open the writable social graph store to recover it before read-only audit",
3847 commit_path.display()
3848 );
3849 }
3850 Ok(())
3851}
3852
3853fn require_no_pending_profile_projection(db_dir: &Path) -> Result<()> {
3854 let path = db_dir.join(PROFILE_PROJECTION_PENDING_FILE);
3855 if load_pending_profile_projection(&path)?.is_some() {
3856 anyhow::bail!(
3857 "profile projection {} is pending; open the writable social graph store to recover it before read-only audit",
3858 path.display()
3859 );
3860 }
3861 Ok(())
3862}
3863
3864fn recover_profile_root_pair_commit_locked(db_dir: &Path) -> Result<()> {
3865 require_no_incomplete_profile_repair_for_root_write(&db_dir.join(PROFILE_ROOT_PAIR_LOCK_FILE))?;
3866 let commit_path = db_dir.join(PROFILE_ROOT_PAIR_COMMIT_FILE);
3867 let Some(commit) = load_profile_root_pair_commit(&commit_path)? else {
3868 return Ok(());
3869 };
3870 install_profile_root_pair_commit_with(
3871 &db_dir.join(PROFILES_BY_PUBKEY_ROOT_FILE),
3872 &db_dir.join(PROFILE_SEARCH_ROOT_FILE),
3873 &commit_path,
3874 &commit,
3875 || Ok(()),
3876 )
3877 .with_context(|| {
3878 format!(
3879 "recover interrupted profile root-pair commit {}",
3880 commit_path.display()
3881 )
3882 })
3883}
3884
3885fn normalize_profile_name(value: &serde_json::Value) -> Option<String> {
3886 let raw = value.as_str()?;
3887 let trimmed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
3888 if trimmed.is_empty() {
3889 return None;
3890 }
3891 Some(trimmed.chars().take(PROFILE_NAME_MAX_LENGTH).collect())
3892}
3893
3894fn extract_profile_names(profile: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
3895 let mut names = Vec::new();
3896 let mut seen = HashSet::new();
3897
3898 for key in ["display_name", "displayName", "name", "username"] {
3899 let Some(value) = profile.get(key).and_then(normalize_profile_name) else {
3900 continue;
3901 };
3902 let lowered = value.to_lowercase();
3903 if seen.insert(lowered) {
3904 names.push(value);
3905 }
3906 }
3907
3908 names
3909}
3910
3911fn should_reject_profile_nip05(local_part: &str, primary_name: &str) -> bool {
3912 if local_part.len() == 1 || local_part.starts_with("npub1") {
3913 return true;
3914 }
3915
3916 primary_name
3917 .to_lowercase()
3918 .split_whitespace()
3919 .collect::<String>()
3920 .contains(local_part)
3921}
3922
3923fn normalize_profile_nip05(
3924 profile: &serde_json::Map<String, serde_json::Value>,
3925 primary_name: Option<&str>,
3926) -> Option<String> {
3927 let raw = profile.get("nip05")?.as_str()?;
3928 let local_part = raw.split('@').next()?.trim().to_lowercase();
3929 if local_part.is_empty() {
3930 return None;
3931 }
3932 let truncated: String = local_part.chars().take(PROFILE_NAME_MAX_LENGTH).collect();
3933 if truncated.is_empty() {
3934 return None;
3935 }
3936 if primary_name.is_some_and(|name| should_reject_profile_nip05(&truncated, name)) {
3937 return None;
3938 }
3939 Some(truncated)
3940}
3941
3942fn is_search_stop_word(word: &str) -> bool {
3943 matches!(
3944 word,
3945 "a" | "an"
3946 | "the"
3947 | "and"
3948 | "or"
3949 | "but"
3950 | "in"
3951 | "on"
3952 | "at"
3953 | "to"
3954 | "for"
3955 | "of"
3956 | "with"
3957 | "by"
3958 | "from"
3959 | "is"
3960 | "it"
3961 | "as"
3962 | "be"
3963 | "was"
3964 | "are"
3965 | "this"
3966 | "that"
3967 | "these"
3968 | "those"
3969 | "i"
3970 | "you"
3971 | "he"
3972 | "she"
3973 | "we"
3974 | "they"
3975 | "my"
3976 | "your"
3977 | "his"
3978 | "her"
3979 | "its"
3980 | "our"
3981 | "their"
3982 | "what"
3983 | "which"
3984 | "who"
3985 | "whom"
3986 | "how"
3987 | "when"
3988 | "where"
3989 | "why"
3990 | "will"
3991 | "would"
3992 | "could"
3993 | "should"
3994 | "can"
3995 | "may"
3996 | "might"
3997 | "must"
3998 | "have"
3999 | "has"
4000 | "had"
4001 | "do"
4002 | "does"
4003 | "did"
4004 | "been"
4005 | "being"
4006 | "get"
4007 | "got"
4008 | "just"
4009 | "now"
4010 | "then"
4011 | "so"
4012 | "if"
4013 | "not"
4014 | "no"
4015 | "yes"
4016 | "all"
4017 | "any"
4018 | "some"
4019 | "more"
4020 | "most"
4021 | "other"
4022 | "into"
4023 | "over"
4024 | "after"
4025 | "before"
4026 | "about"
4027 | "up"
4028 | "down"
4029 | "out"
4030 | "off"
4031 | "through"
4032 | "during"
4033 | "under"
4034 | "again"
4035 | "further"
4036 | "once"
4037 )
4038}
4039
4040fn is_pure_search_number(word: &str) -> bool {
4041 if !word.chars().all(|ch| ch.is_ascii_digit()) {
4042 return false;
4043 }
4044 !(word.len() == 4
4045 && word
4046 .parse::<u16>()
4047 .is_ok_and(|year| (1900..=2099).contains(&year)))
4048}
4049
4050fn split_compound_search_word(word: &str) -> Vec<String> {
4051 let mut parts = Vec::new();
4052 let mut current = String::new();
4053 let chars: Vec<char> = word.chars().collect();
4054
4055 for (index, ch) in chars.iter().copied().enumerate() {
4056 let split_before = current.chars().last().is_some_and(|prev| {
4057 (prev.is_lowercase() && ch.is_uppercase())
4058 || (prev.is_ascii_digit() && ch.is_alphabetic())
4059 || (prev.is_alphabetic() && ch.is_ascii_digit())
4060 || (prev.is_uppercase()
4061 && ch.is_uppercase()
4062 && chars.get(index + 1).is_some_and(|next| next.is_lowercase()))
4063 });
4064
4065 if split_before && !current.is_empty() {
4066 parts.push(std::mem::take(&mut current));
4067 }
4068
4069 current.push(ch);
4070 }
4071
4072 if !current.is_empty() {
4073 parts.push(current);
4074 }
4075
4076 parts
4077}
4078
4079fn parse_search_keywords(text: &str) -> Vec<String> {
4080 let mut keywords = Vec::new();
4081 let mut seen = HashSet::new();
4082
4083 for word in text
4084 .split(|ch: char| !ch.is_alphanumeric())
4085 .filter(|word| !word.is_empty())
4086 {
4087 let mut variants = Vec::with_capacity(1 + word.len() / 4);
4088 variants.push(word.to_lowercase());
4089 variants.extend(
4090 split_compound_search_word(word)
4091 .into_iter()
4092 .map(|part| part.to_lowercase()),
4093 );
4094
4095 for lowered in variants {
4096 if lowered.chars().count() < 2
4097 || is_search_stop_word(&lowered)
4098 || is_pure_search_number(&lowered)
4099 {
4100 continue;
4101 }
4102 if seen.insert(lowered.clone()) {
4103 keywords.push(lowered);
4104 }
4105 }
4106 }
4107
4108 keywords
4109}
4110
4111#[doc(hidden)]
4112pub fn profile_search_terms_for_event(event: &Event) -> Vec<String> {
4113 let profile = match serde_json::from_str::<serde_json::Value>(&event.content) {
4114 Ok(serde_json::Value::Object(profile)) => profile,
4115 _ => serde_json::Map::new(),
4116 };
4117 let names = extract_profile_names(&profile);
4118 let primary_name = names.first().map(String::as_str);
4119 let mut parts = Vec::new();
4120 if let Some(name) = primary_name {
4121 parts.push(name.to_string());
4122 }
4123 if let Some(nip05) = normalize_profile_nip05(&profile, primary_name) {
4124 parts.push(nip05);
4125 }
4126 parts.push(event.pubkey.to_hex());
4127 if names.len() > 1 {
4128 parts.extend(names.into_iter().skip(1));
4129 }
4130 parse_search_keywords(&parts.join(" "))
4131}
4132
4133#[doc(hidden)]
4134pub fn profile_search_keys_for_event(event: &Event) -> Vec<String> {
4135 let pubkey = event.pubkey.to_hex();
4136 profile_search_terms_for_event(event)
4137 .into_iter()
4138 .map(|term| format!("{PROFILE_SEARCH_PREFIX}{term}:{pubkey}"))
4139 .collect()
4140}
4141
4142#[doc(hidden)]
4148pub fn stored_profile_search_entry_for_event(
4149 event: &Event,
4150 mirrored_cid: &Cid,
4151 follow_distance: Option<u32>,
4152) -> Result<StoredProfileSearchEntry> {
4153 index_buckets::build_profile_search_entry(event, mirrored_cid, follow_distance)
4154}
4155
4156#[doc(hidden)]
4162pub fn profile_follow_distance_seal_v2(distances: &BTreeMap<String, Option<u32>>) -> String {
4163 let mut digest = Sha256::new();
4164 digest.update(b"hashtree-profile-follow-distance-seal-v2\0");
4165 digest.update((distances.len() as u64).to_be_bytes());
4166 for (pubkey, distance) in distances {
4167 digest.update((pubkey.len() as u64).to_be_bytes());
4168 digest.update(pubkey.as_bytes());
4169 match distance {
4170 Some(distance) => {
4171 digest.update([1]);
4172 digest.update(distance.to_be_bytes());
4173 }
4174 None => digest.update([0]),
4175 }
4176 }
4177 hex::encode(digest.finalize())
4178}
4179
4180fn compare_nostr_events(left: &Event, right: &Event) -> std::cmp::Ordering {
4181 left.created_at
4182 .as_secs()
4183 .cmp(&right.created_at.as_secs())
4184 .then_with(|| left.id.to_hex().cmp(&right.id.to_hex()))
4185}
4186
4187fn map_event_store_error(err: NostrEventStoreError) -> anyhow::Error {
4188 anyhow::anyhow!("nostr event store error: {err}")
4189}
4190
4191#[cfg(test)]
4192fn ensure_social_graph_mapsize(db_dir: &Path, requested_bytes: u64) -> Result<()> {
4193 ensure_social_graph_mapsize_with_env_flags(db_dir, requested_bytes, EnvFlags::empty())
4194}
4195
4196fn ensure_social_graph_mapsize_with_env_flags(
4197 db_dir: &Path,
4198 requested_bytes: u64,
4199 env_flags: EnvFlags,
4200) -> Result<()> {
4201 let map_size = social_graph_map_size(Some(requested_bytes))?;
4202
4203 let mut options = heed::EnvOpenOptions::new();
4204 options.map_size(map_size).max_dbs(SOCIALGRAPH_MAX_DBS);
4205 unsafe {
4206 options.flags(env_flags);
4207 }
4208 let env = unsafe { ManagedEnv::open(&options, db_dir) }
4209 .context("open social graph LMDB env for resize")?;
4210 if env.info().map_size < map_size {
4211 unsafe { env.resize(map_size) }.context("resize social graph LMDB env")?;
4212 }
4213
4214 Ok(())
4215}
4216
4217fn social_graph_map_size(requested_bytes: Option<u64>) -> Result<usize> {
4218 let requested = match requested_bytes {
4219 Some(bytes) => bytes.max(MIN_SOCIALGRAPH_MAP_SIZE_BYTES),
4220 None => DEFAULT_SOCIALGRAPH_MAP_SIZE_BYTES,
4221 };
4222 let page_size = page_size_bytes() as u64;
4223 let rounded = requested
4224 .checked_add(page_size.saturating_sub(1))
4225 .map(|size| size / page_size * page_size)
4226 .unwrap_or(requested);
4227 usize::try_from(rounded).context("social graph mapsize exceeds usize")
4228}
4229
4230fn page_size_bytes() -> usize {
4231 page_size::get_granularity()
4232}
4233
4234#[cfg(test)]
4235mod tests;