sync user own repos

This commit is contained in:
2026-09-25 20:01:28 +07:00
parent 42c1229a90
commit 333989813e
3 changed files with 53 additions and 80 deletions
+11 -55
View File
@@ -205,19 +205,15 @@ impl RepoStore {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
let coordinate = update.coordinate.as_ref() == Some(addr);
let author = update.author == addr.public_key;
// Announcements and state share the owner and identifier.
// State events carry `d`, not `a`, so `coordinate` misses them.
let authored = (update.kind == Kind::GitRepoAnnouncement
|| update.kind == Kind::RepoState)
&& author;
// NIP-22 comments carry no `a` tag.
// Coordinate matching fails for them.
// Any comment may reference this repository's roots.
let comment = update.kind == Kind::Comment;
// Status events may omit their `a` tag, NIP-34.
// Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || authored || comment || status
@@ -226,12 +222,11 @@ impl RepoStore {
let announcement = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
// State events carry their repository id in the `d` tag.
let state = event.kind == Kind::RepoState
&& author
&& event.tags.identifier().as_deref() == Some(addr.identifier.as_str());
// Locally published deletions may target any event of this repository.
// Refresh so they take effect immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
@@ -264,8 +259,6 @@ impl RepoStore {
/// Announcement, state, activity and deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![
// Announcement and state share author and identifier.
// They combine into one filter, one fewer negentropy reconciliation per relay.
Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.author(addr.public_key)
@@ -314,9 +307,6 @@ impl RepoStore {
}
/// Re-query the local database and update all fields.
///
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.addr.is_none() {
return;
@@ -353,11 +343,10 @@ impl RepoStore {
let deletions = Deletions::from_events(deletion_events);
// Parse and sort off the main thread.
// Only plain data crosses back into the entity.
let all_announcements = announcements
.into_iter()
.filter(|e| !deletions.is_deleted(e));
let announcement = latest(all_announcements)
.as_ref()
.and_then(Announcement::from_event);
@@ -382,8 +371,6 @@ impl RepoStore {
}
}
// NIP-22 comments reference their root via an `E` or `e` tag.
// Not the repository's `a` tag, so query them by the root events.
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
let db = client.database();
@@ -401,8 +388,6 @@ impl RepoStore {
}
}
// Status events may omit their `a` tag.
// Query them by the root events they reference too.
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
let db = client.database();
@@ -425,9 +410,6 @@ impl RepoStore {
sort_newest_first(&mut pull_requests);
sort_oldest_first(&mut comments);
// Resolve every root's status once here.
// Render paths do HashMap lookups instead of per-root status scans.
// Those scans are quadratic, with an allocation per pair.
let maintainers = announcement
.as_ref()
.map(Announcement::effective_maintainers)
@@ -485,25 +467,13 @@ impl RepoStore {
};
let again = this.update(cx, |this, cx| {
// Compare before moving the freshly queried data in, so a pass
// that found nothing new does not notify observers. The store
// is polled in bursts while a sync is in flight; notifying on
// every identical pass would re-render the repository panel
// several times for no visible change.
//
// The first pass is the exception: it must notify even when it
// found nothing, so views can leave their loading state and show
// the empty result.
//
// Keep the open-time hint until that first pass has confirmed what
// the database holds; afterwards the database is the truth,
// including a deletion.
let keep_hint = announcement.is_none() && !this.loaded;
let first_pass = !this.loaded;
let head_changed = state
.as_ref()
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
let changed = first_pass
|| (!keep_hint && this.announcement != announcement)
|| head_changed
@@ -524,6 +494,7 @@ impl RepoStore {
.as_ref()
.map(|a| a.relays.clone())
.unwrap_or_default();
this.connect_announced_relays(&relays, cx);
if let Some((_, head)) = state {
@@ -539,10 +510,6 @@ impl RepoStore {
this.open_pr_count = open_pr_count;
this.loaded = true;
// Comments and statuses without an `a` tag.
// None are addressed to the repository.
// Fetch them by the root events they reference.
// Use the bootstrap relays and the relays this repository announced.
let roots = this
.issues
.iter()
@@ -559,13 +526,13 @@ impl RepoStore {
if !new_roots.is_empty() {
this.root_fetches.extend(new_roots.iter().copied());
// Batch the per-root filters.
// One filter per root costs a negentropy reconciliation per relay.
let mut root_filters = filters::comments_for(new_roots.clone());
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(root_filters.clone(), cx);
backend.connect_repo_relays(announced, root_filters, cx);
@@ -579,8 +546,6 @@ impl RepoStore {
this.refresh.finish()
})?;
// Requests that arrived while the refresh was running.
// They are coalesced into one follow-up refresh.
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
@@ -596,25 +561,16 @@ impl RepoStore {
}
/// Number of open issues.
///
/// Issues whose resolved status is [`RepoStatus::Open`].
/// Issues without status events default to open.
pub fn issue_count(&self) -> usize {
self.open_issue_count
}
/// Number of open pull requests.
///
/// Only root PR events count, PR updates do not.
/// They must resolve to [`RepoStatus::Open`].
pub fn pull_request_count(&self) -> usize {
self.open_pr_count
}
/// Whether `user` is the author or owner of this repository.
///
/// The author is the public key of the repository address.
/// Only the author may manage pull requests, close, reopen or merge.
pub fn is_author(&self, user: &PublicKey) -> bool {
self.addr
.as_ref()
+35 -17
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
@@ -47,6 +47,8 @@ pub struct RepoListStore {
///
/// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
/// Own repositories whose state events were fetched from their announced relays.
state_synced_repos: HashSet<RepoAddr>,
refresh: RefreshGate,
_subscription: Subscription,
}
@@ -82,21 +84,16 @@ impl RepoListStore {
}),
BackendEvent::Published(event) => {
let announcement = event.kind == Kind::GitRepoAnnouncement;
// Locally published state events advance last activity.
// Refresh so a push made in this session dates the card immediately.
let state = event.kind == Kind::RepoState;
// Locally published deletions are already in the local database.
// Refresh so they take effect immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
announcement || state || deletion
}
// Only a completed sync refreshes the list.
// Progress ticks would re-scan the whole database several times
// per sync to reveal entries incrementally.
BackendEvent::SignerChanged => {
this.state_synced_repos.clear();
true
}
BackendEvent::Synced => true,
_ => false,
};
@@ -118,6 +115,7 @@ impl RepoListStore {
announcements: Arc::new(Vec::new()),
last_activity: Arc::new(HashMap::new()),
counts: Arc::new(HashMap::new()),
state_synced_repos: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: subscription,
}
@@ -145,6 +143,30 @@ impl RepoListStore {
});
}
/// Fetch the state events of the user's own repositories.
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let Some(me) = backend.read(cx).current_user() else {
return;
};
let pending: Vec<(RepoAddr, Vec<RelayUrl>)> = self
.announcements
.iter()
.filter(|announcement| announcement.owner == me && !announcement.relays.is_empty())
.map(|announcement| (announcement.addr(), announcement.relays.clone()))
.filter(|(addr, _)| !self.state_synced_repos.contains(addr))
.collect();
for (addr, relays) in pending {
self.state_synced_repos.insert(addr.clone());
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx);
});
}
}
/// Re-query the local database.
///
/// Runs immediately. The backend pump already batches the relay events that
@@ -196,9 +218,6 @@ impl RepoListStore {
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
// Last activity per repository.
// State updates count, and all NIP-34 activity events.
// The activity events are patches, PRs, issues and statuses.
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
.iter()
.map(|a| (a.addr(), a.created_at))
@@ -224,6 +243,7 @@ impl RepoListStore {
let activity_filter = Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.since(Timestamp::now() - ACTIVITY_WINDOW);
for event in client.database().query(activity_filter).await? {
if deletions.is_deleted(&event) {
continue;
@@ -232,8 +252,6 @@ impl RepoListStore {
if addr.kind != Kind::GitRepoAnnouncement {
continue;
}
// Skip events for repos we do not list.
// The map cannot grow beyond the number of announcements.
let Some(entry) = last_activity.get_mut(&addr) else {
continue;
};
@@ -246,13 +264,12 @@ impl RepoListStore {
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
let count_filter =
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
for event in client.database().query(count_filter).await? {
if deletions.is_deleted(&event) {
continue;
}
for addr in event.tags.coordinates() {
// Skip events for repos we do not list.
// The map cannot grow beyond the number of announcements.
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
{
continue;
@@ -285,6 +302,7 @@ impl RepoListStore {
this.announcements = Arc::new(announcements);
this.last_activity = Arc::new(last_activity);
this.counts = Arc::new(counts);
this.sync_own_repo_states(cx);
cx.notify();
this.refresh.finish()
+7 -8
View File
@@ -130,12 +130,12 @@ flowchart TD
announcements. Caveat: coverage depends on whether state events reach the bootstrap
relays; if a grasp server is the only holder, those repositories remain unseen.
- **B. Targeted fetch for the user's own repositories. — not implemented.**
In `sync_inbox` (`crates/signed_state/src/backend.rs` L1126-1152), where the user's
own repositories' relays are already connected, also sync `filters::state(addr)` per
own repository. This directly addresses "my repositories updated elsewhere" at low
cost. Note the existing race: `sync_inbox` reads the announcement list right after
login, which may not be loaded yet.
- **B. Targeted fetch for the user's own repositories. — implemented.**
`RepoListStore` fetches each own repository's state event from the relays that
repository announces, once the list discovers it. Driving this from the list rather
than `sync_inbox` sidesteps the login race described above: whenever the announcement
list is (re)loaded, any own repository not yet fetched is covered. The fetched-set is
cleared on `SignerChanged`, so a login or account switch re-fetches.
- **C. Reactivity fixes. — implemented.**
`Kind::RepoState` is now included in the `BackendEvent::Published` refresh condition of
@@ -144,5 +144,4 @@ flowchart TD
matching misses them — and locally published state updates by owner plus the `d`
identifier.
A + C are implemented; B remains available if own-repository freshness on fresh profiles
matters.
A + B + C are implemented.