chore: improce performance (#9)
Reviewed-on: https://git.reya.su/reya/signed/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
@@ -18,6 +18,7 @@ bitcoin_hashes = "1"
|
||||
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
@@ -32,6 +34,12 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
/// Relays used for indexing user's relay list (NIP-65).
|
||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
/// How long an identical fetch/sync request is suppressed after it started.
|
||||
/// A second panel for the same repository (or the global and per-author
|
||||
/// list stores at login) doesn't duplicate a sync that just ran; after the
|
||||
/// window, re-fetching is allowed again so data stays fresh.
|
||||
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendEvent {
|
||||
/// User has no signer configured.
|
||||
@@ -84,6 +92,10 @@ pub struct Backend {
|
||||
/// Whether the stored credential is NIP-49 encrypted and a passphrase
|
||||
/// is still needed to resume the session.
|
||||
passphrase_required: bool,
|
||||
/// Fingerprints of recently started fetches/syncs (relay + filter set),
|
||||
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
|
||||
/// one. Entries are pruned lazily on the next request.
|
||||
recent_fetches: HashMap<u64, Instant>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
@@ -134,6 +146,7 @@ impl Backend {
|
||||
connected: false,
|
||||
sync_progress: None,
|
||||
passphrase_required: false,
|
||||
recent_fetches: HashMap::new(),
|
||||
tasks: vec![pump],
|
||||
};
|
||||
|
||||
@@ -245,9 +258,8 @@ impl Backend {
|
||||
/// Decrypt the NIP-49 encrypted credential stored in the keyring with
|
||||
/// the given passphrase and resume the session.
|
||||
///
|
||||
/// The scrypt decryption runs off the UI thread. The returned task
|
||||
/// yields the public key on success, or the failure reason (e.g. wrong
|
||||
/// passphrase), so callers can render inline errors.
|
||||
/// The scrypt decryption runs off the UI thread. The task yields the
|
||||
/// public key, or the failure reason (e.g. wrong passphrase).
|
||||
pub fn restore_with_passphrase(
|
||||
&mut self,
|
||||
password: &str,
|
||||
@@ -286,9 +298,8 @@ impl Backend {
|
||||
/// passphrase (NIP-49) and persist it in the keyring, then publish the
|
||||
/// user's NIP-65 relay list, metadata and grasp list.
|
||||
///
|
||||
/// The heavy encryption runs off the UI thread. The returned task yields
|
||||
/// the new public key on success, or the failure reason, so callers can
|
||||
/// render progress and inline errors.
|
||||
/// The encryption runs off the UI thread; the task yields the new
|
||||
/// public key.
|
||||
pub fn create_identity(
|
||||
&mut self,
|
||||
name: &str,
|
||||
@@ -386,11 +397,8 @@ impl Backend {
|
||||
/// a push for a not-yet-existing repository while that authorization is
|
||||
/// pending (it expires after 30 minutes), like gitworkshop and ngit.
|
||||
///
|
||||
/// The git work (init, commit, push) runs on background threads. The
|
||||
/// returned task yields the published announcement on success, so
|
||||
/// callers can open the new repository right away. The announcement's
|
||||
/// `relays` tag carries the grasp servers, which are also added to the
|
||||
/// relay pool so the published events reach them.
|
||||
/// The git work runs on background threads; the task yields the
|
||||
/// published announcement.
|
||||
pub fn create_repository(
|
||||
&mut self,
|
||||
name: &str,
|
||||
@@ -435,8 +443,7 @@ impl Backend {
|
||||
let servers = grasp_servers.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// 1. Initialize the local clone (main branch + README + initial
|
||||
// commit) on a background thread.
|
||||
// Initialize the local clone (main branch + README + initial commit).
|
||||
let work = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
let name = name.clone();
|
||||
@@ -467,16 +474,14 @@ impl Backend {
|
||||
let commit_sha =
|
||||
Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?;
|
||||
|
||||
// 2. Ensure the grasp servers are in the relay pool; the nostr
|
||||
// client queues events until each relay is connected.
|
||||
// The nostr client queues events until each relay is connected.
|
||||
this.update(cx, |this, cx| {
|
||||
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
|
||||
this.add_relays(urls, cx);
|
||||
})?;
|
||||
|
||||
// 3. Publish the announcement, then the state event, to the
|
||||
// grasp relays. The state event is the push authorization
|
||||
// ("purgatory"), so it must be accepted before step 4.
|
||||
// The state event is the push authorization ("purgatory"), so
|
||||
// it must be accepted before the push below.
|
||||
let announcement = GitRepositoryAnnouncement {
|
||||
id: repo_id.clone(),
|
||||
name: Some(name.clone()),
|
||||
@@ -522,9 +527,8 @@ impl Backend {
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Push the initial commit to every grasp server. A server
|
||||
// that fails to accept the push is logged, but the creation
|
||||
// only fails when no server accepted it.
|
||||
// Push to every grasp server; creation only fails when no
|
||||
// server accepted it.
|
||||
let push = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
let owner = owner.clone();
|
||||
@@ -555,14 +559,8 @@ impl Backend {
|
||||
/// state to the grasp relays, then push every branch and tag to each
|
||||
/// grasp server. Also points `origin` at the first grasp server.
|
||||
///
|
||||
/// The events must reach the grasp servers *before* the push, like
|
||||
/// [`Self::create_repository`]: GRASP servers hold the signed state
|
||||
/// event in "purgatory" and only accept a push while that
|
||||
/// authorization is pending.
|
||||
///
|
||||
/// The git work (ref listing, push) runs on background threads. The
|
||||
/// returned task yields the published announcement on success, so
|
||||
/// callers can switch the repository into its NIP-34 mode.
|
||||
/// Same ordering constraint as [`Self::create_repository`]: the state
|
||||
/// event ("purgatory") must be accepted before the push.
|
||||
pub fn publish_local_repo(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
@@ -586,9 +584,8 @@ impl Backend {
|
||||
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
|
||||
};
|
||||
|
||||
// The repository identifier is derived from the name, like
|
||||
// [`Self::create_repository`]: spaces become hyphens, other
|
||||
// non-alphanumeric characters (except `/`) become hyphens.
|
||||
// The repository identifier is derived from the name as in
|
||||
// [`Self::create_repository`].
|
||||
let repo_id = identifier_from_name(&name);
|
||||
|
||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||
@@ -607,8 +604,6 @@ impl Backend {
|
||||
let servers = grasp_servers.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// 1. Read the local repository's refs (branches, tags, HEAD)
|
||||
// and its root commit on a background thread.
|
||||
let work = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
async move {
|
||||
@@ -619,16 +614,14 @@ impl Backend {
|
||||
});
|
||||
let (state, euc) = work.await?;
|
||||
|
||||
// 2. Ensure the grasp servers are in the relay pool; the nostr
|
||||
// client queues events until each relay is connected.
|
||||
// The nostr client queues events until each relay is connected.
|
||||
this.update(cx, |this, cx| {
|
||||
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
|
||||
this.add_relays(urls, cx);
|
||||
})?;
|
||||
|
||||
// 3. Publish the announcement, then the state event, to the
|
||||
// grasp relays. The state event is the push authorization
|
||||
// ("purgatory"), so it must be accepted before step 4.
|
||||
// The state event is the push authorization ("purgatory"), so
|
||||
// it must be accepted before the push below.
|
||||
let announcement = GitRepositoryAnnouncement {
|
||||
id: repo_id.clone(),
|
||||
name: Some(name.clone()),
|
||||
@@ -672,10 +665,9 @@ impl Backend {
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Push every branch and tag to each grasp server. A server
|
||||
// that fails to accept the push is logged, but the init only
|
||||
// fails when no server accepted it. An empty repository
|
||||
// (no refs yet) has nothing to push.
|
||||
// Push every branch and tag to each grasp server; the init
|
||||
// only fails when no server accepted it. An empty repository
|
||||
// has nothing to push.
|
||||
if !refs.is_empty() {
|
||||
let push = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
@@ -697,8 +689,8 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Point `origin` at the first grasp server so later pushes
|
||||
// have a target, like the create flow.
|
||||
// Point `origin` at the first grasp server so later pushes
|
||||
// have a target.
|
||||
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
let path = path.clone();
|
||||
@@ -732,15 +724,13 @@ impl Backend {
|
||||
let relays = announcement.relays.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// 1. Read the current refs of the local clone.
|
||||
let work = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
async move { signed_git::worktree_ref_state(&path) }
|
||||
});
|
||||
let state = work.await?;
|
||||
|
||||
// 2. Publish a fresh state event; grasp servers authorize a
|
||||
// push by the state they have seen.
|
||||
// Grasp servers authorize a push by the state they have seen.
|
||||
let refs = state.refs.clone();
|
||||
let head = state.head.clone();
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -749,7 +739,6 @@ impl Backend {
|
||||
})?
|
||||
.await?;
|
||||
|
||||
// 3. Push every branch and tag to the announced grasp servers.
|
||||
if !refs.is_empty() {
|
||||
let push = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
@@ -1106,25 +1095,53 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`]
|
||||
/// and is still recent enough to suppress a duplicate. Records the
|
||||
/// fingerprint (after pruning expired entries) when returning `false`.
|
||||
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
|
||||
self.recent_fetches
|
||||
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
|
||||
if self.recent_fetches.contains_key(&fingerprint) {
|
||||
return true;
|
||||
}
|
||||
self.recent_fetches.insert(fingerprint, Instant::now());
|
||||
false
|
||||
}
|
||||
|
||||
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and
|
||||
/// fetch its events from them: a one-shot auto-closing subscription for
|
||||
/// `filters`, plus a negentropy sync so issues, patches and PRs stored
|
||||
/// only on those relays are not missed.
|
||||
///
|
||||
/// Best-effort: failures are logged, not surfaced, because the bootstrap
|
||||
/// relays already cover the repository. The relays stay in the pool, so
|
||||
/// events the user publishes for this repository also reach them.
|
||||
/// Deduplicated: an identical request (same relays and filters) started
|
||||
/// within [`FETCH_DEDUP_WINDOW`] is skipped, so a second panel for the
|
||||
/// same repository doesn't re-run the fetch.
|
||||
///
|
||||
/// Best-effort: failures are logged, not surfaced. The relays stay in
|
||||
/// the pool, so later publishes for this repository also reach them.
|
||||
pub fn connect_repo_relays(
|
||||
&mut self,
|
||||
relays: Vec<RelayUrl>,
|
||||
filters: Vec<Filter>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let relay_strs: Vec<&str> = relays.iter().map(|url| url.as_str()).collect();
|
||||
let fingerprint = fetch_fingerprint(&relay_strs, &filters);
|
||||
if self.fetch_recently_started(fingerprint) {
|
||||
log::debug!("skipping duplicate repo relay fetch");
|
||||
return;
|
||||
}
|
||||
|
||||
let client = self.client.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
|
||||
log::warn!("repo relay fetch failed: {e}");
|
||||
// Allow an immediate retry after a failure.
|
||||
this.update(cx, |this, _cx| {
|
||||
this.recent_fetches.remove(&fingerprint);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
@@ -1152,7 +1169,17 @@ impl Backend {
|
||||
/// reconciles the local database with the relays in both directions.
|
||||
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to
|
||||
/// whole-percent changes) and [`BackendEvent::Synced`] on completion.
|
||||
///
|
||||
/// Deduplicated: an identical sync started within
|
||||
/// [`FETCH_DEDUP_WINDOW`] is skipped. Observers still see the original
|
||||
/// sync's progress and completion events.
|
||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
|
||||
if self.fetch_recently_started(fingerprint) {
|
||||
log::debug!("skipping duplicate bootstrap sync");
|
||||
return;
|
||||
}
|
||||
|
||||
let client = self.client.clone();
|
||||
|
||||
self.sync_progress = Some((0, 0));
|
||||
@@ -1210,6 +1237,8 @@ impl Backend {
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.sync_progress = None;
|
||||
// Allow an immediate retry after a failure.
|
||||
this.recent_fetches.remove(&fingerprint);
|
||||
cx.emit(BackendEvent::error(e.to_string()))
|
||||
})?;
|
||||
}
|
||||
@@ -1221,10 +1250,9 @@ impl Backend {
|
||||
/// Sign, broadcast and locally store an event. Emits
|
||||
/// [`BackendEvent::Published`] on success so stores can refresh.
|
||||
///
|
||||
/// The returned task yields the outcome of this specific action, so
|
||||
/// callers can show inline progress/errors instead of relying on
|
||||
/// the global [`BackendEvent::Error`]. The task is owned by the caller;
|
||||
/// dropping it cancels the publish.
|
||||
/// The task yields the outcome of this specific action (for inline
|
||||
/// progress/errors) and is owned by the caller; dropping it cancels
|
||||
/// the publish.
|
||||
pub fn send(
|
||||
&mut self,
|
||||
builder: EventBuilder,
|
||||
@@ -1329,6 +1357,20 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and
|
||||
/// filters are sorted first so the fingerprint is order-independent.
|
||||
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
||||
let mut relays: Vec<&str> = relays.to_vec();
|
||||
relays.sort_unstable();
|
||||
let mut filters: Vec<&Filter> = filters.iter().collect();
|
||||
filters.sort_unstable();
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
relays.hash(&mut hasher);
|
||||
filters.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Add the given relays, connect to them, and fetch the filters: a one-shot
|
||||
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter
|
||||
/// as a second pass, so events that race with the subscription or relays
|
||||
@@ -1343,10 +1385,15 @@ async fn connect_repo_relays_only(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut added = false;
|
||||
for url in &relays {
|
||||
client.add_relay(url).await?;
|
||||
added |= client.add_relay(url).await?;
|
||||
}
|
||||
// Connecting is only needed when the pool grew; connected relays no-op,
|
||||
// but the call still iterates every relay in the pool.
|
||||
if added {
|
||||
client.connect().await;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
@@ -1358,17 +1405,21 @@ async fn connect_repo_relays_only(
|
||||
.collect();
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
for filter in filters {
|
||||
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
|
||||
if let Err(e) = client
|
||||
.sync(filter)
|
||||
.with(relays.iter())
|
||||
.opts(sync_opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("repo relay negentropy sync failed: {e}");
|
||||
// Sync the filters concurrently: each reconciles against every relay
|
||||
// either way, and a relay without NEG-XX support otherwise serializes
|
||||
// its initial timeout behind every other filter.
|
||||
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
|
||||
let syncs = filters.into_iter().map(|filter| {
|
||||
let client = &client;
|
||||
let relays = &relays;
|
||||
let sync_opts = sync_opts.clone();
|
||||
async move {
|
||||
if let Err(e) = client.sync(filter).with(relays.iter()).opts(sync_opts).await {
|
||||
log::warn!("repo relay negentropy sync failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
futures::future::join_all(syncs).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
|
||||
.install_default()
|
||||
.ok();
|
||||
|
||||
// Initialize the nostr client and universal signer.
|
||||
let (client, signer) = cx.foreground_executor().block_on(async move {
|
||||
let path = db_path.as_ref().to_path_buf();
|
||||
new_backend(path)
|
||||
@@ -42,24 +41,18 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
|
||||
.expect("failed to initialize nostr backend")
|
||||
});
|
||||
|
||||
// Initialize the backend and stores.
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
|
||||
// Initialize the profile store.
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// Start the explore list from the local database before
|
||||
// the first window opens, relay syncs continue in the background,
|
||||
// so the list never waits for them.
|
||||
// Seed the explore list from the local database; relay syncs continue
|
||||
// in the background.
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
|
||||
// The clone cache is only meaningful on native platforms,
|
||||
// the wasm build registers an empty store so `GitStore::global` still works.
|
||||
// The clone cache is native-only; wasm registers an empty store so
|
||||
// `GitStore::global` still works.
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
|
||||
// Scan the default directories (Desktop, Documents) for local git
|
||||
// repositories; the sidebar lists them next to the user's NIP-34 repos.
|
||||
LocalReposStore::set_global(
|
||||
cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)),
|
||||
cx,
|
||||
@@ -71,26 +64,13 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
|
||||
/// Initialize the backend with an in-memory database on wasm.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||
// Initialize the nostr client and universal signer.
|
||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||
|
||||
// Initialize the backend and stores.
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
|
||||
// Initialize the profile store.
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// Start the explore list from the local database before
|
||||
// the first window opens, relay syncs continue in the background,
|
||||
// so the list never waits for them.
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
|
||||
// The clone cache is only meaningful on native platforms,
|
||||
// the wasm build registers an empty store so `GitStore::global` still works.
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
|
||||
// No filesystem scan on wasm: there are no local git repositories.
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||
|
||||
entity
|
||||
|
||||
+134
-66
@@ -1,5 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
@@ -33,11 +33,21 @@ pub struct RepoStore {
|
||||
pub pull_requests: Vec<Event>,
|
||||
/// Comments on issues / PRs, oldest first.
|
||||
pub comments: Vec<Event>,
|
||||
statuses: Vec<Event>,
|
||||
/// Resolved status per root event (issue / patch / PR), recomputed on
|
||||
/// every refresh so render paths are HashMap lookups instead of
|
||||
/// scanning all status events per root.
|
||||
status_by_root: HashMap<EventId, RepoStatus>,
|
||||
/// Open issue / root PR counts, computed with [`Self::status_by_root`]
|
||||
/// on every refresh.
|
||||
open_issue_count: usize,
|
||||
open_pr_count: usize,
|
||||
/// Kind-1624 cover notes and kind-1985 label events referencing this
|
||||
/// repository's roots (ngit / GitWorkshop extensions).
|
||||
cover_notes: Vec<Event>,
|
||||
labels: Vec<Event>,
|
||||
/// Incremented on every applied refresh; views key their derived-data
|
||||
/// caches to it instead of recomputing on every render.
|
||||
version: u64,
|
||||
/// Error of the last action initiated from this store, if any.
|
||||
pub last_error: Option<String>,
|
||||
/// Relays announced by this repository (NIP-34 `relays` tag) that we
|
||||
@@ -111,9 +121,12 @@ impl RepoStore {
|
||||
patches: Vec::new(),
|
||||
pull_requests: Vec::new(),
|
||||
comments: Vec::new(),
|
||||
statuses: Vec::new(),
|
||||
status_by_root: HashMap::new(),
|
||||
open_issue_count: 0,
|
||||
open_pr_count: 0,
|
||||
cover_notes: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
version: 0,
|
||||
last_error: None,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
@@ -141,8 +154,13 @@ impl RepoStore {
|
||||
/// deletions targeting it.
|
||||
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
|
||||
let mut filters = vec![
|
||||
filters::announcement(addr),
|
||||
filters::state(addr),
|
||||
// Announcement and state share author and identifier, so they
|
||||
// combine into one filter: one fewer negentropy reconciliation
|
||||
// per relay when fetching from the repo's announced relays.
|
||||
Filter::new()
|
||||
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
||||
.author(addr.public_key)
|
||||
.identifier(addr.identifier.clone()),
|
||||
filters::activity(addr),
|
||||
];
|
||||
// Deletion requests (NIP-09/62) must be known before any event of
|
||||
@@ -293,7 +311,7 @@ impl RepoStore {
|
||||
.chain(&pull_requests)
|
||||
.map(|e| e.id);
|
||||
for root in roots {
|
||||
for event in db.query(filters::statuses_for(root)).await? {
|
||||
for event in db.query(filters::statuses_for([root])).await? {
|
||||
if seen_statuses.insert(event.id) {
|
||||
statuses.push(event);
|
||||
}
|
||||
@@ -312,7 +330,7 @@ impl RepoStore {
|
||||
.chain(&pull_requests)
|
||||
.map(|e| e.id);
|
||||
for root in roots {
|
||||
for event in db.query(filters::annotations_for(root)).await? {
|
||||
for event in db.query(filters::annotations_for([root])).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
@@ -331,13 +349,36 @@ impl RepoStore {
|
||||
sort_newest_first(&mut cover_notes);
|
||||
sort_newest_first(&mut labels);
|
||||
|
||||
// Resolve every root's status once here; render paths do
|
||||
// HashMap lookups instead of scanning all status events per
|
||||
// root (quadratic, with an allocation per pair).
|
||||
let maintainers = announcement
|
||||
.as_ref()
|
||||
.map(Announcement::effective_maintainers)
|
||||
.unwrap_or_default();
|
||||
let status_by_root =
|
||||
resolve_statuses(&issues, &patches, &pull_requests, &statuses, &maintainers);
|
||||
let open_issue_count = issues
|
||||
.iter()
|
||||
.filter(|issue| status_of(&status_by_root, issue) == RepoStatus::Open)
|
||||
.count();
|
||||
let open_pr_count = pull_requests
|
||||
.iter()
|
||||
.filter(|pr| {
|
||||
pr.kind == Kind::GitPullRequest
|
||||
&& status_of(&status_by_root, pr) == RepoStatus::Open
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok::<_, Error>((
|
||||
announcement,
|
||||
state,
|
||||
issues,
|
||||
patches,
|
||||
pull_requests,
|
||||
statuses,
|
||||
status_by_root,
|
||||
open_issue_count,
|
||||
open_pr_count,
|
||||
comments,
|
||||
cover_notes,
|
||||
labels,
|
||||
@@ -353,7 +394,9 @@ impl RepoStore {
|
||||
issues,
|
||||
patches,
|
||||
pull_requests,
|
||||
statuses,
|
||||
status_by_root,
|
||||
open_issue_count,
|
||||
open_pr_count,
|
||||
comments,
|
||||
cover_notes,
|
||||
labels,
|
||||
@@ -389,9 +432,12 @@ impl RepoStore {
|
||||
this.patches = patches;
|
||||
this.pull_requests = pull_requests;
|
||||
this.comments = comments;
|
||||
this.statuses = statuses;
|
||||
this.status_by_root = status_by_root;
|
||||
this.open_issue_count = open_issue_count;
|
||||
this.open_pr_count = open_pr_count;
|
||||
this.cover_notes = cover_notes;
|
||||
this.labels = labels;
|
||||
this.version = this.version.wrapping_add(1);
|
||||
|
||||
// Comments, statuses without an `a` tag, cover notes and
|
||||
// labels are not addressed to the repository, so fetch them
|
||||
@@ -411,25 +457,18 @@ impl RepoStore {
|
||||
.collect();
|
||||
if !new_roots.is_empty() {
|
||||
this.root_fetches.extend(new_roots.iter().copied());
|
||||
let comment_filters = filters::comments_for(new_roots.clone());
|
||||
let status_filters: Vec<Filter> = new_roots
|
||||
.iter()
|
||||
.copied()
|
||||
.map(filters::statuses_for)
|
||||
.collect();
|
||||
let annotation_filters: Vec<Filter> = new_roots
|
||||
.into_iter()
|
||||
.map(filters::annotations_for)
|
||||
.collect();
|
||||
// Batch the per-root filters: one statuses filter and one
|
||||
// annotations filter covering all new roots, instead of
|
||||
// one filter per root (each filter is a separate
|
||||
// negentropy reconciliation per relay).
|
||||
let mut root_filters = filters::comments_for(new_roots.clone());
|
||||
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
|
||||
root_filters.push(filters::annotations_for(new_roots));
|
||||
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
||||
let backend = Backend::global(cx);
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.subscribe_bootstrap(comment_filters.clone(), cx);
|
||||
backend.connect_repo_relays(announced.clone(), comment_filters, cx);
|
||||
backend.subscribe_bootstrap(status_filters.clone(), cx);
|
||||
backend.connect_repo_relays(announced.clone(), status_filters, cx);
|
||||
backend.subscribe_bootstrap(annotation_filters.clone(), cx);
|
||||
backend.connect_repo_relays(announced, annotation_filters, cx);
|
||||
backend.subscribe_bootstrap(root_filters.clone(), cx);
|
||||
backend.connect_repo_relays(announced, root_filters, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -454,20 +493,17 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Resolve the status of a root event (issue / patch / PR) per NIP-34.
|
||||
/// Resolve the status of a root event (issue / patch / PR) per NIP-34:
|
||||
/// a lookup into the map built on the last refresh.
|
||||
pub fn status_of(&self, root: &Event) -> RepoStatus {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(Announcement::effective_maintainers)
|
||||
.unwrap_or_default();
|
||||
status_of(&self.status_by_root, root)
|
||||
}
|
||||
|
||||
let events = self
|
||||
.statuses
|
||||
.iter()
|
||||
.filter(|e| signed_core::references_root(e, &root.id));
|
||||
|
||||
signed_core::resolve_status(events, &root.pubkey, &maintainers)
|
||||
/// Refresh generation, incremented on every applied refresh. Views use
|
||||
/// it to key their derived-data caches (filtered lists, counts) so
|
||||
/// renders that change nothing stay O(1).
|
||||
pub fn version(&self) -> u64 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// The effective cover note of `root` (kind 1624), if any: the latest
|
||||
@@ -509,21 +545,16 @@ impl RepoStore {
|
||||
|
||||
/// Number of open issues: issues whose resolved status is
|
||||
/// [`RepoStatus::Open`] (issues without status events default to open).
|
||||
/// Cached on the last refresh.
|
||||
pub fn issue_count(&self) -> usize {
|
||||
self.issues
|
||||
.iter()
|
||||
.filter(|issue| self.status_of(issue) == RepoStatus::Open)
|
||||
.count()
|
||||
self.open_issue_count
|
||||
}
|
||||
|
||||
/// Number of open pull requests: root PR events (not PR updates, whose
|
||||
/// status is carried by the root) with a resolved status of
|
||||
/// [`RepoStatus::Open`].
|
||||
/// [`RepoStatus::Open`]. Cached on the last refresh.
|
||||
pub fn pull_request_count(&self) -> usize {
|
||||
self.pull_requests
|
||||
.iter()
|
||||
.filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open)
|
||||
.count()
|
||||
self.open_pr_count
|
||||
}
|
||||
|
||||
/// Whether `user` is the author (owner) of this repository: the public
|
||||
@@ -586,15 +617,12 @@ impl RepoStore {
|
||||
/// (kind 1617) carrying the `git format-patch` output, which the PR
|
||||
/// references via an `e` tag (NIP-34).
|
||||
///
|
||||
/// The patch is published first and the PR is sent once the patch
|
||||
/// event's id is known, so the two always arrive together. The proposed
|
||||
/// commit is parsed from the patch's `From <commit>` header; publishing
|
||||
/// without one is refused, because the PR's `c` tag (and the patch's
|
||||
/// `commit`/`r` tags) must carry a real commit id for other NIP-34
|
||||
/// clients to verify and apply the proposal. The PR's `clone` tag
|
||||
/// carries the repository's announced mirror URLs (the commit may not be
|
||||
/// pushed there yet; the linked patch is the source of truth until a
|
||||
/// push backend exists).
|
||||
/// The patch is published first so the PR can reference its id. The
|
||||
/// proposed commit is parsed from the patch's `From <commit>` header;
|
||||
/// without one publishing is refused, because the PR's `c` tag must
|
||||
/// carry a real commit id for other NIP-34 clients to verify and apply
|
||||
/// the proposal. The `clone` tag carries the announced mirror URLs; the
|
||||
/// linked patch is the source of truth until the commit is pushed there.
|
||||
pub fn open_pull_request(
|
||||
&mut self,
|
||||
subject: Option<String>,
|
||||
@@ -783,10 +811,9 @@ impl RepoStore {
|
||||
/// the merged status.
|
||||
///
|
||||
/// Only the repository author may merge. The clone is created on demand
|
||||
/// from the announcement's clone URLs when the repository hasn't been
|
||||
/// mirrored locally yet. Patch application runs on a background thread
|
||||
/// (`git am`); failures (e.g. a patch that no longer applies) surface in
|
||||
/// [`Self::last_error`] and no status is sent.
|
||||
/// from the announcement's clone URLs when needed. Patch application
|
||||
/// (`git am`) runs on a background thread; failures (e.g. a patch that
|
||||
/// no longer applies) surface in [`Self::last_error`].
|
||||
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
|
||||
@@ -860,6 +887,49 @@ where
|
||||
events.into_iter().max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
/// Status of `root` from the precomputed map; roots without status events
|
||||
/// default to [`RepoStatus::Open`], like [`signed_core::resolve_status`].
|
||||
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
|
||||
status_by_root
|
||||
.get(&root.id)
|
||||
.copied()
|
||||
.unwrap_or(RepoStatus::Open)
|
||||
}
|
||||
|
||||
/// Resolve the status of every root event in one pass: status events are
|
||||
/// indexed by the root they reference (`e`/`E` tag), then each root
|
||||
/// resolves against its own slice. O(roots + statuses) instead of the
|
||||
/// O(roots × statuses) of resolving per root on demand.
|
||||
fn resolve_statuses(
|
||||
issues: &[Event],
|
||||
patches: &[Event],
|
||||
pull_requests: &[Event],
|
||||
statuses: &[Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> HashMap<EventId, RepoStatus> {
|
||||
let mut by_root: HashMap<EventId, Vec<&Event>> = HashMap::new();
|
||||
for event in statuses {
|
||||
for tag in event.tags.iter() {
|
||||
if matches!(tag.kind(), "e" | "E")
|
||||
&& let Some(id) = tag.content().and_then(|hex| EventId::from_hex(hex).ok())
|
||||
{
|
||||
by_root.entry(id).or_default().push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues
|
||||
.iter()
|
||||
.chain(patches)
|
||||
.chain(pull_requests)
|
||||
.map(|root| {
|
||||
let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let status = signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers);
|
||||
(root.id, status)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sort_newest_first(events: &mut [Event]) {
|
||||
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
|
||||
}
|
||||
@@ -876,12 +946,10 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
|
||||
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
|
||||
}
|
||||
|
||||
/// Build a NIP-22 kind-1111 comment using the SDK's [`CommentBuilder`]:
|
||||
/// uppercase `E`/`K`/`P` tags scope the thread root, lowercase `e`/`k`/`p`
|
||||
/// tags the direct parent (`parent`, or the root itself for a top-level
|
||||
/// comment). An `a` tag with the repository coordinate is added so Signed's
|
||||
/// own activity subscriptions also match the comment (it is not part of
|
||||
/// NIP-22).
|
||||
/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the
|
||||
/// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a
|
||||
/// top-level comment). An `a` tag with the repository coordinate (not part
|
||||
/// of NIP-22) is added so Signed's own activity subscriptions also match.
|
||||
fn comment_builder(
|
||||
root: &Event,
|
||||
parent: Option<&Event>,
|
||||
|
||||
@@ -22,8 +22,7 @@ impl Global for GlobalRepoListStore {}
|
||||
|
||||
/// Counts of NIP-34 activity events per repository, used to rank the
|
||||
/// explore list by popularity. Each patch event is a pushed commit (or a
|
||||
/// small commit series), which is the closest cross-repository proxy for
|
||||
/// commit count available from event data alone.
|
||||
/// small series), the closest proxy for commit count in the event data.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RepoActivityCounts {
|
||||
/// Root `30611` issue events addressed to the repository.
|
||||
|
||||
Reference in New Issue
Block a user