Skip to main content

hashtree_cli/socialgraph/
mod.rs

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