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