Skip to main content

hashtree_cli/socialgraph/
mod.rs

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