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